Compare commits

..

No commits in common. "2a893f89d6ee1544ffc4d937bab4f256d9f1750b" and "5f68476c76b411dc84c3fe646c18bd766655cbd7" have entirely different histories.

17 changed files with 76 additions and 586 deletions

View file

@ -5,7 +5,6 @@ RUN apt-get update && apt-get install -y \
curl \ curl \
gnupg \ gnupg \
ffmpeg \ ffmpeg \
git \
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \ && curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& apt-get install -y nodejs \ && apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

View file

@ -1,15 +0,0 @@
from fastapi import APIRouter, Depends
from backend.services.youtube import YouTubeService
router = APIRouter()
def get_youtube_service():
return YouTubeService()
@router.get("/browse")
async def get_browse_content(yt: YouTubeService = Depends(get_youtube_service)):
return yt.get_home()
@router.get("/trending")
async def get_trending(yt: YouTubeService = Depends(get_youtube_service)):
return yt.get_trending()

View file

@ -1,76 +0,0 @@
import subprocess
import os
import sys
from fastapi import APIRouter, HTTPException, BackgroundTasks
import logging
router = APIRouter()
logger = logging.getLogger(__name__)
def restart_server():
"""Restarts the server by killing the current process."""
logger.info("Restarting server...")
# This works in Docker if a restart policy is set (e.g., restart: always)
os.kill(os.getpid(), 15) # SIGTERM
@router.get("/check")
async def check_settings_health():
"""Debug endpoint to verify settings router is mounted."""
return {"status": "ok", "message": "Settings router is active"}
@router.post("/update-ytdlp")
async def update_ytdlp(background_tasks: BackgroundTasks):
try:
# Run pip install to upgrade yt-dlp to master
logger.info("Starting yt-dlp update...")
# Force PyPI index via environment variable to override global config
env = os.environ.copy()
env["PIP_INDEX_URL"] = "https://pypi.org/simple"
process = subprocess.run(
[sys.executable, "-m", "pip", "install", "--upgrade", "--force-reinstall", "git+https://github.com/yt-dlp/yt-dlp.git@master"],
capture_output=True,
text=True,
check=True,
env=env
)
logger.info(f"Update Output: {process.stdout}")
# Schedule restart after a short delay to allow response to be sent
background_tasks.add_task(restart_server)
return {"status": "success", "message": "yt-dlp updated. Server restarting..."}
except subprocess.CalledProcessError as e:
logger.error(f"Update Failed: {e.stderr}")
raise HTTPException(status_code=500, detail=f"Update failed: {e.stderr}")
except Exception as e:
logger.error(f"Unexpected Error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/update-spotdl")
async def update_spotdl(background_tasks: BackgroundTasks):
try:
logger.info("Starting spotdl update...")
# Force PyPI index via environment variable
env = os.environ.copy()
env["PIP_INDEX_URL"] = "https://pypi.org/simple"
process = subprocess.run(
[sys.executable, "-m", "pip", "install", "--upgrade", "spotdl"],
capture_output=True,
text=True,
check=True,
env=env
)
logger.info(f"Update Output: {process.stdout}")
background_tasks.add_task(restart_server)
return {"status": "success", "message": "spotdl updated. Server restarting..."}
except subprocess.CalledProcessError as e:
logger.error(f"Update Failed: {e.stderr}")
raise HTTPException(status_code=500, detail=f"Update failed: {e.stderr}")
except Exception as e:
logger.error(f"Unexpected Error: {e}")
raise HTTPException(status_code=500, detail=str(e))

View file

@ -11,82 +11,34 @@ def get_youtube_service():
@router.get("/stream") @router.get("/stream")
async def stream_audio(id: str, yt: YouTubeService = Depends(get_youtube_service)): async def stream_audio(id: str, yt: YouTubeService = Depends(get_youtube_service)):
try: try:
data = yt.get_stream_url(id) stream_url = yt.get_stream_url(id)
if isinstance(data, dict):
stream_url = data.get("url")
headers = data.get("headers", {})
else:
# Fallback for old cached string values
stream_url = data
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
}
# Helper function to get stream
def get_stream(url, headers):
return requests.get(url, headers=headers, stream=True, timeout=10)
try:
r = get_stream(stream_url, headers)
r.raise_for_status()
except requests.exceptions.HTTPError as e:
if e.response.status_code == 403:
print("Got 403 Forbidden. Invalidating cache and retrying...")
yt.invalidate_stream_cache(id)
# Fetch fresh
data = yt.get_stream_url(id)
if isinstance(data, dict):
stream_url = data.get("url")
headers = data.get("headers", {})
else:
stream_url = data
# Retry request
r = get_stream(stream_url, headers)
r.raise_for_status()
else:
raise e
def iterfile(): def iterfile():
# Already opened request 'r' with requests.get(stream_url, stream=True) as r:
try: r.raise_for_status()
for chunk in r.iter_content(chunk_size=64*1024): for chunk in r.iter_content(chunk_size=64*1024):
yield chunk yield chunk
except Exception as e:
print(f"Chunk Error: {e}")
finally:
r.close()
return StreamingResponse(iterfile(), media_type="audio/mpeg") return StreamingResponse(iterfile(), media_type="audio/mpeg")
except Exception as e: except Exception as e:
print(f"Stream Error: {e}") print(f"Stream Error: {e}")
if isinstance(e, requests.exceptions.HTTPError):
print(f"Upstream Status: {e.response.status_code}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/download") @router.get("/download")
async def download_audio(id: str, title: str = "audio", yt: YouTubeService = Depends(get_youtube_service)): async def download_audio(id: str, title: str = "audio", yt: YouTubeService = Depends(get_youtube_service)):
try: try:
data = yt.get_stream_url(id) stream_url = yt.get_stream_url(id)
if isinstance(data, dict):
stream_url = data.get("url")
headers = data.get("headers", {})
else:
stream_url = data
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
}
def iterfile(): def iterfile():
with requests.get(stream_url, headers=headers, stream=True, timeout=10) as r: with requests.get(stream_url, stream=True) as r:
r.raise_for_status() r.raise_for_status()
for chunk in r.iter_content(chunk_size=1024*1024): for chunk in r.iter_content(chunk_size=1024*1024):
yield chunk yield chunk
safe_filename = "".join([c for c in title if c.isalnum() or c in (' ', '-', '_')]).strip() safe_filename = "".join([c for c in title if c.isalnum() or c in (' ', '-', '_')]).strip()
final_headers = { headers = {
"Content-Disposition": f'attachment; filename="{safe_filename}.mp3"' "Content-Disposition": f'attachment; filename="{safe_filename}.mp3"'
} }
return StreamingResponse(iterfile(), media_type="audio/mpeg", headers=final_headers) return StreamingResponse(iterfile(), media_type="audio/mpeg", headers=headers)
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))

View file

@ -1,36 +1,31 @@
from fastapi import FastAPI, APIRouter from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
import os import os
from backend.core.config import settings as settings_config # Renamed to settings_config to avoid conflict from backend.core.config import settings
from backend.api.endpoints import playlists, search, stream, lyrics, settings as settings_router, browse # Aliased settings router from backend.api.endpoints import playlists, search, stream, lyrics
app = FastAPI(title=settings_config.APP_NAME, openapi_url=f"{settings_config.API_V1_STR}/openapi.json") # Used settings_config app = FastAPI(title=settings.APP_NAME)
# CORS setup # CORS setup
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings_config.BACKEND_CORS_ORIGINS, # Used settings_config allow_origins=settings.BACKEND_CORS_ORIGINS,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# Include Routers # Include Routers
api_router = APIRouter() app.include_router(playlists.router, prefix=f"{settings.API_V1_STR}", tags=["playlists"])
api_router.include_router(playlists.router, prefix="/playlists", tags=["playlists"]) app.include_router(search.router, prefix=f"{settings.API_V1_STR}", tags=["search"])
api_router.include_router(search.router, tags=["search"]) app.include_router(stream.router, prefix=f"{settings.API_V1_STR}", tags=["stream"])
api_router.include_router(stream.router, tags=["stream"]) app.include_router(lyrics.router, prefix=f"{settings.API_V1_STR}", tags=["lyrics"])
api_router.include_router(lyrics.router, tags=["lyrics"])
api_router.include_router(browse.router, tags=["browse"])
api_router.include_router(settings_router.router, prefix="/settings", tags=["settings"]) # Included settings_router
app.include_router(api_router, prefix=settings_config.API_V1_STR) # Corrected prefix and removed extra tags
# Serve Static Frontend (Production Mode) # Serve Static Frontend (Production Mode)
if settings_config.CACHE_DIR.parent.name == "backend": if settings.CACHE_DIR.parent.name == "backend":
# assuming running from root # assuming running from root
STATIC_DIR = "static" STATIC_DIR = "static"
else: else:
@ -58,16 +53,3 @@ else:
@app.get("/health") @app.get("/health")
def health_check(): def health_check():
return {"status": "ok"} return {"status": "ok"}
from fastapi import Request
from fastapi.responses import JSONResponse
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
import traceback
error_detail = "".join(traceback.format_exception(None, exc, exc.__traceback__))
print(f"Global 500 Error: {error_detail}")
return JSONResponse(
status_code=500,
content={"message": "Internal Server Error", "detail": error_detail}
)

View file

@ -4,7 +4,7 @@ spotdl
pydantic==2.10.4 pydantic==2.10.4
python-multipart==0.0.20 python-multipart==0.0.20
requests==2.32.3 requests==2.32.3
yt-dlp @ git+https://github.com/yt-dlp/yt-dlp.git@master yt-dlp==2024.12.23
ytmusicapi==1.9.1 ytmusicapi==1.9.1
syncedlyrics syncedlyrics
pydantic-settings pydantic-settings

View file

@ -159,60 +159,23 @@ class YouTubeService:
cached = self.cache.get(cache_key) cached = self.cache.get(cache_key)
if cached: return cached if cached: return cached
# Strategy: Try versatile clients in order try:
clients_to_try = [ url = f"https://www.youtube.com/watch?v={id}"
# 1. iOS (often best for audio) ydl_opts = {
{'extractor_args': {'youtube': {'player_client': ['ios']}}}, 'format': 'bestaudio[ext=m4a]/best[ext=mp4]/best',
# 2. Android (robust) 'quiet': True,
{'extractor_args': {'youtube': {'player_client': ['android']}}}, 'noplaylist': True,
# 3. Web (standard, prone to 403) }
{'extractor_args': {'youtube': {'player_client': ['web']}}}, with yt_dlp.YoutubeDL(ydl_opts) as ydl:
# 4. TV (sometimes works for age-gated) info = ydl.extract_info(url, download=False)
{'extractor_args': {'youtube': {'player_client': ['tv']}}}, stream_url = info.get('url')
]
last_error = None if stream_url:
self.cache.set(cache_key, stream_url, ttl_seconds=3600)
for client_config in clients_to_try: return stream_url
try: raise ResourceNotFound("Stream not found")
url = f"https://www.youtube.com/watch?v={id}" except Exception as e:
ydl_opts = { raise ExternalAPIError(str(e))
'format': 'bestaudio[ext=m4a]/best[ext=mp4]/best',
'quiet': True,
'noplaylist': True,
'force_ipv4': True,
}
ydl_opts.update(client_config)
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
stream_url = info.get('url')
if stream_url:
headers = info.get('http_headers', {})
result = {
"url": stream_url,
"headers": headers
}
self.cache.set(cache_key, result, ttl_seconds=3600)
return result
except Exception as e:
last_error = e
print(f"Fetch failed with client {client_config}: {e}")
continue
# If all fail
print(f"All clients failed for {id}. Last error: {last_error}")
raise ExternalAPIError(str(last_error))
def invalidate_stream_cache(self, id: str):
cache_key = f"stream:{id}"
path = self.cache._get_path(cache_key)
if path.exists():
try:
path.unlink()
except:
pass
def get_recommendations(self, seed_id: str): def get_recommendations(self, seed_id: str):
if not seed_id: return [] if not seed_id: return []
@ -246,166 +209,3 @@ class YouTubeService:
except Exception as e: except Exception as e:
print(f"Rec Error: {e}") print(f"Rec Error: {e}")
return {"tracks": []} return {"tracks": []}
def get_home(self):
cache_key = "home:browse"
cached = self.cache.get(cache_key)
if cached: return cached
try:
# ytmusicapi `get_home` returns complex Sections
# For simplicity, we'll fetch charts and new releases as "Browse" content
# Prepare trending songs
trending_songs = []
try:
# Get charts
trending = self.yt.get_charts(country='VN')
if 'videos' in trending and trending['videos']:
for item in trending['videos']['items']:
# Extract high-res thumbnail
thumbnails = item.get('thumbnails', [])
cover_url = thumbnails[-1]['url'] if thumbnails else ""
trending_songs.append({
"id": item.get('videoId'),
"title": item.get('title'),
"artist": item.get('artists', [{'name': 'Unknown'}])[0]['name'],
"album": "Trending", # Charts don't usually have album info, stick to generic
"cover_url": cover_url,
"duration": 0 # Charts might not have duration
})
except Exception as e:
print(f"Error fetching trending: {e}")
# --- FALLBACK IF API FAILS OR RETURNS EMPTY ---
if not trending_songs:
print("Using HARDCODED fallback for trending songs.")
trending_songs = [
{
"id": "Da4P2uT4ikU", "title": "Angel Baby", "artist": "Troye Sivan", "album": "Angel Baby",
"cover_url": "https://lh3.googleusercontent.com/Fj_JpwC1QGEFkH3y973Xv7w7tqVw5C_V-1o7g1gX_c4X_1o7g1gX_c4X_1o7g1=w544-h544-l90-rj"
},
{
"id": "fJ9rUzIMcZQ", "title": "Bohemian Rhapsody", "artist": "Queen", "album": "A Night at the Opera",
"cover_url": "https://lh3.googleusercontent.com/yFj_JpwC1QGEFkH3y973Xv7w7tqVw5C_V-1o7g1gX_c4X_1o7g1gX_c4X_1o7g1=w544-h544-l90-rj"
},
{
"id": "4NRXx6U8ABQ", "title": "Blinding Lights", "artist": "The Weeknd", "album": "After Hours",
"cover_url": "https://lh3.googleusercontent.com/Fj_JpwC1QGEFkH3y973Xv7w7tqVw5C_V-1o7g1gX_c4X_1o7g1gX_c4X_1o7g1=w544-h544-l90-rj"
},
{
"id": "OPf0YbXqDm0", "title": "Uptown Funk", "artist": "Mark Ronson", "album": "Uptown Special",
"cover_url": "https://lh3.googleusercontent.com/Fj_JpwC1QGEFkH3y973Xv7w7tqVw5C_V-1o7g1gX_c4X_1o7g1gX_c4X_1o7g1=w544-h544-l90-rj"
}
]
# -----------------------------------------------
# New Releases (using search for "New Songs" as proxy or actual new releases if supported)
# Actually ytmusicapi has get_new_releases usually under get_charts or specific calls
# We'll use get_charts "trending" for "Trending" category
# And maybe "Top Songs" for "Top Hits"
# 1. Trending (from Charts)
trending_playlist = {
"id": "trending",
"title": "Trending Now",
"description": "Top music videos right now",
"cover_url": trending_songs[0]['cover_url'] if trending_songs else "",
"tracks": trending_songs,
"type": "Playlist",
"creator": "YouTube Charts"
}
# 2. Top Hits (Simulated via search)
# We'll fetch a few "standard" playlists or results to populate the home page
# This makes the app feel "alive" even without user history
async def get_search_shelf(query, title):
try:
res = self.search(query)
if res and 'tracks' in res:
return {
"id": f"shelf_{query}",
"title": title,
"description": f"Best of {title}",
"cover_url": res['tracks'][0]['cover_url'] if res['tracks'] else "",
"tracks": res['tracks'],
"type": "Playlist",
"creator": "Spotify Clone"
}
except:
return None
# Since this is synchronous, we'll do simple searches or use cached results
# For speed, we might want to hardcode IDs of popular playlists in the future
# But for now, let's just reuse the trending videos for a "Top Hits" section to fill space
# and maybe shuffle them or pick different slice
import random
top_hits_tracks = list(trending_songs)
if len(top_hits_tracks) > 5:
random.shuffle(top_hits_tracks)
top_hits_playlist = {
"id": "top_hits",
"title": "Top Hits Today",
"description": "The hottest tracks right now.",
"cover_url": top_hits_tracks[0]['cover_url'] if top_hits_tracks else "",
"tracks": top_hits_tracks,
"type": "Playlist",
"creator": "Editors"
}
# 3. New Releases (Simulated)
new_releases_tracks = list(trending_songs)
if len(new_releases_tracks) > 2:
# Just rotate them to look different
new_releases_tracks = new_releases_tracks[2:] + new_releases_tracks[:2]
new_releases_playlist = {
"id": "new_releases",
"title": "New Releases",
"description": "Brand new music found for you.",
"cover_url": new_releases_tracks[0]['cover_url'] if new_releases_tracks else "",
"tracks": new_releases_tracks,
"type": "Playlist",
"creator": "Spotify Clone"
}
response = {
"Trending": [trending_playlist],
"Top Hits": [top_hits_playlist],
"New Releases": [new_releases_playlist],
"Focus & Chill": [
{
"id": "lofi_beats",
"title": "Lofi Beats",
"description": "Chill beats to study/relax to",
"cover_url": "https://i.ytimg.com/vi/jfKfPfyJRdk/hqdefault.jpg",
"tracks": [], # Empty tracks will force a fetch when clicked if handled
"type": "Playlist",
"creator": "Lofi Girl"
},
{
"id": "jazz_vibes",
"title": "Jazz Vibes",
"description": "Relaxing Jazz instrumental",
"cover_url": "https://i.ytimg.com/vi/DX7W7WUI6w8/hqdefault.jpg",
"tracks": [],
"type": "Playlist",
"creator": "Jazz Cafe"
}
]
}
self.cache.set(cache_key, response, ttl_seconds=3600)
return response
except Exception as e:
print(f"Home Error: {e}")
return {}
def get_trending(self):
# Dedicated trending endpoint
home = self.get_home()
if "Trending" in home and home["Trending"]:
return {"tracks": home["Trending"][0]["tracks"]}
return {"tracks": []}

View file

@ -1,19 +0,0 @@
import requests
import json
try:
r = requests.get('http://localhost:8000/api/browse')
data = r.json()
print("Keys:", data.keys())
for key, val in data.items():
print(f"Key: {key}, Type: {type(val)}")
if isinstance(val, list) and len(val) > 0:
item = val[0]
print(f" Item 0 keys: {item.keys()}")
if 'tracks' in item:
print(f" Tracks length: {len(item['tracks'])}")
if len(item['tracks']) > 0:
print(f" Track 0 keys: {item['tracks'][0].keys()}")
print(f" Track 0 sample: {item['tracks'][0]}")
except Exception as e:
print(e)

View file

@ -1,6 +1,6 @@
services: services:
spotify-clone: spotify-clone:
image: git.khoavo.myds.me/vndangkhoa/spotify-clone:latest image: vndangkhoa/spotify-clone:latest
container_name: spotify-clone container_name: spotify-clone
restart: always restart: always
network_mode: bridge # Synology often prefers explicit bridge or host network_mode: bridge # Synology often prefers explicit bridge or host
@ -9,3 +9,13 @@ services:
volumes: volumes:
- ./data:/app/backend/data - ./data:/app/backend/data
watchtower:
image: containrrr/watchtower
container_name: spotify-watchtower
restart: always
volumes:
- /var/run/docker.sock:/var/run/docker.sock
command: --interval 3600 --cleanup
environment:
- WATCHTOWER_INCLUDE_RESTARTING=true

View file

@ -20,12 +20,10 @@ export const metadata: Metadata = {
manifest: "/manifest.json", manifest: "/manifest.json",
referrer: "no-referrer", referrer: "no-referrer",
appleWebApp: { appleWebApp: {
capable: true,
statusBarStyle: "black-translucent", statusBarStyle: "black-translucent",
title: "Audiophile Web Player", title: "Audiophile Web Player",
}, },
other: {
"mobile-web-app-capable": "yes",
},
icons: { icons: {
icon: "/icons/icon-192x192.png", icon: "/icons/icon-192x192.png",
apple: "/icons/icon-512x512.png", apple: "/icons/icon-512x512.png",

View file

@ -76,7 +76,7 @@ export default function LibraryPage() {
{playlists.map((playlist) => ( {playlists.map((playlist) => (
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}> <Link href={`/playlist?id=${playlist.id}`} key={playlist.id}>
<div className="bg-[#181818] p-2 md:p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col"> <div className="bg-[#181818] p-2 md:p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col">
<div className="aspect-square w-full mb-1 md:mb-3 overflow-hidden rounded-md shadow-lg"> <div className="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-md shadow-lg">
<CoverImage <CoverImage
src={playlist.cover_url} src={playlist.cover_url}
alt={playlist.title} alt={playlist.title}
@ -84,8 +84,8 @@ export default function LibraryPage() {
fallbackText={playlist.title?.substring(0, 2).toUpperCase()} fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
/> />
</div> </div>
<h3 className="text-white font-bold text-[10px] md:text-sm truncate w-full">{playlist.title}</h3> <h3 className="text-white font-bold text-xs md:text-sm truncate">{playlist.title}</h3>
<p className="text-[#a7a7a7] text-[9px] md:text-xs truncate w-full">Playlist You</p> <p className="text-[#a7a7a7] text-[10px] md:text-xs">Playlist You</p>
</div> </div>
</Link> </Link>
))} ))}
@ -93,7 +93,7 @@ export default function LibraryPage() {
{browsePlaylists.map((playlist) => ( {browsePlaylists.map((playlist) => (
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}> <Link href={`/playlist?id=${playlist.id}`} key={playlist.id}>
<div className="bg-[#181818] p-2 md:p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col"> <div className="bg-[#181818] p-2 md:p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col">
<div className="aspect-square w-full mb-1 md:mb-3 overflow-hidden rounded-md shadow-lg"> <div className="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-md shadow-lg">
<CoverImage <CoverImage
src={playlist.cover_url} src={playlist.cover_url}
alt={playlist.title} alt={playlist.title}
@ -101,8 +101,8 @@ export default function LibraryPage() {
fallbackText={playlist.title?.substring(0, 2).toUpperCase()} fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
/> />
</div> </div>
<h3 className="text-white font-bold text-[10px] md:text-sm truncate w-full">{playlist.title}</h3> <h3 className="text-white font-bold text-xs md:text-sm truncate">{playlist.title}</h3>
<p className="text-[#a7a7a7] text-[9px] md:text-xs truncate w-full">Playlist Made for you</p> <p className="text-[#a7a7a7] text-[10px] md:text-xs">Playlist Made for you</p>
</div> </div>
</Link> </Link>
))} ))}
@ -113,7 +113,7 @@ export default function LibraryPage() {
{showArtists && artists.map((artist) => ( {showArtists && artists.map((artist) => (
<Link href={`/artist?name=${encodeURIComponent(artist.title)}`} key={artist.id}> <Link href={`/artist?name=${encodeURIComponent(artist.title)}`} key={artist.id}>
<div className="bg-[#181818] p-2 md:p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col items-center text-center"> <div className="bg-[#181818] p-2 md:p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col items-center text-center">
<div className="aspect-square w-full mb-1 md:mb-3 overflow-hidden rounded-full shadow-lg"> <div className="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-full shadow-lg">
<CoverImage <CoverImage
src={artist.cover_url} src={artist.cover_url}
alt={artist.title} alt={artist.title}
@ -121,8 +121,8 @@ export default function LibraryPage() {
fallbackText={artist.title?.substring(0, 2).toUpperCase()} fallbackText={artist.title?.substring(0, 2).toUpperCase()}
/> />
</div> </div>
<h3 className="text-white font-bold text-[10px] md:text-sm truncate w-full">{artist.title}</h3> <h3 className="text-white font-bold text-xs md:text-sm truncate w-full">{artist.title}</h3>
<p className="text-[#a7a7a7] text-[9px] md:text-xs truncate w-full">Artist</p> <p className="text-[#a7a7a7] text-[10px] md:text-xs">Artist</p>
</div> </div>
</Link> </Link>
))} ))}
@ -131,7 +131,7 @@ export default function LibraryPage() {
{showAlbums && albums.map((album) => ( {showAlbums && albums.map((album) => (
<Link href={`/playlist?id=${album.id}`} key={album.id}> <Link href={`/playlist?id=${album.id}`} key={album.id}>
<div className="bg-[#181818] p-2 md:p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col"> <div className="bg-[#181818] p-2 md:p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col">
<div className="aspect-square w-full mb-1 md:mb-3 overflow-hidden rounded-md shadow-lg"> <div className="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-md shadow-lg">
<CoverImage <CoverImage
src={album.cover_url} src={album.cover_url}
alt={album.title} alt={album.title}
@ -139,8 +139,8 @@ export default function LibraryPage() {
fallbackText={album.title?.substring(0, 2).toUpperCase()} fallbackText={album.title?.substring(0, 2).toUpperCase()}
/> />
</div> </div>
<h3 className="text-white font-bold text-[10px] md:text-sm truncate w-full">{album.title}</h3> <h3 className="text-white font-bold text-xs md:text-sm truncate">{album.title}</h3>
<p className="text-[#a7a7a7] text-[9px] md:text-xs truncate w-full">Album {album.creator || 'Spotify'}</p> <p className="text-[#a7a7a7] text-[10px] md:text-xs">Album {album.creator || 'Spotify'}</p>
</div> </div>
</Link> </Link>
))} ))}

View file

@ -183,22 +183,22 @@ export default function Home() {
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6"> <div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{sortPlaylists(playlists).slice(0, 5).map((playlist: any) => ( {sortPlaylists(playlists).slice(0, 5).map((playlist: any) => (
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}> <Link href={`/playlist?id=${playlist.id}`} key={playlist.id}>
<div className="bg-[#181818] p-2 md:p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col"> <div className="bg-[#181818] p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col">
<div className="relative mb-1 md:mb-4"> <div className="relative mb-4">
<CoverImage <CoverImage
src={playlist.cover_url} src={playlist.cover_url}
alt={playlist.title} alt={playlist.title}
className="w-full aspect-square object-cover rounded-md shadow-lg" className="w-full aspect-square object-cover rounded-md shadow-lg"
fallbackText={playlist.title.substring(0, 2).toUpperCase()} fallbackText={playlist.title.substring(0, 2).toUpperCase()}
/> />
<div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl hidden md:block"> <div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105"> <div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-1" /> <Play className="fill-black text-black ml-1" />
</div> </div>
</div> </div>
</div> </div>
<h3 className="font-bold mb-0.5 md:mb-1 truncate text-[10px] md:text-base">{playlist.title}</h3> <h3 className="font-bold mb-1 truncate">{playlist.title}</h3>
<p className="text-[9px] md:text-sm text-[#a7a7a7] line-clamp-2">{playlist.description}</p> <p className="text-sm text-[#a7a7a7] line-clamp-2">{playlist.description}</p>
</div> </div>
</Link> </Link>
))} ))}
@ -316,22 +316,22 @@ function MadeForYouSection() {
) : ( ) : (
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6"> <div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{recommendations.slice(0, 5).map((track, i) => ( {recommendations.slice(0, 5).map((track, i) => (
<div key={i} onClick={() => playTrack(track, recommendations)} className="bg-[#181818] p-2 md:p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col"> <div key={i} onClick={() => playTrack(track, recommendations)} className="bg-[#181818] p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col">
<div className="relative mb-1 md:mb-4"> <div className="relative mb-4">
<CoverImage <CoverImage
src={track.cover_url} src={track.cover_url}
alt={track.title} alt={track.title}
className="w-full aspect-square object-cover rounded-md shadow-lg" className="w-full aspect-square object-cover rounded-md shadow-lg"
fallbackText={track.title?.substring(0, 2).toUpperCase()} fallbackText={track.title?.substring(0, 2).toUpperCase()}
/> />
<div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl hidden md:block"> <div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105"> <div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-1" /> <Play className="fill-black text-black ml-1" />
</div> </div>
</div> </div>
</div> </div>
<h3 className="font-bold mb-0.5 md:mb-1 truncate text-[10px] md:text-base">{track.title}</h3> <h3 className="font-bold mb-1 truncate">{track.title}</h3>
<p className="text-[9px] md:text-sm text-[#a7a7a7] line-clamp-2">{track.artist}</p> <p className="text-sm text-[#a7a7a7] line-clamp-2">{track.artist}</p>
</div> </div>
))} ))}
</div> </div>
@ -391,21 +391,21 @@ function RecommendedAlbumsSection() {
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-6"> <div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-6">
{albums.slice(0, 5).map((album, i) => ( {albums.slice(0, 5).map((album, i) => (
<Link href={`/playlist?id=${album.id}`} key={i}> <Link href={`/playlist?id=${album.id}`} key={i}>
<div className="bg-[#181818] p-2 md:p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col"> <div className="bg-[#181818] p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col">
<div className="relative mb-1 md:mb-4"> <div className="relative mb-4">
<CoverImage <CoverImage
src={album.cover_url} src={album.cover_url}
alt={album.title} alt={album.title}
className="w-full aspect-square object-cover rounded-md shadow-lg" className="w-full aspect-square object-cover rounded-md shadow-lg"
/> />
<div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl hidden md:block"> <div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105"> <div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-1" /> <Play className="fill-black text-black ml-1" />
</div> </div>
</div> </div>
</div> </div>
<h3 className="font-bold mb-0.5 md:mb-1 truncate text-[10px] md:text-base">{album.title}</h3> <h3 className="font-bold mb-1 truncate">{album.title}</h3>
<p className="text-[9px] md:text-sm text-[#a7a7a7] line-clamp-2">{album.description}</p> <p className="text-sm text-[#a7a7a7] line-clamp-2">{album.description}</p>
</div> </div>
</Link> </Link>
))} ))}

View file

@ -1,14 +1,11 @@
"use client"; "use client";
import { Home, Search, Library, Settings } from "lucide-react"; import { Home, Search, Library } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useState } from "react";
import SettingsModal from "./SettingsModal";
export default function MobileNav() { export default function MobileNav() {
const pathname = usePathname(); const pathname = usePathname();
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const isActive = (path: string) => pathname === path; const isActive = (path: string) => pathname === path;
@ -28,11 +25,6 @@ export default function MobileNav() {
<Library size={24} /> <Library size={24} />
<span className="text-[10px]">Library</span> <span className="text-[10px]">Library</span>
</Link> </Link>
<button onClick={() => setIsSettingsOpen(true)} className={`flex flex-col items-center gap-1 text-neutral-400 hover:text-white`}>
<Settings size={24} />
<span className="text-[10px]">Settings</span>
</button>
<SettingsModal isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} />
</div> </div>
); );
} }

View file

@ -1,106 +0,0 @@
"use client";
import { useState } from "react";
import { X, RefreshCw, CheckCircle, AlertCircle } from "lucide-react";
import { api } from "@/services/apiClient";
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
const [updating, setUpdating] = useState(false);
const [status, setStatus] = useState<{ type: "success" | "error" | null; message: string }>({ type: null, message: "" });
if (!isOpen) return null;
const handleUpdate = async (module: 'ytdlp' | 'spotdl') => {
setUpdating(true);
setStatus({ type: null, message: "" });
try {
const endpoint = module === 'ytdlp' ? "/settings/update-ytdlp" : "/settings/update-spotdl";
await api.post(endpoint, {});
setStatus({ type: "success", message: `${module} updated! Server is restarting...` });
// Reload page after a delay to reflect restart
setTimeout(() => {
window.location.reload();
}, 5000);
} catch (e: any) {
console.error(e); // Debugging
setStatus({ type: "error", message: e.message || "Update failed. Check console." });
} finally {
setUpdating(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-[#1e1e1e] rounded-xl p-6 w-full max-w-md shadow-2xl border border-white/10">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold">Settings</h2>
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-full transition">
<X size={20} />
</button>
</div>
<div className="space-y-6">
<div className="bg-[#2a2a2a] p-4 rounded-lg flex flex-col gap-4">
<div>
<h3 className="font-semibold mb-2">Core Components</h3>
<p className="text-sm text-gray-400 mb-2">
Update core libraries to fix playback or download issues.
</p>
</div>
<button
onClick={() => handleUpdate('ytdlp')}
disabled={updating}
className={`w-full py-3 rounded-lg font-medium flex items-center justify-center gap-2 transition ${updating ? "bg-blue-600/50 cursor-not-allowed" : "bg-blue-600 hover:bg-blue-500"
}`}
>
{updating ? (
<>
<RefreshCw className="animate-spin" size={18} />
Updating yt-dlp...
</>
) : (
<>
<RefreshCw size={18} />
Update yt-dlp (Nightly)
</>
)}
</button>
<button
onClick={() => handleUpdate('spotdl')}
disabled={updating}
className={`w-full py-3 rounded-lg font-medium flex items-center justify-center gap-2 transition ${updating ? "bg-green-600/50 cursor-not-allowed" : "bg-green-600 hover:bg-green-500"
}`}
>
{updating ? (
<>
<RefreshCw className="animate-spin" size={18} />
Updating spotdl...
</>
) : (
<>
<RefreshCw size={18} />
Update spotdl (Latest)
</>
)}
</button>
</div>
{status.message && (
<div className={`p-4 rounded-lg flex items-start gap-3 ${status.type === "success" ? "bg-green-500/10 text-green-400" : "bg-red-500/10 text-red-400"
}`}>
{status.type === "success" ? <CheckCircle size={20} /> : <AlertCircle size={20} />}
<p className="text-sm">{status.message}</p>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -1,11 +1,10 @@
"use client"; "use client";
import { Home, Search, Library, Plus, Heart, Settings } from "lucide-react"; import { Home, Search, Library, Plus, Heart } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePlayer } from "@/context/PlayerContext"; import { usePlayer } from "@/context/PlayerContext";
import { useState } from "react"; import { useState } from "react";
import CreatePlaylistModal from "./CreatePlaylistModal"; import CreatePlaylistModal from "./CreatePlaylistModal";
import SettingsModal from "./SettingsModal";
import { dbService } from "@/services/db"; import { dbService } from "@/services/db";
import { useLibrary } from "@/context/LibraryContext"; import { useLibrary } from "@/context/LibraryContext";
import Logo from "./Logo"; import Logo from "./Logo";
@ -15,7 +14,6 @@ export default function Sidebar() {
const { likedTracks } = usePlayer(); const { likedTracks } = usePlayer();
const { userPlaylists, libraryItems, refreshLibrary: refresh, activeFilter, setActiveFilter } = useLibrary(); const { userPlaylists, libraryItems, refreshLibrary: refresh, activeFilter, setActiveFilter } = useLibrary();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const handleCreatePlaylist = async (name: string) => { const handleCreatePlaylist = async (name: string) => {
await dbService.createPlaylist(name); await dbService.createPlaylist(name);
@ -51,13 +49,6 @@ export default function Sidebar() {
<Search className="w-6 h-6" /> <Search className="w-6 h-6" />
<span className="font-bold">Search</span> <span className="font-bold">Search</span>
</Link> </Link>
<button
onClick={() => setIsSettingsOpen(true)}
className="flex items-center gap-4 text-spotify-text-muted hover:text-white transition cursor-pointer text-left"
>
<Settings className="w-6 h-6" />
<span className="font-bold">Settings</span>
</button>
</div> </div>
<div className="bg-[#121212] rounded-lg flex-1 flex flex-col overflow-hidden"> <div className="bg-[#121212] rounded-lg flex-1 flex flex-col overflow-hidden">
@ -194,10 +185,6 @@ export default function Sidebar() {
onClose={() => setIsCreateModalOpen(false)} onClose={() => setIsCreateModalOpen(false)}
onCreate={handleCreatePlaylist} onCreate={handleCreatePlaylist}
/> />
<SettingsModal
isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)}
/>
</aside> </aside>
); );
} }

View file

@ -21,10 +21,7 @@ const nextConfig = {
{ source: '/api/download-status/:path*', destination: 'http://127.0.0.1:8000/api/download-status/:path*' }, { source: '/api/download-status/:path*', destination: 'http://127.0.0.1:8000/api/download-status/:path*' },
{ source: '/api/lyrics/:path*', destination: 'http://127.0.0.1:8000/api/lyrics/:path*' }, { source: '/api/lyrics/:path*', destination: 'http://127.0.0.1:8000/api/lyrics/:path*' },
{ source: '/api/trending/:path*', destination: 'http://127.0.0.1:8000/api/trending/:path*' }, { source: '/api/trending/:path*', destination: 'http://127.0.0.1:8000/api/trending/:path*' },
{ source: '/api/settings/:path*', destination: 'http://127.0.0.1:8000/api/settings/:path*' },
{ source: '/api/recommendations/:path*', destination: 'http://127.0.0.1:8000/api/recommendations/:path*' }, { source: '/api/recommendations/:path*', destination: 'http://127.0.0.1:8000/api/recommendations/:path*' },
// Catch-all for other new endpoints
{ source: '/api/:path*', destination: 'http://127.0.0.1:8000/api/:path*' },
]; ];
}, },
images: { images: {

View file

@ -1,11 +0,0 @@
@echo off
echo Updating Spotify Clone...
:: Pull the latest image
docker-compose pull
:: Recreate the container
docker-compose up -d
echo Update complete!
pause