Compare commits

...

8 commits

10 changed files with 214 additions and 102 deletions

29
.dockerignore Normal file
View file

@ -0,0 +1,29 @@
# Git
.git
.gitignore
# Node
node_modules
npm-debug.log
# Python
venv
__pycache__
*.pyc
*.pyo
*.pyd
# Next.js
.next
out
# Docker
Dockerfile
.dockerignore
# OS
.DS_Store
Thumbs.db
# Misc
*.log

View file

@ -1,56 +1,60 @@
# --- Stage 1: Frontend Builder ---
FROM node:18-alpine AS builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
# Install dependencies including devDependencies for build
RUN npm install --legacy-peer-deps
COPY frontend/ ./
# Build with standalone output
ENV NEXT_PUBLIC_API_URL=""
RUN npm run build
# --- Stage 2: Final Runtime Image ---
FROM python:3.11-slim
# Install Node.js
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
gnupg \
ffmpeg \
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& apt-get install -y nodejs \
ca-certificates \
nodejs \
npm \
&& update-ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# --- Backend Setup ---
# Backend Setup
COPY backend/requirements.txt ./backend/requirements.txt
RUN pip install --no-cache-dir -r backend/requirements.txt
# --- Frontend Setup ---
COPY frontend/package*.json ./frontend/
WORKDIR /app/frontend
# Install dependencies (ignoring peer deps conflicts)
RUN npm install --legacy-peer-deps
# Frontend Setup (Copy from Builder)
# Copy the standalone server
COPY --from=builder /app/frontend/.next/standalone /app/frontend
# Copy static files (required for standalone)
COPY --from=builder /app/frontend/.next/static /app/frontend/.next/static
COPY --from=builder /app/frontend/public /app/frontend/public
COPY frontend/ .
# Build Next.js (with ignore-lint already set in next.config.mjs)
# We set API URL to http://localhost:8000 because in this container strategy,
# the browser will access the backend directly.
# Wait, for client-side fetches, "localhost" refers to the user's machine.
# If we run this container on port 3000 and 8000, localhost:8000 works internally via Rewrites.
# ENV NEXT_PUBLIC_API_URL="http://localhost:8000" Removed to use relative path proxying
# Build Next.js
ENV NEXTAUTH_URL=http://localhost:3000
# Secret should be provided at runtime via docker run -e or docker-compose
ARG NEXTAUTH_SECRET_ARG=default_dev_secret_change_in_production
ENV NEXTAUTH_SECRET=${NEXTAUTH_SECRET_ARG}
RUN npm run build
# --- Final Setup ---
WORKDIR /app
# Copy Backend Code
COPY backend/ ./backend/
# Create a start script
# We also implement a "seed data" check.
# If the volume mount is empty (missing data.json), we copy from our backup.
# Create start script
RUN mkdir -p backend/data_seed && cp -r backend/data/* backend/data_seed/ || true
# Set Environment Variables
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Note: Standalone mode runs with 'node server.js'
RUN echo '#!/bin/bash\n\
if [ ! -f backend/data/data.json ]; then\n\
echo "Data volume appears empty. Seeding with bundled data..."\n\
cp -r backend/data_seed/* backend/data/\n\
fi\n\
uvicorn backend.main:app --host 0.0.0.0 --port 8000 &\n\
cd frontend && npm start -- -p 3000\n\
cd frontend && node server.js\n\
' > start.sh && chmod +x start.sh
EXPOSE 3000 8000

View file

@ -317,14 +317,18 @@ async def get_playlist(id: str):
# Safely extract album
album_name = extract_album_name(track, playlist_data.get('title', 'Single'))
video_id = track.get('videoId')
if not video_id:
continue
formatted_tracks.append({
"title": track.get('title', 'Unknown Title'),
"artist": artist_names,
"album": album_name,
"duration": track.get('duration_seconds', track.get('length_seconds', 0)),
"cover_url": cover_url,
"id": track.get('videoId'),
"url": f"https://music.youtube.com/watch?v={track.get('videoId')}"
"id": video_id,
"url": f"https://music.youtube.com/watch?v={video_id}"
})
# Get Playlist Cover (usually highest res)
@ -604,54 +608,87 @@ async def stream_audio(id: str):
"""
try:
# Check Cache for stream URL
cache_key = f"stream:{id}"
cached_url = cache.get(cache_key)
cache_key = f"v2:stream:{id}" # v2 cache key for new format
cached_data = cache.get(cache_key)
stream_url = None
if cached_url:
print(f"DEBUG: Using cached stream URL for '{id}'")
stream_url = cached_url
else:
mime_type = "audio/mp4"
if cached_data:
print(f"DEBUG: Using cached stream data for '{id}'")
if isinstance(cached_data, dict):
stream_url = cached_data.get('url')
mime_type = cached_data.get('mime', 'audio/mp4')
else:
stream_url = cached_data # Legacy fallback
if not stream_url:
print(f"DEBUG: Fetching new stream URL for '{id}'")
url = f"https://www.youtube.com/watch?v={id}"
ydl_opts = {
'format': 'bestaudio[ext=m4a]/best[ext=mp4]/best', # Prefer m4a/aac for iOS
'format': 'bestaudio[ext=m4a]/bestaudio/best',
'quiet': True,
'noplaylist': True,
'nocheckcertificate': True,
'geo_bypass': True,
'socket_timeout': 30,
'retries': 3,
'force_ipv4': True,
'extractor_args': {'youtube': {'player_client': ['ios', 'android']}},
}
# Extract direct URL
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
stream_url = info.get('url')
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
stream_url = info.get('url')
ext = info.get('ext')
# Determine MIME type
if ext == 'm4a':
mime_type = "audio/mp4"
elif ext == 'webm':
mime_type = "audio/webm"
else:
mime_type = "audio/mpeg" # Fallback
print(f"DEBUG: Got stream URL format: {info.get('format')}, ext: {ext}, mime: {mime_type}")
except Exception as ydl_error:
print(f"DEBUG: yt-dlp extraction error: {type(ydl_error).__name__}: {str(ydl_error)}")
raise ydl_error
if stream_url:
# Cache for 1 hour (3600 seconds) - URLs expire
cache.set(cache_key, stream_url, ttl_seconds=3600)
cache_data = {"url": stream_url, "mime": mime_type}
cache.set(cache_key, cache_data, ttl_seconds=3600)
if not stream_url:
raise HTTPException(status_code=404, detail="Audio stream not found")
# Stream the content
def iterfile():
# Verify if URL is still valid by making a HEAD request or handling stream error
# For simplicity, we just try to stream. If 403, we might need to invalidate,
# but that logic is complex for this method.
with requests.get(stream_url, stream=True) as r:
r.raise_for_status() # Check for 403
# Use smaller chunks (64KB) for better TTFB (Time To First Byte)
for chunk in r.iter_content(chunk_size=64*1024):
yield chunk
print(f"Streaming {id} with Content-Type: {mime_type}")
# Note: We return audio/mpeg, but it might be opus/webm.
# Browsers are usually smart enough to sniff.
return StreamingResponse(iterfile(), media_type="audio/mpeg")
def iterfile():
try:
with requests.get(stream_url, stream=True, timeout=30) as r:
r.raise_for_status()
for chunk in r.iter_content(chunk_size=64*1024):
yield chunk
except requests.exceptions.HTTPError as http_err:
print(f"DEBUG: Stream HTTP Error: {http_err}")
if http_err.response.status_code == 403:
cache.delete(cache_key)
raise
except Exception as e:
print(f"DEBUG: Stream Iterator Error: {e}")
raise
return StreamingResponse(iterfile(), media_type=mime_type)
except HTTPException:
raise
except Exception as e:
print(f"Stream Error: {e}")
# If cached URL failed (likely 403), we could try to invalidate here,
# but for now we just return error.
raise HTTPException(status_code=500, detail=str(e))
import traceback
print(f"Stream Error for ID '{id}': {type(e).__name__}: {str(e)}")
print(traceback.format_exc())
raise HTTPException(status_code=500, detail=f"Stream error: {type(e).__name__}: {str(e)}")
@router.get("/download")
async def download_audio(id: str, title: str = "audio"):

View file

@ -24,6 +24,9 @@ export const metadata: Metadata = {
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

@ -180,25 +180,25 @@ export default function Home() {
</Link>
</div>
<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-2 md: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-2 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="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 className="absolute bottom-1 right-1 md:bottom-2 md:right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
<div className="w-8 h-8 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
</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-xs md:text-base">{playlist.title}</h3>
<p className="text-[10px] md:text-sm text-[#a7a7a7] line-clamp-2">{playlist.description}</p>
</div>
</Link>
))}
@ -314,24 +314,24 @@ function MadeForYouSection() {
))}
</div>
) : (
<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-2 md: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-2 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="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 className="absolute bottom-1 right-1 md:bottom-2 md:right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
<div className="w-8 h-8 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
</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-xs md:text-base">{track.title}</h3>
<p className="text-[10px] md:text-sm text-[#a7a7a7] line-clamp-2">{track.artist}</p>
</div>
))}
</div>
@ -388,24 +388,24 @@ function RecommendedAlbumsSection() {
))}
</div>
) : (
<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-2 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-2 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="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 className="absolute bottom-1 right-1 md:bottom-2 md:right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
<div className="w-8 h-8 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
</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-xs md:text-base">{album.title}</h3>
<p className="text-[10px] md:text-sm text-[#a7a7a7] line-clamp-2">{album.description}</p>
</div>
</Link>
))}

View file

@ -1,5 +1,7 @@
"use client";
import Link from 'next/link';
import Image from 'next/image';
import { usePlayer } from "@/context/PlayerContext";
import { Play, Pause, Clock, Heart, MoreHorizontal, Plus } from "lucide-react";
import { useEffect, useState, Suspense } from "react";
@ -127,7 +129,16 @@ function PlaylistContent() {
<div className="h-full overflow-y-auto no-scrollbar bg-gradient-to-b from-gray-900 via-[#121212] to-[#121212]">
{/* Header */}
<div className="flex flex-col md:flex-row items-center md:items-end gap-6 p-8 bg-gradient-to-b from-transparent to-black/20 pt-20 text-center md:text-left">
<img src={playlist.cover_url} alt={playlist.title} className="w-52 h-52 md:w-60 md:h-60 shadow-2xl rounded-md object-cover" />
<div className="relative w-52 h-52 md:w-60 md:h-60 shadow-2xl rounded-md overflow-hidden shrink-0">
<Image
src={playlist.cover_url}
alt={playlist.title}
fill
className="object-cover"
sizes="(max-width: 768px) 208px, 240px"
priority
/>
</div>
<div className="flex flex-col gap-2 w-full md:w-auto">
<span className="text-sm font-bold uppercase hidden md:block">Playlist</span>
<h1 className="text-2xl md:text-6xl font-black tracking-tight text-white mb-2 md:mb-4 line-clamp-2 leading-tight">{playlist.title}</h1>
@ -179,7 +190,7 @@ function PlaylistContent() {
</div>
<div className="flex flex-col">
{playlist.tracks.map((track, i) => {
{playlist.tracks.filter(t => t.id).map((track, i) => {
const isCurrent = currentTrack?.id === track.id;
const isLiked = likedTracks.has(track.id);
return (
@ -198,7 +209,15 @@ function PlaylistContent() {
</span>
<div className="flex items-center gap-3 min-w-0 overflow-hidden">
<img src={track.cover_url} className="w-10 h-10 rounded shadow-sm object-cover shrink-0" alt="" />
<div className="relative w-10 h-10 rounded shadow-sm overflow-hidden shrink-0">
<Image
src={track.cover_url}
alt={track.title}
fill
className="object-cover"
sizes="40px"
/>
</div>
<div className="flex flex-col min-w-0 pr-2">
{/* Changed from truncate to line-clamp-2 for readability */}
<span className={`font-semibold text-base leading-tight line-clamp-2 break-words ${isCurrent ? 'text-green-500' : 'text-white'}`}>{track.title}</span>

View file

@ -32,6 +32,8 @@ function getGradient(text: string): string {
return gradients[Math.abs(hash) % gradients.length];
}
import Image from "next/image";
export default function CoverImage({ src, alt, className = "", fallbackText }: CoverImageProps) {
const [hasError, setHasError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
@ -53,24 +55,26 @@ export default function CoverImage({ src, alt, className = "", fallbackText }: C
}
return (
<>
<div className={`relative overflow-hidden ${className} bg-[#282828]`}>
{isLoading && (
<div
className={`bg-gradient-to-br ${gradient} flex items-center justify-center text-white animate-pulse ${className}`}
className={`absolute inset-0 bg-gradient-to-br ${gradient} flex items-center justify-center text-white animate-pulse z-10`}
>
<span className="opacity-50"></span>
</div>
)}
<img
<Image
src={src}
alt={alt}
className={`${className} ${isLoading ? 'hidden' : ''}`}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className={`object-cover ${isLoading ? 'opacity-0' : 'opacity-100'} transition-opacity duration-300`}
onLoad={() => setIsLoading(false)}
onError={() => {
setHasError(true);
setIsLoading(false);
}}
/>
</>
</div>
);
}

View file

@ -161,15 +161,14 @@ export default function Sidebar() {
{/* Albums */}
{showAlbums && albums.map((album) => (
<Link href={`/ search ? q = ${encodeURIComponent(album.title)} `} key={album.id}>
<Link href={`/search?q=${encodeURIComponent(album.title)}`} key={album.id}>
<div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
<div className="w-12 h-12 bg-[#282828] rounded flex items-center justify-center overflow-hidden relative">
{album.cover_url ? (
<img src={album.cover_url} alt="" className="w-full h-full object-cover" onError={(e) => e.currentTarget.style.display = 'none'} />
) : (
<span className="text-xl">💿</span>
)}
</div>
<CoverImage
src={album.cover_url}
alt={album.title}
className="w-12 h-12 rounded object-cover"
fallbackText="💿"
/>
<div className="flex-1 min-w-0">
<h3 className="text-white font-medium truncate">{album.title}</h3>
<p className="text-sm text-spotify-text-muted truncate">Album {album.creator || 'Spotify'}</p>

View file

@ -30,7 +30,14 @@ export function LibraryProvider({ children }: { children: React.ReactNode }) {
// 2. Local/Backend Content
const browse = await libraryService.getBrowseContent();
const browsePlaylists = Object.values(browse).flat();
// Deduplicate by ID to avoid React duplicate key warnings
const browsePlaylistsRaw = Object.values(browse).flat();
const seenIds = new Map();
const browsePlaylists = browsePlaylistsRaw.filter((p: any) => {
if (seenIds.has(p.id)) return false;
seenIds.set(p.id, true);
return true;
});
const artistsMap = new Map();
const albumsMap = new Map();

View file

@ -1,7 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// strict mode true is default but good to be explicit
// strict mode true is default but good to be explicit
reactStrictMode: true,
output: "standalone",
eslint: {
ignoreDuringBuilds: true,
},
@ -34,6 +36,14 @@ const nextConfig = {
protocol: 'https',
hostname: 'lh3.googleusercontent.com',
},
{
protocol: 'https',
hostname: 'yt3.googleusercontent.com',
},
{
protocol: 'https',
hostname: 'yt3.ggpht.com',
},
{
protocol: 'https',
hostname: 'placehold.co',