mirror of
https://github.com/wahyd4/you-music.git
synced 2026-08-08 20:59:47 +10:00
Now it can download , play add to playlist, artist list page
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from typing import List
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.models import Music
|
||||
from app.schemas.schemas import Music as MusicSchema
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class Artist(BaseModel):
|
||||
name: str
|
||||
song_count: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/", response_model=List[Artist])
|
||||
async def get_artists(db: AsyncSession = Depends(get_db)):
|
||||
"""Get all artists with song counts"""
|
||||
result = await db.execute(
|
||||
select(
|
||||
Music.artist,
|
||||
func.count(Music.id).label('song_count')
|
||||
)
|
||||
.where(Music.artist.isnot(None))
|
||||
.where(Music.artist != "")
|
||||
.where(Music.artist != "Unknown")
|
||||
.group_by(Music.artist)
|
||||
.order_by(func.count(Music.id).desc())
|
||||
)
|
||||
|
||||
artists = []
|
||||
for row in result:
|
||||
artists.append(Artist(name=row[0], song_count=row[1]))
|
||||
|
||||
return artists
|
||||
|
||||
|
||||
@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"""
|
||||
result = await db.execute(
|
||||
select(Music)
|
||||
.where(Music.artist == artist_name)
|
||||
.order_by(Music.created_at.desc())
|
||||
)
|
||||
songs = result.scalars().all()
|
||||
return songs
|
||||
@@ -47,7 +47,8 @@ async def process_download(
|
||||
file_path=relative_path,
|
||||
file_size=os.path.getsize(file_path),
|
||||
source_url=url,
|
||||
source_type=source_type
|
||||
source_type=source_type,
|
||||
thumbnail=metadata.get("thumbnail") # Add thumbnail
|
||||
)
|
||||
|
||||
db.add(db_music)
|
||||
|
||||
+13
-10
@@ -1,6 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List
|
||||
|
||||
from app.db.session import get_db
|
||||
@@ -13,7 +14,9 @@ router = APIRouter()
|
||||
@router.get("/", response_model=List[PlaylistSchema])
|
||||
async def get_playlists(db: AsyncSession = Depends(get_db)):
|
||||
"""Get all playlists"""
|
||||
result = await db.execute(select(Playlist))
|
||||
result = await db.execute(
|
||||
select(Playlist).options(selectinload(Playlist.music_items))
|
||||
)
|
||||
playlists = result.scalars().all()
|
||||
return playlists
|
||||
|
||||
@@ -35,7 +38,7 @@ async def create_playlist(
|
||||
db_playlist = Playlist(**playlist.model_dump())
|
||||
db.add(db_playlist)
|
||||
await db.commit()
|
||||
await db.refresh(db_playlist)
|
||||
await db.refresh(db_playlist, ['music_items'])
|
||||
return db_playlist
|
||||
|
||||
|
||||
@@ -43,7 +46,7 @@ async def create_playlist(
|
||||
async def get_playlist(playlist_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Get a specific playlist with all its music"""
|
||||
result = await db.execute(
|
||||
select(Playlist).where(Playlist.id == playlist_id)
|
||||
select(Playlist).options(selectinload(Playlist.music_items)).where(Playlist.id == playlist_id)
|
||||
)
|
||||
playlist = result.scalar_one_or_none()
|
||||
if not playlist:
|
||||
@@ -59,7 +62,7 @@ async def update_playlist(
|
||||
):
|
||||
"""Update playlist details"""
|
||||
result = await db.execute(
|
||||
select(Playlist).where(Playlist.id == playlist_id)
|
||||
select(Playlist).options(selectinload(Playlist.music_items)).where(Playlist.id == playlist_id)
|
||||
)
|
||||
playlist = result.scalar_one_or_none()
|
||||
if not playlist:
|
||||
@@ -69,7 +72,7 @@ async def update_playlist(
|
||||
setattr(playlist, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(playlist)
|
||||
await db.refresh(playlist, ['music_items'])
|
||||
return playlist
|
||||
|
||||
|
||||
@@ -96,9 +99,9 @@ async def add_music_to_playlist(
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Add music to playlist"""
|
||||
# Get playlist
|
||||
# Get playlist with music_items loaded
|
||||
result = await db.execute(
|
||||
select(Playlist).where(Playlist.id == playlist_id)
|
||||
select(Playlist).options(selectinload(Playlist.music_items)).where(Playlist.id == playlist_id)
|
||||
)
|
||||
playlist = result.scalar_one_or_none()
|
||||
if not playlist:
|
||||
@@ -130,9 +133,9 @@ async def remove_music_from_playlist(
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Remove music from playlist"""
|
||||
# Get playlist
|
||||
# Get playlist with music_items loaded
|
||||
result = await db.execute(
|
||||
select(Playlist).where(Playlist.id == playlist_id)
|
||||
select(Playlist).options(selectinload(Playlist.music_items)).where(Playlist.id == playlist_id)
|
||||
)
|
||||
playlist = result.scalar_one_or_none()
|
||||
if not playlist:
|
||||
@@ -162,7 +165,7 @@ async def get_playlist_by_name(
|
||||
):
|
||||
"""Get playlist by name"""
|
||||
result = await db.execute(
|
||||
select(Playlist).where(Playlist.name == playlist_name)
|
||||
select(Playlist).options(selectinload(Playlist.music_items)).where(Playlist.name == playlist_name)
|
||||
)
|
||||
playlist = result.scalar_one_or_none()
|
||||
if not playlist:
|
||||
|
||||
@@ -33,6 +33,45 @@ class MusicDownloader:
|
||||
Returns: (success, file_path, error_message)
|
||||
"""
|
||||
try:
|
||||
# First, extract info to get metadata
|
||||
info_cmd = [
|
||||
"yt-dlp",
|
||||
"--dump-json",
|
||||
"--no-playlist",
|
||||
url
|
||||
]
|
||||
|
||||
if self.proxy:
|
||||
info_cmd.extend(["--proxy", self.proxy])
|
||||
|
||||
info_process = await asyncio.create_subprocess_exec(
|
||||
*info_cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
info_stdout, _ = await info_process.communicate()
|
||||
|
||||
# Extract metadata from info
|
||||
import json
|
||||
video_info = {}
|
||||
artist_name = None
|
||||
thumbnail_url = None
|
||||
if info_process.returncode == 0 and info_stdout:
|
||||
try:
|
||||
video_info = json.loads(info_stdout.decode())
|
||||
# Try to extract artist from various fields
|
||||
artist_name = (
|
||||
video_info.get('artist') or
|
||||
video_info.get('creator') or
|
||||
video_info.get('uploader') or
|
||||
video_info.get('channel')
|
||||
)
|
||||
# Get best thumbnail
|
||||
thumbnail_url = video_info.get('thumbnail')
|
||||
except:
|
||||
pass
|
||||
|
||||
# Prepare output template
|
||||
if output_name:
|
||||
title = f"{output_name}.%(ext)s"
|
||||
@@ -49,6 +88,9 @@ class MusicDownloader:
|
||||
"--paths", self.download_path,
|
||||
"-o", title,
|
||||
"--ffmpeg-location", self.ffmpeg_location,
|
||||
"--embed-metadata", # Embed metadata
|
||||
"--parse-metadata", "%(artist)s:%(meta_artist)s", # Parse artist
|
||||
"--parse-metadata", "%(uploader)s:%(meta_artist)s", # Fallback to uploader
|
||||
]
|
||||
|
||||
if self.proxy:
|
||||
@@ -71,6 +113,17 @@ class MusicDownloader:
|
||||
# Find the downloaded file
|
||||
output_file = await self._find_downloaded_file(output_name)
|
||||
if output_file:
|
||||
# Download and embed thumbnail
|
||||
thumbnail_path = None
|
||||
if thumbnail_url:
|
||||
thumbnail_path = await self._download_thumbnail(thumbnail_url, output_file)
|
||||
if thumbnail_path:
|
||||
await self._embed_thumbnail(output_file, thumbnail_path)
|
||||
|
||||
# Embed artist info if we extracted it
|
||||
if artist_name:
|
||||
await self._embed_artist_metadata(output_file, artist_name, video_info)
|
||||
|
||||
logger.info(f"Download successful: {output_file}")
|
||||
return True, output_file, None
|
||||
else:
|
||||
@@ -109,6 +162,89 @@ class MusicDownloader:
|
||||
logger.error(f"Error finding downloaded file: {e}")
|
||||
return None
|
||||
|
||||
async def _embed_artist_metadata(self, file_path: str, artist: str, video_info: dict = None):
|
||||
"""Embed artist and other metadata into MP3 file"""
|
||||
try:
|
||||
audio = MP3(file_path, ID3=ID3)
|
||||
|
||||
# Add ID3 tag if it doesn't exist
|
||||
try:
|
||||
audio.add_tags()
|
||||
except mutagen.id3.error:
|
||||
pass
|
||||
|
||||
# Set artist
|
||||
audio.tags.add(TPE1(encoding=3, text=artist))
|
||||
|
||||
# Set title if available
|
||||
if video_info and video_info.get('title'):
|
||||
audio.tags.add(TIT2(encoding=3, text=video_info['title']))
|
||||
|
||||
# Set album if available
|
||||
if video_info and video_info.get('album'):
|
||||
audio.tags.add(TALB(encoding=3, text=video_info['album']))
|
||||
|
||||
audio.save()
|
||||
logger.info(f"Embedded artist metadata: {artist}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not embed metadata: {e}")
|
||||
|
||||
async def _download_thumbnail(self, thumbnail_url: str, audio_file_path: str) -> Optional[str]:
|
||||
"""Download thumbnail from URL"""
|
||||
try:
|
||||
# Create thumbnails directory
|
||||
thumbnails_dir = os.path.join(settings.MUSIC_DIR, "thumbnails")
|
||||
os.makedirs(thumbnails_dir, exist_ok=True)
|
||||
|
||||
# Generate thumbnail filename based on audio file
|
||||
audio_basename = os.path.splitext(os.path.basename(audio_file_path))[0]
|
||||
thumbnail_path = os.path.join(thumbnails_dir, f"{audio_basename}.jpg")
|
||||
|
||||
# Download thumbnail
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(thumbnail_url) as response:
|
||||
if response.status == 200:
|
||||
with open(thumbnail_path, 'wb') as f:
|
||||
f.write(await response.read())
|
||||
logger.info(f"Downloaded thumbnail: {thumbnail_path}")
|
||||
return thumbnail_path
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not download thumbnail: {e}")
|
||||
return None
|
||||
|
||||
async def _embed_thumbnail(self, audio_file_path: str, thumbnail_path: str):
|
||||
"""Embed thumbnail into MP3 file"""
|
||||
try:
|
||||
audio = MP3(audio_file_path, ID3=ID3)
|
||||
|
||||
# Add ID3 tag if it doesn't exist
|
||||
try:
|
||||
audio.add_tags()
|
||||
except mutagen.id3.error:
|
||||
pass
|
||||
|
||||
# Read thumbnail data
|
||||
with open(thumbnail_path, 'rb') as img_file:
|
||||
img_data = img_file.read()
|
||||
|
||||
# Add cover art
|
||||
audio.tags.add(
|
||||
APIC(
|
||||
encoding=3,
|
||||
mime='image/jpeg',
|
||||
type=3, # Cover (front)
|
||||
desc='Cover',
|
||||
data=img_data
|
||||
)
|
||||
)
|
||||
|
||||
audio.save()
|
||||
logger.info(f"Embedded thumbnail into {audio_file_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not embed thumbnail: {e}")
|
||||
|
||||
async def download_playlist(
|
||||
self,
|
||||
url: str,
|
||||
@@ -162,17 +298,43 @@ class MusicDownloader:
|
||||
async def get_music_metadata(self, file_path: str) -> dict:
|
||||
"""Extract metadata from audio file using mutagen"""
|
||||
try:
|
||||
audio = mutagen.File(file_path, easy=True)
|
||||
if audio is None:
|
||||
# First get basic metadata with easy=True
|
||||
audio_easy = mutagen.File(file_path, easy=True)
|
||||
if audio_easy is None:
|
||||
return {}
|
||||
|
||||
metadata = {
|
||||
"title": audio.get("title", [os.path.basename(file_path)])[0] if audio.get("title") else os.path.basename(file_path),
|
||||
"artist": audio.get("artist", ["Unknown"])[0] if audio.get("artist") else "Unknown",
|
||||
"album": audio.get("album", [""])[0] if audio.get("album") else "",
|
||||
"duration": audio.info.length if hasattr(audio, 'info') else 0,
|
||||
"title": audio_easy.get("title", [os.path.basename(file_path)])[0] if audio_easy.get("title") else os.path.basename(file_path),
|
||||
"artist": audio_easy.get("artist", ["Unknown"])[0] if audio_easy.get("artist") else "Unknown",
|
||||
"album": audio_easy.get("album", [""])[0] if audio_easy.get("album") else "",
|
||||
"duration": audio_easy.info.length if hasattr(audio_easy, 'info') else 0,
|
||||
}
|
||||
|
||||
# Now extract thumbnail from ID3 tags
|
||||
try:
|
||||
audio = MP3(file_path, ID3=ID3)
|
||||
if audio.tags:
|
||||
# Look for APIC (attached picture) frames
|
||||
for tag in audio.tags.values():
|
||||
if isinstance(tag, APIC):
|
||||
# Save thumbnail to thumbnails directory
|
||||
thumbnails_dir = os.path.join(settings.MUSIC_DIR, "thumbnails")
|
||||
os.makedirs(thumbnails_dir, exist_ok=True)
|
||||
|
||||
audio_basename = os.path.splitext(os.path.basename(file_path))[0]
|
||||
thumbnail_filename = f"{audio_basename}.jpg"
|
||||
thumbnail_path = os.path.join(thumbnails_dir, thumbnail_filename)
|
||||
|
||||
# Save thumbnail
|
||||
with open(thumbnail_path, 'wb') as img_file:
|
||||
img_file.write(tag.data)
|
||||
|
||||
# Store relative path for database
|
||||
metadata["thumbnail"] = f"thumbnails/{thumbnail_filename}"
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not extract thumbnail: {e}")
|
||||
|
||||
return metadata
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting metadata: {e}")
|
||||
|
||||
+2
-1
@@ -3,7 +3,7 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from app.api import music, playlist, download, search, stream
|
||||
from app.api import music, playlist, download, search, stream, artist
|
||||
from app.core.config import settings
|
||||
from app.db.session import init_db
|
||||
|
||||
@@ -43,6 +43,7 @@ app.include_router(playlist.router, prefix="/api/playlists", tags=["playlists"])
|
||||
app.include_router(download.router, prefix="/api/download", tags=["download"])
|
||||
app.include_router(search.router, prefix="/api/search", tags=["search"])
|
||||
app.include_router(stream.router, prefix="/api", tags=["stream"])
|
||||
app.include_router(artist.router, prefix="/api/artists", tags=["artists"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -6,6 +6,8 @@ import MusicLibrary from './components/MusicLibrary'
|
||||
import SearchPage from './components/search/SearchPage'
|
||||
import PlaylistsPage from './components/playlist/PlaylistsPage'
|
||||
import DownloadCenter from './components/download/DownloadCenter'
|
||||
import ArtistsPage from './components/artist/ArtistsPage'
|
||||
import ArtistDetailPage from './components/artist/ArtistDetailPage'
|
||||
import Navigation from './components/Navigation'
|
||||
import { Toaster } from 'sonner'
|
||||
|
||||
@@ -82,6 +84,8 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<MusicLibrary onPlayMusic={playMusic} />} />
|
||||
<Route path="/search" element={<SearchPage onPlayMusic={playMusic} />} />
|
||||
<Route path="/artists" element={<ArtistsPage />} />
|
||||
<Route path="/artists/:artistName" element={<ArtistDetailPage onPlayMusic={playMusic} />} />
|
||||
<Route path="/playlists" element={<PlaylistsPage onPlayMusic={playMusic} />} />
|
||||
<Route path="/downloads" element={<DownloadCenter onPlayMusic={playMusic} />} />
|
||||
</Routes>
|
||||
|
||||
@@ -61,3 +61,9 @@ export const searchApi = {
|
||||
searchBilibili: (query: string, limit: number = 10) =>
|
||||
api.get('/search/bilibili', { params: { q: query, limit } }),
|
||||
}
|
||||
|
||||
// Artist API
|
||||
export const artistApi = {
|
||||
getAll: () => api.get('/artists/'),
|
||||
getArtistSongs: (artistName: string) => api.get(`/artists/${encodeURIComponent(artistName)}`),
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { musicApi } from '@/api/client'
|
||||
import { Music } from '@/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -9,6 +10,7 @@ interface MusicLibraryProps {
|
||||
}
|
||||
|
||||
export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
|
||||
const navigate = useNavigate()
|
||||
const { data: musicList = [], isLoading } = useQuery({
|
||||
queryKey: ['music'],
|
||||
queryFn: async () => {
|
||||
@@ -31,6 +33,18 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
|
||||
key={music.id}
|
||||
className="flex items-center gap-4 p-3 rounded-lg hover:bg-accent transition-colors"
|
||||
>
|
||||
{music.thumbnail ? (
|
||||
<img
|
||||
src={`/music/${music.thumbnail}`}
|
||||
alt={music.title}
|
||||
className="w-12 h-12 rounded object-cover flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded bg-secondary flex items-center justify-center flex-shrink-0">
|
||||
<Play className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -43,9 +57,18 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium truncate">{music.title}</h3>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{music.artist || 'Unknown Artist'}
|
||||
</p>
|
||||
{music.artist && music.artist !== 'Unknown' ? (
|
||||
<button
|
||||
onClick={() => navigate(`/artists/${encodeURIComponent(music.artist!)}`)}
|
||||
className="text-sm text-muted-foreground truncate hover:underline text-left"
|
||||
>
|
||||
{music.artist}
|
||||
</button>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{music.artist || 'Unknown Artist'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { Home, Search, ListMusic, Download } from 'lucide-react'
|
||||
import { Home, Search, ListMusic, Download, Music2 } from 'lucide-react'
|
||||
|
||||
export default function Navigation() {
|
||||
const location = useLocation()
|
||||
@@ -7,6 +7,7 @@ export default function Navigation() {
|
||||
const navItems = [
|
||||
{ path: '/', label: 'Library', icon: Home },
|
||||
{ path: '/search', label: 'Search', icon: Search },
|
||||
{ path: '/artists', label: 'Artists', icon: Music2 },
|
||||
{ path: '/playlists', label: 'Playlists', icon: ListMusic },
|
||||
{ path: '/downloads', label: 'Downloads', icon: Download },
|
||||
]
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { artistApi } from '@/api/client'
|
||||
import { Music } from '@/types'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft, Play, Loader2 } from 'lucide-react'
|
||||
import { formatDuration } from '@/lib/utils'
|
||||
|
||||
interface ArtistDetailProps {
|
||||
onPlayMusic: (music: Music, musicList?: Music[]) => void
|
||||
}
|
||||
|
||||
export default function ArtistDetailPage({ onPlayMusic }: ArtistDetailProps) {
|
||||
const { artistName } = useParams<{ artistName: string }>()
|
||||
const navigate = useNavigate()
|
||||
const decodedArtistName = decodeURIComponent(artistName || '')
|
||||
|
||||
const { data: songs, isLoading } = useQuery({
|
||||
queryKey: ['artist-songs', decodedArtistName],
|
||||
queryFn: async () => {
|
||||
const response = await artistApi.getArtistSongs(decodedArtistName)
|
||||
return response.data as Music[]
|
||||
},
|
||||
enabled: !!decodedArtistName,
|
||||
})
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (songs && songs.length > 0) {
|
||||
onPlayMusic(songs[0], songs)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-screen-xl mx-auto p-4">
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-screen-xl mx-auto p-4">
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/artists')}
|
||||
className="mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Artists
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{decodedArtistName}</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{songs?.length || 0} {songs?.length === 1 ? 'song' : 'songs'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{songs && songs.length > 0 && (
|
||||
<Button onClick={handlePlayAll} size="lg">
|
||||
<Play className="h-5 w-5 mr-2" />
|
||||
Play All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{songs && songs.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{songs.map((song, index) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className="flex items-center gap-4 p-3 rounded-lg hover:bg-accent cursor-pointer group"
|
||||
onClick={() => onPlayMusic(song, songs)}
|
||||
>
|
||||
<div className="w-8 text-center text-muted-foreground group-hover:hidden">
|
||||
{index + 1}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hidden group-hover:flex w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPlayMusic(song, songs)
|
||||
}}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{song.thumbnail ? (
|
||||
<img
|
||||
src={`/music/${song.thumbnail}`}
|
||||
alt={song.title}
|
||||
className="w-12 h-12 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded bg-secondary" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium truncate">{song.title}</h3>
|
||||
{song.album && (
|
||||
<p className="text-sm text-muted-foreground truncate">{song.album}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatDuration(song.duration || 0)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">No songs found for this artist</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { artistApi } from '@/api/client'
|
||||
import { Artist } from '@/types'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Music2, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function ArtistsPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: artists, isLoading } = useQuery({
|
||||
queryKey: ['artists'],
|
||||
queryFn: async () => {
|
||||
const response = await artistApi.getAll()
|
||||
return response.data as Artist[]
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-screen-xl mx-auto p-4">
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-screen-xl mx-auto p-4">
|
||||
<h2 className="text-2xl font-bold mb-6">Artists</h2>
|
||||
|
||||
{artists && artists.length > 0 ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{artists.map((artist) => (
|
||||
<button
|
||||
key={artist.name}
|
||||
onClick={() => navigate(`/artists/${encodeURIComponent(artist.name)}`)}
|
||||
className="p-4 rounded-lg bg-card hover:bg-accent transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Music2 className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold truncate">{artist.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{artist.song_count} {artist.song_count === 1 ? 'song' : 'songs'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Music2 className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">No artists yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Download some music to see artists here!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Music } from '@/types'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'
|
||||
import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Heart, ListPlus } from 'lucide-react'
|
||||
import { formatDuration } from '@/lib/utils'
|
||||
import { playlistApi } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
import PlaylistSelector from './PlaylistSelector'
|
||||
|
||||
interface PlayerProps {
|
||||
currentMusic: Music | null
|
||||
@@ -26,6 +31,61 @@ export default function Player({
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [volume, setVolume] = useState(1)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
const [showPlaylistSelector, setShowPlaylistSelector] = useState(false)
|
||||
const [isLiked, setIsLiked] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Check if current music is in Liked playlist
|
||||
useQuery({
|
||||
queryKey: ['liked-playlist', currentMusic?.id],
|
||||
queryFn: async () => {
|
||||
if (!currentMusic) return null
|
||||
try {
|
||||
const response = await playlistApi.getByName('Liked')
|
||||
const likedPlaylist = response.data
|
||||
const inLiked = likedPlaylist.music_items?.some((m: Music) => m.id === currentMusic.id)
|
||||
setIsLiked(inLiked)
|
||||
return likedPlaylist
|
||||
} catch {
|
||||
setIsLiked(false)
|
||||
return null
|
||||
}
|
||||
},
|
||||
enabled: !!currentMusic && currentMusic.id !== 0,
|
||||
})
|
||||
|
||||
const toggleLikeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!currentMusic) return
|
||||
|
||||
// Ensure Liked playlist exists
|
||||
let likedPlaylist
|
||||
try {
|
||||
const response = await playlistApi.getByName('Liked')
|
||||
likedPlaylist = response.data
|
||||
} catch {
|
||||
// Create Liked playlist if it doesn't exist
|
||||
const createResponse = await playlistApi.create({ name: 'Liked', description: 'Your favorite songs' })
|
||||
likedPlaylist = createResponse.data
|
||||
}
|
||||
|
||||
if (isLiked) {
|
||||
await playlistApi.removeMusic(likedPlaylist.id, currentMusic.id)
|
||||
} else {
|
||||
await playlistApi.addMusic(likedPlaylist.id, currentMusic.id)
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsLiked(!isLiked)
|
||||
queryClient.invalidateQueries({ queryKey: ['liked-playlist'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['playlists'] })
|
||||
toast.success(isLiked ? 'Removed from Liked' : 'Added to Liked')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update Liked')
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
@@ -75,89 +135,139 @@ export default function Player({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-card border-t border-border p-4 md:p-6">
|
||||
<div className="max-w-screen-xl mx-auto">
|
||||
{/* Progress bar */}
|
||||
<div className="mb-3">
|
||||
<Slider
|
||||
value={[currentTime]}
|
||||
max={duration || 100}
|
||||
step={0.1}
|
||||
onValueChange={handleSeek}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground mt-1">
|
||||
<span>{formatDuration(currentTime)}</span>
|
||||
<span>{formatDuration(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Music info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold truncate">{currentMusic.title}</h3>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{currentMusic.artist || 'Unknown Artist'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onPrevious}
|
||||
className="hidden md:flex"
|
||||
>
|
||||
<SkipBack className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
onClick={onTogglePlay}
|
||||
className="h-10 w-10"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-5 w-5" />
|
||||
) : (
|
||||
<Play className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onNext}
|
||||
className="hidden md:flex"
|
||||
>
|
||||
<SkipForward className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Volume control - desktop only */}
|
||||
<div className="hidden md:flex items-center gap-2 flex-1 justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleMute}
|
||||
>
|
||||
{isMuted || volume === 0 ? (
|
||||
<VolumeX className="h-5 w-5" />
|
||||
) : (
|
||||
<Volume2 className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
<>
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-card border-t border-border p-4 md:p-6">
|
||||
<div className="max-w-screen-xl mx-auto">
|
||||
{/* Progress bar */}
|
||||
<div className="mb-3">
|
||||
<Slider
|
||||
value={[isMuted ? 0 : volume]}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onValueChange={handleVolumeChange}
|
||||
className="w-24"
|
||||
value={[currentTime]}
|
||||
max={duration || 100}
|
||||
step={0.1}
|
||||
onValueChange={handleSeek}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground mt-1">
|
||||
<span>{formatDuration(currentTime)}</span>
|
||||
<span>{formatDuration(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Album art and music info */}
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{currentMusic.thumbnail && (
|
||||
<img
|
||||
src={`/music/${currentMusic.thumbnail}`}
|
||||
alt={currentMusic.title}
|
||||
className="w-14 h-14 rounded object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold truncate">{currentMusic.title}</h3>
|
||||
{currentMusic.artist && currentMusic.artist !== 'Unknown' ? (
|
||||
<button
|
||||
onClick={() => navigate(`/artists/${encodeURIComponent(currentMusic.artist!)}`)}
|
||||
className="text-sm text-muted-foreground truncate hover:underline text-left"
|
||||
>
|
||||
{currentMusic.artist}
|
||||
</button>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{currentMusic.artist || 'Unknown Artist'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Like and Playlist buttons */}
|
||||
{currentMusic.id !== 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => toggleLikeMutation.mutate()}
|
||||
disabled={toggleLikeMutation.isPending}
|
||||
>
|
||||
<Heart className={`h-5 w-5 ${isLiked ? 'fill-red-500 text-red-500' : ''}`} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowPlaylistSelector(true)}
|
||||
>
|
||||
<ListPlus className="h-5 w-5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onPrevious}
|
||||
className="hidden md:flex"
|
||||
>
|
||||
<SkipBack className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
onClick={onTogglePlay}
|
||||
className="h-10 w-10"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-5 w-5" />
|
||||
) : (
|
||||
<Play className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onNext}
|
||||
className="hidden md:flex"
|
||||
>
|
||||
<SkipForward className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Volume control - desktop only */}
|
||||
<div className="hidden md:flex items-center gap-2 flex-1 justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleMute}
|
||||
>
|
||||
{isMuted || volume === 0 ? (
|
||||
<VolumeX className="h-5 w-5" />
|
||||
) : (
|
||||
<Volume2 className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
<Slider
|
||||
value={[isMuted ? 0 : volume]}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onValueChange={handleVolumeChange}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playlist Selector Modal */}
|
||||
{showPlaylistSelector && currentMusic && (
|
||||
<PlaylistSelector
|
||||
music={currentMusic}
|
||||
onClose={() => setShowPlaylistSelector(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { playlistApi } from '@/api/client'
|
||||
import { Music, Playlist } from '@/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X, Plus, Check } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface PlaylistSelectorProps {
|
||||
music: Music
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function PlaylistSelector({ music, onClose }: PlaylistSelectorProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [newPlaylistName, setNewPlaylistName] = useState('')
|
||||
|
||||
const { data: playlists, isLoading } = useQuery({
|
||||
queryKey: ['playlists'],
|
||||
queryFn: async () => {
|
||||
const response = await playlistApi.getAll()
|
||||
return response.data as Playlist[]
|
||||
},
|
||||
})
|
||||
|
||||
const createPlaylistMutation = useMutation({
|
||||
mutationFn: (name: string) => playlistApi.create({ name }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['playlists'] })
|
||||
setNewPlaylistName('')
|
||||
toast.success('Playlist created')
|
||||
},
|
||||
})
|
||||
|
||||
const addToPlaylistMutation = useMutation({
|
||||
mutationFn: ({ playlistId }: { playlistId: number }) =>
|
||||
playlistApi.addMusic(playlistId, music.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['playlists'] })
|
||||
toast.success('Added to playlist')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to add to playlist')
|
||||
},
|
||||
})
|
||||
|
||||
const removeFromPlaylistMutation = useMutation({
|
||||
mutationFn: ({ playlistId }: { playlistId: number }) =>
|
||||
playlistApi.removeMusic(playlistId, music.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['playlists'] })
|
||||
toast.success('Removed from playlist')
|
||||
},
|
||||
})
|
||||
|
||||
const isInPlaylist = (playlist: Playlist) => {
|
||||
return playlist.music_items?.some(m => m.id === music.id)
|
||||
}
|
||||
|
||||
const handleTogglePlaylist = (playlist: Playlist) => {
|
||||
if (isInPlaylist(playlist)) {
|
||||
removeFromPlaylistMutation.mutate({ playlistId: playlist.id })
|
||||
} else {
|
||||
addToPlaylistMutation.mutate({ playlistId: playlist.id })
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreatePlaylist = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (newPlaylistName.trim()) {
|
||||
createPlaylistMutation.mutate(newPlaylistName.trim())
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-card rounded-lg p-6 max-w-md w-full mx-4 max-h-[80vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold">Add to Playlist</h2>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 p-3 bg-accent rounded-lg">
|
||||
<p className="font-medium truncate">{music.title}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{music.artist || 'Unknown'}</p>
|
||||
</div>
|
||||
|
||||
{/* Create new playlist */}
|
||||
<form onSubmit={handleCreatePlaylist} className="mb-4">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New playlist name..."
|
||||
value={newPlaylistName}
|
||||
onChange={(e) => setNewPlaylistName(e.target.value)}
|
||||
className="flex-1 px-3 py-2 bg-background border border-input rounded-md text-sm"
|
||||
/>
|
||||
<Button type="submit" size="sm" disabled={!newPlaylistName.trim() || createPlaylistMutation.isPending}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Playlist list */}
|
||||
<div className="space-y-2">
|
||||
{isLoading ? (
|
||||
<p className="text-center text-muted-foreground py-4">Loading playlists...</p>
|
||||
) : playlists && playlists.length > 0 ? (
|
||||
playlists.map((playlist) => {
|
||||
const inPlaylist = isInPlaylist(playlist)
|
||||
return (
|
||||
<button
|
||||
key={playlist.id}
|
||||
onClick={() => handleTogglePlaylist(playlist)}
|
||||
className="w-full flex items-center justify-between p-3 rounded-lg hover:bg-accent transition-colors"
|
||||
disabled={addToPlaylistMutation.isPending || removeFromPlaylistMutation.isPending}
|
||||
>
|
||||
<span className="font-medium">{playlist.name}</span>
|
||||
{inPlaylist && <Check className="h-5 w-5 text-primary" />}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-4">No playlists yet</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -60,3 +60,8 @@ export interface DownloadStatus {
|
||||
completed: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
export interface Artist {
|
||||
name: string
|
||||
song_count: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user