Compare commits
No commits in common. "2a893f89d6ee1544ffc4d937bab4f256d9f1750b" and "5f68476c76b411dc84c3fe646c18bd766655cbd7" have entirely different histories.
2a893f89d6
...
5f68476c76
17 changed files with 76 additions and 586 deletions
|
|
@ -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/*
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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}
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
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:
|
try:
|
||||||
url = f"https://www.youtube.com/watch?v={id}"
|
url = f"https://www.youtube.com/watch?v={id}"
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
'format': 'bestaudio[ext=m4a]/best[ext=mp4]/best',
|
'format': 'bestaudio[ext=m4a]/best[ext=mp4]/best',
|
||||||
'quiet': True,
|
'quiet': True,
|
||||||
'noplaylist': True,
|
'noplaylist': True,
|
||||||
'force_ipv4': True,
|
|
||||||
}
|
}
|
||||||
ydl_opts.update(client_config)
|
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
info = ydl.extract_info(url, download=False)
|
info = ydl.extract_info(url, download=False)
|
||||||
stream_url = info.get('url')
|
stream_url = info.get('url')
|
||||||
|
|
||||||
if stream_url:
|
if stream_url:
|
||||||
headers = info.get('http_headers', {})
|
self.cache.set(cache_key, stream_url, ttl_seconds=3600)
|
||||||
result = {
|
return stream_url
|
||||||
"url": stream_url,
|
raise ResourceNotFound("Stream not found")
|
||||||
"headers": headers
|
|
||||||
}
|
|
||||||
self.cache.set(cache_key, result, ttl_seconds=3600)
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_error = e
|
raise ExternalAPIError(str(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": []}
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
11
update.bat
11
update.bat
|
|
@ -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
|
|
||||||
Loading…
Reference in a new issue