Compare commits

...

15 commits

Author SHA1 Message Date
Your Name
2a893f89d6 Feat: Final polish for deployment - Fallbacks and UI consistency 2026-01-01 14:09:21 +07:00
Your Name
e63a9c664c Fix: Add hardcoded content fallback to prevent empty library 2026-01-01 14:05:44 +07:00
Your Name
66bccae7e2 Fix: Hard override PIP_INDEX_URL env var to ignore local pip config 2026-01-01 14:02:19 +07:00
Your Name
9e96869f8d Fix: Force official PyPI index for updates to bypass mirror errors 2026-01-01 14:00:10 +07:00
Your Name
87d53f06da Feat: Enrich Home Page with simulated content categories 2026-01-01 13:59:24 +07:00
Your Name
b67dbe577d Feat: Add Watchtower for auto-updates and update.bat script 2026-01-01 13:52:21 +07:00
Your Name
5e77d799e1 Fix: Indentation error in YouTubeService and Add Global Exception Handler 2026-01-01 13:50:25 +07:00
Your Name
c23395e225 Feat: Add SpotDL update and debug check endpoint 2026-01-01 13:45:29 +07:00
Your Name
74748ec86f Fix: Add fallback logic for stream fetching and implement browse endpoint 2026-01-01 13:43:26 +07:00
Your Name
701b146dc2 Feat: Implement Settings Panel to update yt-dlp core library 2026-01-01 13:36:51 +07:00
Your Name
a41b9d2143 Fix: Add git to Dockerfile for pip git+ dependencies 2026-01-01 13:31:47 +07:00
Your Name
61aae31ea3 Fix: Upgrade yt-dlp to nightly build to resolve cloud blocking 2026-01-01 13:30:29 +07:00
Your Name
a65ff6785b Fix: Switch yt-dlp to Android/iOS client and fix meta tags 2026-01-01 13:25:17 +07:00
Your Name
3264286f7f Fix: Force IPv4 and add Retry logic for stream 403s 2026-01-01 13:21:07 +07:00
Your Name
20f48529ba Fix: Add upstream headers to stream endpoint to prevent 403s 2026-01-01 13:18:10 +07:00
17 changed files with 586 additions and 76 deletions

View file

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

View file

@ -0,0 +1,15 @@
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

@ -0,0 +1,76 @@
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,34 +11,82 @@ def get_youtube_service():
@router.get("/stream")
async def stream_audio(id: str, yt: YouTubeService = Depends(get_youtube_service)):
try:
stream_url = yt.get_stream_url(id)
data = 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"
}
def iterfile():
with requests.get(stream_url, stream=True) as r:
# 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():
# Already opened request 'r'
try:
for chunk in r.iter_content(chunk_size=64*1024):
yield chunk
except Exception as e:
print(f"Chunk Error: {e}")
finally:
r.close()
return StreamingResponse(iterfile(), media_type="audio/mpeg")
except Exception as 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))
@router.get("/download")
async def download_audio(id: str, title: str = "audio", yt: YouTubeService = Depends(get_youtube_service)):
try:
stream_url = yt.get_stream_url(id)
data = 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():
with requests.get(stream_url, stream=True) as r:
with requests.get(stream_url, headers=headers, stream=True, timeout=10) as r:
r.raise_for_status()
for chunk in r.iter_content(chunk_size=1024*1024):
yield chunk
safe_filename = "".join([c for c in title if c.isalnum() or c in (' ', '-', '_')]).strip()
headers = {
final_headers = {
"Content-Disposition": f'attachment; filename="{safe_filename}.mp3"'
}
return StreamingResponse(iterfile(), media_type="audio/mpeg", headers=headers)
return StreamingResponse(iterfile(), media_type="audio/mpeg", headers=final_headers)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View file

@ -1,31 +1,36 @@
from fastapi import FastAPI
from fastapi import FastAPI, APIRouter
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
import os
from backend.core.config import settings
from backend.api.endpoints import playlists, search, stream, lyrics
from backend.core.config import settings as settings_config # Renamed to settings_config to avoid conflict
from backend.api.endpoints import playlists, search, stream, lyrics, settings as settings_router, browse # Aliased settings router
app = FastAPI(title=settings.APP_NAME)
app = FastAPI(title=settings_config.APP_NAME, openapi_url=f"{settings_config.API_V1_STR}/openapi.json") # Used settings_config
# CORS setup
app.add_middleware(
CORSMiddleware,
allow_origins=settings.BACKEND_CORS_ORIGINS,
allow_origins=settings_config.BACKEND_CORS_ORIGINS, # Used settings_config
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include Routers
app.include_router(playlists.router, prefix=f"{settings.API_V1_STR}", tags=["playlists"])
app.include_router(search.router, prefix=f"{settings.API_V1_STR}", tags=["search"])
app.include_router(stream.router, prefix=f"{settings.API_V1_STR}", tags=["stream"])
app.include_router(lyrics.router, prefix=f"{settings.API_V1_STR}", tags=["lyrics"])
api_router = APIRouter()
api_router.include_router(playlists.router, prefix="/playlists", tags=["playlists"])
api_router.include_router(search.router, tags=["search"])
api_router.include_router(stream.router, tags=["stream"])
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)
if settings.CACHE_DIR.parent.name == "backend":
if settings_config.CACHE_DIR.parent.name == "backend":
# assuming running from root
STATIC_DIR = "static"
else:
@ -53,3 +58,16 @@ else:
@app.get("/health")
def health_check():
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
python-multipart==0.0.20
requests==2.32.3
yt-dlp==2024.12.23
yt-dlp @ git+https://github.com/yt-dlp/yt-dlp.git@master
ytmusicapi==1.9.1
syncedlyrics
pydantic-settings

View file

@ -159,23 +159,60 @@ class YouTubeService:
cached = self.cache.get(cache_key)
if cached: return cached
try:
url = f"https://www.youtube.com/watch?v={id}"
ydl_opts = {
'format': 'bestaudio[ext=m4a]/best[ext=mp4]/best',
'quiet': True,
'noplaylist': True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
stream_url = info.get('url')
if stream_url:
self.cache.set(cache_key, stream_url, ttl_seconds=3600)
return stream_url
raise ResourceNotFound("Stream not found")
except Exception as e:
raise ExternalAPIError(str(e))
# Strategy: Try versatile clients in order
clients_to_try = [
# 1. iOS (often best for audio)
{'extractor_args': {'youtube': {'player_client': ['ios']}}},
# 2. Android (robust)
{'extractor_args': {'youtube': {'player_client': ['android']}}},
# 3. Web (standard, prone to 403)
{'extractor_args': {'youtube': {'player_client': ['web']}}},
# 4. TV (sometimes works for age-gated)
{'extractor_args': {'youtube': {'player_client': ['tv']}}},
]
last_error = None
for client_config in clients_to_try:
try:
url = f"https://www.youtube.com/watch?v={id}"
ydl_opts = {
'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):
if not seed_id: return []
@ -209,3 +246,166 @@ class YouTubeService:
except Exception as e:
print(f"Rec Error: {e}")
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": []}

19
debug_browse.py Normal file
View file

@ -0,0 +1,19 @@
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:
spotify-clone:
image: vndangkhoa/spotify-clone:latest
image: git.khoavo.myds.me/vndangkhoa/spotify-clone:latest
container_name: spotify-clone
restart: always
network_mode: bridge # Synology often prefers explicit bridge or host
@ -9,13 +9,3 @@ services:
volumes:
- ./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,10 +20,12 @@ export const metadata: Metadata = {
manifest: "/manifest.json",
referrer: "no-referrer",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: "Audiophile Web Player",
},
other: {
"mobile-web-app-capable": "yes",
},
icons: {
icon: "/icons/icon-192x192.png",
apple: "/icons/icon-512x512.png",

View file

@ -76,7 +76,7 @@ export default function LibraryPage() {
{playlists.map((playlist) => (
<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="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-md shadow-lg">
<div className="aspect-square w-full mb-1 md:mb-3 overflow-hidden rounded-md shadow-lg">
<CoverImage
src={playlist.cover_url}
alt={playlist.title}
@ -84,8 +84,8 @@ export default function LibraryPage() {
fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
/>
</div>
<h3 className="text-white font-bold text-xs md:text-sm truncate">{playlist.title}</h3>
<p className="text-[#a7a7a7] text-[10px] md:text-xs">Playlist You</p>
<h3 className="text-white font-bold text-[10px] md:text-sm truncate w-full">{playlist.title}</h3>
<p className="text-[#a7a7a7] text-[9px] md:text-xs truncate w-full">Playlist You</p>
</div>
</Link>
))}
@ -93,7 +93,7 @@ export default function LibraryPage() {
{browsePlaylists.map((playlist) => (
<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="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-md shadow-lg">
<div className="aspect-square w-full mb-1 md:mb-3 overflow-hidden rounded-md shadow-lg">
<CoverImage
src={playlist.cover_url}
alt={playlist.title}
@ -101,8 +101,8 @@ export default function LibraryPage() {
fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
/>
</div>
<h3 className="text-white font-bold text-xs md:text-sm truncate">{playlist.title}</h3>
<p className="text-[#a7a7a7] text-[10px] md:text-xs">Playlist Made for you</p>
<h3 className="text-white font-bold text-[10px] md:text-sm truncate w-full">{playlist.title}</h3>
<p className="text-[#a7a7a7] text-[9px] md:text-xs truncate w-full">Playlist Made for you</p>
</div>
</Link>
))}
@ -113,7 +113,7 @@ export default function LibraryPage() {
{showArtists && artists.map((artist) => (
<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="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-full shadow-lg">
<div className="aspect-square w-full mb-1 md:mb-3 overflow-hidden rounded-full shadow-lg">
<CoverImage
src={artist.cover_url}
alt={artist.title}
@ -121,8 +121,8 @@ export default function LibraryPage() {
fallbackText={artist.title?.substring(0, 2).toUpperCase()}
/>
</div>
<h3 className="text-white font-bold text-xs md:text-sm truncate w-full">{artist.title}</h3>
<p className="text-[#a7a7a7] text-[10px] md:text-xs">Artist</p>
<h3 className="text-white font-bold text-[10px] md:text-sm truncate w-full">{artist.title}</h3>
<p className="text-[#a7a7a7] text-[9px] md:text-xs truncate w-full">Artist</p>
</div>
</Link>
))}
@ -131,7 +131,7 @@ export default function LibraryPage() {
{showAlbums && albums.map((album) => (
<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="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-md shadow-lg">
<div className="aspect-square w-full mb-1 md:mb-3 overflow-hidden rounded-md shadow-lg">
<CoverImage
src={album.cover_url}
alt={album.title}
@ -139,8 +139,8 @@ export default function LibraryPage() {
fallbackText={album.title?.substring(0, 2).toUpperCase()}
/>
</div>
<h3 className="text-white font-bold text-xs md:text-sm truncate">{album.title}</h3>
<p className="text-[#a7a7a7] text-[10px] md:text-xs">Album {album.creator || 'Spotify'}</p>
<h3 className="text-white font-bold text-[10px] md:text-sm truncate w-full">{album.title}</h3>
<p className="text-[#a7a7a7] text-[9px] md:text-xs truncate w-full">Album {album.creator || 'Spotify'}</p>
</div>
</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">
{sortPlaylists(playlists).slice(0, 5).map((playlist: any) => (
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}>
<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-4">
<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="relative mb-1 md:mb-4">
<CoverImage
src={playlist.cover_url}
alt={playlist.title}
className="w-full aspect-square object-cover rounded-md shadow-lg"
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">
<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="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-1" />
</div>
</div>
</div>
<h3 className="font-bold mb-1 truncate">{playlist.title}</h3>
<p className="text-sm text-[#a7a7a7] line-clamp-2">{playlist.description}</p>
<h3 className="font-bold mb-0.5 md:mb-1 truncate text-[10px] md:text-base">{playlist.title}</h3>
<p className="text-[9px] md:text-sm text-[#a7a7a7] line-clamp-2">{playlist.description}</p>
</div>
</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">
{recommendations.slice(0, 5).map((track, i) => (
<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-4">
<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 className="relative mb-1 md:mb-4">
<CoverImage
src={track.cover_url}
alt={track.title}
className="w-full aspect-square object-cover rounded-md shadow-lg"
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">
<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="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-1" />
</div>
</div>
</div>
<h3 className="font-bold mb-1 truncate">{track.title}</h3>
<p className="text-sm text-[#a7a7a7] line-clamp-2">{track.artist}</p>
<h3 className="font-bold mb-0.5 md:mb-1 truncate text-[10px] md:text-base">{track.title}</h3>
<p className="text-[9px] md:text-sm text-[#a7a7a7] line-clamp-2">{track.artist}</p>
</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">
{albums.slice(0, 5).map((album, i) => (
<Link href={`/playlist?id=${album.id}`} key={i}>
<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-4">
<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="relative mb-1 md:mb-4">
<CoverImage
src={album.cover_url}
alt={album.title}
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">
<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="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-1" />
</div>
</div>
</div>
<h3 className="font-bold mb-1 truncate">{album.title}</h3>
<p className="text-sm text-[#a7a7a7] line-clamp-2">{album.description}</p>
<h3 className="font-bold mb-0.5 md:mb-1 truncate text-[10px] md:text-base">{album.title}</h3>
<p className="text-[9px] md:text-sm text-[#a7a7a7] line-clamp-2">{album.description}</p>
</div>
</Link>
))}

View file

@ -1,11 +1,14 @@
"use client";
import { Home, Search, Library } from "lucide-react";
import { Home, Search, Library, Settings } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import SettingsModal from "./SettingsModal";
export default function MobileNav() {
const pathname = usePathname();
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const isActive = (path: string) => pathname === path;
@ -25,6 +28,11 @@ export default function MobileNav() {
<Library size={24} />
<span className="text-[10px]">Library</span>
</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>
);
}

View file

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

View file

@ -21,7 +21,10 @@ const nextConfig = {
{ 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/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*' },
// Catch-all for other new endpoints
{ source: '/api/:path*', destination: 'http://127.0.0.1:8000/api/:path*' },
];
},
images: {

11
update.bat Normal file
View file

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