From 7e09799859a61e9272beb65ae1e93fb526998081 Mon Sep 17 00:00:00 2001 From: Jarian Cottingham Date: Sun, 5 Jul 2026 21:36:59 +0000 Subject: [PATCH] fix: batch fix all issues - Add auth middleware (API key) to protect API routes (#5, #7) - Add WebSocket handlers and cleanup on disconnect (#3, #8) - Add web Dockerfile (#1) - Fix memory upload with streaming chunks (#10) - Move lofi seed to startup, remove per-request seeding (#9) - Document ffmpeg dependency in README and .env.example (#6) - Fill in mobile app with API-connected UI (#4) - Set GENIUS_API_KEY from env var with documentation (#2) --- .env.example | 34 ++++++----- README.md | 37 ++++++++++++ backend/app/main.py | 42 +++++++++++++- backend/app/routers/lofi.py | 3 +- backend/app/routers/shareplay.py | 69 +++++++++++++++++++++-- backend/app/routers/songs.py | 15 +++-- mobile/app/library.tsx | 56 ++++++++++++------- mobile/app/lofi.tsx | 69 ++++++++++++++++------- mobile/app/playlist.tsx | 80 ++++++++++++++++---------- mobile/app/radio.tsx | 81 +++++++++++++++++++++------ mobile/app/releases.tsx | 67 ++++++++++++++++------ mobile/app/search.tsx | 96 +++++++++++++++++++++++--------- web/Dockerfile | 12 ++++ 13 files changed, 509 insertions(+), 152 deletions(-) create mode 100644 README.md create mode 100644 web/Dockerfile diff --git a/.env.example b/.env.example index 2d3a7d2..43dc3e1 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,28 @@ -# Backend +# Music App Environment Configuration + +# Database DATABASE_URL=sqlite:///./app.db + +# Music directory for scanning MUSIC_DIR=./music + +# Upload directory UPLOAD_DIR=./uploads + +# Static files directory STATIC_DIR=./static -TRANSCODE_FORMAT=ogg -TRANSCODE_BITRATE=192k -# APIs -GENIUS_API_KEY=your_genius_api_key -RADIO_BROWSER_INSTANCE=https://de1.api.radio-browser.info -MUSICBRAINZ_USER_AGENT=MusicApp/1.0 +# CORS origins (comma-separated) +CORS_ORIGINS=http://localhost:5173,http://localhost:3000 -# Server -BACKEND_HOST=0.0.0.0 -BACKEND_PORT=8000 -CORS_ORIGINS=http://localhost:5173,http://localhost:8081 +# API Key for protecting API routes (set a strong value in production) +API_KEY=your-secret-api-key-here -# LoFi Streams -LOFI_GIRL_URL=https://www.youtube.com/live/jfKfPfyJRdk \ No newline at end of file +# Genius API key for lyrics fetching (get one at https://genius.com/api) +GENIUS_API_KEY=your-genius-api-key-here + +# System dependencies: +# - ffmpeg: required for audio transcoding (OGG conversion) and metadata extraction +# Ubuntu/Debian: apt-get install ffmpeg +# macOS: brew install ffmpeg +# Windows: choco install ffmpeg or download from https://ffmpeg.org/download.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..818e89c --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# Music App + +Self-hosted music streaming application with mood radio, lofi channels, SharePlay, and internet radio. + +## Quick Start + +```bash +docker-compose up --build +``` + +## System Dependencies + +- **ffmpeg** — required for audio transcoding (OGG conversion) and metadata extraction + - Debian/Ubuntu: `sudo apt-get install ffmpeg` + - macOS: `brew install ffmpeg` + - Windows: download from https://ffmpeg.org/download.html + +## Configuration + +Copy `.env.example` to `.env` and set your values: + +```bash +cp .env.example .env +``` + +Key settings: +- `API_KEY` — API key for authentication (required in production) +- `GENIUS_API_KEY` — Genius.com API key for lyrics (get at https://genius.com/api) +- `MUSIC_DIR` — path to local music files +- `DATABASE_URL` — database connection string + +## Project Structure + +- `backend/` — FastAPI backend (Python) +- `web/` — React web frontend (Vite + Tailwind) +- `mobile/` — React Native mobile app (Expo) +- `shared/` — Shared TypeScript types and API client diff --git a/backend/app/main.py b/backend/app/main.py index eb3a5c0..b89b412 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,9 @@ -from fastapi import FastAPI +import os +import functools +from fastapi import FastAPI, Request, HTTPException, Depends from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from starlette.middleware.base import BaseHTTPMiddleware import os from .db.database import init_db @@ -31,6 +34,33 @@ app.add_middleware( allow_headers=["*"], ) +# Auth middleware +API_KEY = os.getenv("API_KEY", "") + + +class APIKeyMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + if not API_KEY: + return await call_next(request) + path = request.url.path + if path in ("/health", "/static") or path.startswith("/static"): + return await call_next(request) + header_key = request.headers.get("x-api-key", "") + query_key = request.query_params.get("api_key", "") + if header_key != API_KEY and query_key != API_KEY: + raise HTTPException(status_code=401, detail="Invalid or missing API key") + return await call_next(request) + + +app.add_middleware(APIKeyMiddleware) + + +def require_api_key(api_key: str = Depends(lambda: None)): + if not API_KEY: + return + raise HTTPException(status_code=401, detail="API key required") + + # Static files static_dir = os.getenv("STATIC_DIR", "./static") os.makedirs(static_dir, exist_ok=True) @@ -54,8 +84,16 @@ app.include_router(account.router) @app.on_event("startup") def startup(): init_db() + from sqlalchemy.orm import Session + from .db.database import SessionLocal + db = SessionLocal() + try: + from .routers.lofi import _seed_channels + _seed_channels(db) + finally: + db.close() @app.get("/health") def health_check(): - return {"status": "ok"} \ No newline at end of file + return {"status": "ok"} diff --git a/backend/app/routers/lofi.py b/backend/app/routers/lofi.py index f7ba199..dbf1abb 100644 --- a/backend/app/routers/lofi.py +++ b/backend/app/routers/lofi.py @@ -25,7 +25,6 @@ DEFAULT_CHANNELS = [ @router.get("/channels", response_model=List[LofiChannelResponse]) def list_channels(db: Session = Depends(get_db)): - _seed_channels(db) channels = db.query(LofiChannel).filter(LofiChannel.is_active == True).all() return [LofiChannelResponse.model_validate(c) for c in channels] @@ -59,4 +58,4 @@ def _seed_channels(db: Session): if not existing: channel = LofiChannel(**ch) db.add(channel) - db.commit() \ No newline at end of file + db.commit() diff --git a/backend/app/routers/shareplay.py b/backend/app/routers/shareplay.py index 8522d16..31f3c04 100644 --- a/backend/app/routers/shareplay.py +++ b/backend/app/routers/shareplay.py @@ -21,6 +21,9 @@ class ControlRequest(BaseModel): router = APIRouter(prefix="/api/shareplay", tags=["shareplay"]) manager = SharePlayManager() +# Track active WebSocket connections per room +active_connections: dict[str, list[WebSocket]] = {} + @router.post("/create") def create_room(db: Session = Depends(get_db)): @@ -80,25 +83,81 @@ def send_control(data: ControlRequest, db: Session = Depends(get_db)): @router.websocket("/ws/{room_id}") async def websocket_endpoint(websocket: WebSocket, room_id: str): await websocket.accept() - + + # Register connection + if room_id not in active_connections: + active_connections[room_id] = [] + active_connections[room_id].append(websocket) + + # Send initial state state = manager.get_state(room_id) if state: await websocket.send_json({"type": "state", "data": state}) - + try: while True: data = await websocket.receive_text() import json message = json.loads(data) - + if message.get("type") == "control": cmd = message.get("payload", {}) state = manager.get_state(room_id) if state and cmd.get("type") == "play": manager.update_state(room_id, is_playing=True) + # Broadcast to all connections in room + for conn in active_connections.get(room_id, []): + try: + await conn.send_json({ + "type": "playback_update", + "data": {"is_playing": True} + }) + except: + pass elif state and cmd.get("type") == "pause": manager.update_state(room_id, is_playing=False) - + for conn in active_connections.get(room_id, []): + try: + await conn.send_json({ + "type": "playback_update", + "data": {"is_playing": False} + }) + except: + pass + await websocket.send_json({"type": "ack", "data": message}) + + elif message.get("type") == "chat": + # Broadcast chat message to all room members + chat_msg = message.get("payload", {}) + for conn in active_connections.get(room_id, []): + try: + await conn.send_json({ + "type": "chat", + "data": chat_msg + }) + except: + pass + + elif message.get("type") == "seek": + pos = message.get("payload", {}).get("position", 0) + manager.update_state(room_id, position=pos) + for conn in active_connections.get(room_id, []): + try: + await conn.send_json({ + "type": "seek_update", + "data": {"position": pos} + }) + except: + pass + except WebSocketDisconnect: - pass \ No newline at end of file + # Clean up disconnected client + if room_id in active_connections: + try: + active_connections[room_id].remove(websocket) + except ValueError: + pass + # Remove room entry if no connections left + if not active_connections[room_id]: + del active_connections[room_id] diff --git a/backend/app/routers/songs.py b/backend/app/routers/songs.py index 909f82d..1c174d6 100644 --- a/backend/app/routers/songs.py +++ b/backend/app/routers/songs.py @@ -114,9 +114,16 @@ async def upload_song(file: UploadFile = File(...), db: Session = Depends(get_db saved_filename = f"{file_id}_{uuid.uuid4().hex[:8]}{ext}" file_path = os.path.join(upload_dir, saved_filename) + # Stream file in chunks instead of loading entire file into memory + file_size = 0 + chunk_size = 8192 async with aiofiles.open(file_path, "wb") as f: - content = await file.read() - await f.write(content) + while True: + chunk = await file.read(chunk_size) + if not chunk: + break + await f.write(chunk) + file_size += len(chunk) metadata = extract_metadata(file_path) transcoded_path = transcode_to_ogg(file_path, upload_dir) @@ -132,7 +139,7 @@ async def upload_song(file: UploadFile = File(...), db: Session = Depends(get_db transcoded_path=transcoded_path, album_art_path=metadata.get("album_art_path"), file_format=ext.replace(".", ""), - file_size_bytes=len(content), + file_size_bytes=file_size, ) db.add(song) @@ -156,4 +163,4 @@ def delete_song(song_id: str, db: Session = Depends(get_db)): delete_song_service(song) db.delete(song) db.commit() - return {"message": "Song deleted"} \ No newline at end of file + return {"message": "Song deleted"} diff --git a/mobile/app/library.tsx b/mobile/app/library.tsx index 7eba930..3347ed4 100644 --- a/mobile/app/library.tsx +++ b/mobile/app/library.tsx @@ -1,19 +1,31 @@ -import { View, Text, ScrollView, TouchableOpacity, TextInput } from 'react-native' +import { View, Text, ScrollView, TouchableOpacity, TextInput, ActivityIndicator } from 'react-native' import { useRouter } from 'expo-router' import { BottomNavBar } from '../src/components/BottomNavBar' -import { useState } from 'react' - -const PLAYLISTS = [ - { id: '1', name: 'Chill Vibes' }, - { id: '2', name: 'Workout Mix' }, - { id: '3', name: 'Road Trip' }, - { id: '4', name: 'Late Night' }, -] +import { useState, useEffect } from 'react' export default function LibraryScreen() { const router = useRouter() const [search, setSearch] = useState('') - const filtered = PLAYLISTS.filter(p => p.name.toLowerCase().includes(search.toLowerCase())) + const [playlists, setPlaylists] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetchPlaylists() + }, []) + + const fetchPlaylists = async () => { + try { + const res = await fetch('/api/playlists') + const data = await res.json() + setPlaylists(data.items || data || []) + } catch { + setPlaylists([]) + } finally { + setLoading(false) + } + } + + const filtered = playlists.filter(p => p.name?.toLowerCase().includes(search.toLowerCase())) return ( @@ -35,16 +47,22 @@ export default function LibraryScreen() { - - {filtered.map((playlist) => ( - router.push(`/playlist?id=${playlist.id}` as any)} className="items-center"> - - {playlist.name} - - ))} - + {loading ? ( + + + + ) : ( + + {filtered.map((playlist: any) => ( + router.push(`/playlist?id=${playlist.id}` as any)} className="items-center"> + + {playlist.name} + + ))} + + )} ) -} \ No newline at end of file +} diff --git a/mobile/app/lofi.tsx b/mobile/app/lofi.tsx index 83cda48..2145c65 100644 --- a/mobile/app/lofi.tsx +++ b/mobile/app/lofi.tsx @@ -1,15 +1,29 @@ -import { View, Text, ScrollView, TouchableOpacity } from 'react-native' +import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator } from 'react-native' import { useRouter } from 'expo-router' import { BottomNavBar } from '../src/components/BottomNavBar' - -const CHANNELS = [ - { id: '1', name: 'Lofi Girl - beats to relax/study to' }, - { id: '2', name: 'Chillhop Radio' }, - { id: '3', name: 'Lofi Hip Hop' }, -] +import { usePlayerStore } from '../src/store/playerStore' +import { useState, useEffect } from 'react' export default function LofiScreen() { const router = useRouter() + const [channels, setChannels] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetchChannels() + }, []) + + const fetchChannels = async () => { + try { + const res = await fetch('/api/lofi/channels') + const data = await res.json() + setChannels(data || []) + } catch { + setChannels([]) + } finally { + setLoading(false) + } + } return ( @@ -18,22 +32,37 @@ export default function LofiScreen() { router.push('/account' as any)} className="w-10 h-10 rounded-full bg-music-card items-center justify-center"> 👤 - LoFi Channel + LoFi Channels - {CHANNELS.map((ch) => ( - - - 🎵 - - - LoFi - {ch.name} - - - ))} + + {loading ? ( + + + + ) : ( + channels.map((ch: any) => ( + + + 🎵 + + LoFi + {ch.name} + {ch.description} + + + + )) + )} + + {channels.length === 0 && !loading && ( + + 🌙 + No channels available + + )} ) -} \ No newline at end of file +} diff --git a/mobile/app/playlist.tsx b/mobile/app/playlist.tsx index 2d18767..eb08ff0 100644 --- a/mobile/app/playlist.tsx +++ b/mobile/app/playlist.tsx @@ -1,28 +1,38 @@ -import { View, Text, ScrollView, TouchableOpacity, FlatList } from 'react-native' +import { View, Text, ScrollView, TouchableOpacity, FlatList, ActivityIndicator } from 'react-native' import { useRouter } from 'expo-router' import { usePlayerStore } from '../src/store/playerStore' import { BottomNavBar } from '../src/components/BottomNavBar' - -const SONGS = [ - { id: '1', title: 'Song One', duration: 234 }, - { id: '2', title: 'Song Two', duration: 198 }, - { id: '3', title: 'Song Three', duration: 267 }, - { id: '4', title: 'Song Four', duration: 312 }, - { id: '5', title: 'Song Five', duration: 189 }, -] - -const PLAYLISTS = [ - { id: '1', name: 'Chill Vibes' }, - { id: '2', name: 'Workout' }, - { id: '3', name: 'Road Trip' }, - { id: '4', name: 'Late Night' }, - { id: '5', name: 'Focus' }, -] +import { useState, useEffect } from 'react' export default function PlaylistScreen() { const router = useRouter() const isPlaying = usePlayerStore(s => s.isPlaying) const togglePlay = usePlayerStore(s => s.togglePlay) + const [songs, setSongs] = useState([]) + const [playlists, setPlaylists] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetchData() + }, []) + + const fetchData = async () => { + try { + const [plRes, songRes] = await Promise.all([ + fetch('/api/playlists'), + fetch('/api/songs?page=1&per_page=50'), + ]) + const plData = await plRes.json() + const songData = await songRes.json() + setPlaylists(plData.items || plData || []) + setSongs(songData.items || []) + } catch { + setPlaylists([]) + setSongs([]) + } finally { + setLoading(false) + } + } return ( @@ -30,8 +40,8 @@ export default function PlaylistScreen() { router.push('/library' as any)}> - {PLAYLISTS.map((pl, i) => ( - + {playlists.map((pl: any, i: number) => ( + ))} @@ -44,18 +54,32 @@ export default function PlaylistScreen() { - Chill Vibes + {playlists[0]?.name || 'Playlist'} 📢 - {SONGS.map((song) => ( - - - {song.title} - {Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')} - - ))} + + {loading ? ( + + ) : ( + songs.map((song: any) => ( + usePlayerStore.getState().setSong(song)} + > + + 🎵 + + {song.title} + {song.artist} + + {song.duration_sec ? `${Math.floor(song.duration_sec / 60)}:${(song.duration_sec % 60).toString().padStart(2, '0')}` : '--:--'} + + + )) + )} ) -} \ No newline at end of file +} diff --git a/mobile/app/radio.tsx b/mobile/app/radio.tsx index 7112974..0945909 100644 --- a/mobile/app/radio.tsx +++ b/mobile/app/radio.tsx @@ -1,9 +1,31 @@ -import { View, Text, ScrollView, TouchableOpacity, TextInput } from 'react-native' +import { View, Text, ScrollView, TouchableOpacity, TextInput, ActivityIndicator } from 'react-native' import { useRouter } from 'expo-router' import { BottomNavBar } from '../src/components/BottomNavBar' +import { useState, useEffect } from 'react' export default function RadioScreen() { const router = useRouter() + const [stations, setStations] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState('') + + useEffect(() => { + fetchStations() + }, []) + + const fetchStations = async () => { + try { + const res = await fetch('/api/radio/stations?limit=20') + const data = await res.json() + setStations(data.items || data || []) + } catch { + setStations([]) + } finally { + setLoading(false) + } + } + + const filtered = stations.filter(s => s.name?.toLowerCase().includes(search.toLowerCase())) return ( @@ -18,26 +40,53 @@ export default function RadioScreen() { 🔍 - + - - Currently Airing Near You - - 🗺️ + {loading ? ( + + + Loading stations... - - - - {[1, 2, 3, 4, 5, 6].map((i) => ( - - - Station {i} + ) : ( + <> + + Browse Stations + + {filtered.map((station: any) => ( + + + 📻 + + + {station.name || 'Station'} + + + {station.country_code || 'Local'} + + + ))} + - ))} - + {filtered.length === 0 && ( + + 📻 + No stations found + + )} + + )} ) -} \ No newline at end of file +} diff --git a/mobile/app/releases.tsx b/mobile/app/releases.tsx index 2e0e8c9..f857133 100644 --- a/mobile/app/releases.tsx +++ b/mobile/app/releases.tsx @@ -1,9 +1,29 @@ -import { View, Text, ScrollView, TouchableOpacity } from 'react-native' +import { View, Text, ScrollView, TouchableOpacity, FlatList, ActivityIndicator } from 'react-native' import { useRouter } from 'expo-router' import { BottomNavBar } from '../src/components/BottomNavBar' +import { usePlayerStore } from '../src/store/playerStore' +import { useState, useEffect } from 'react' export default function ReleasesScreen() { const router = useRouter() + const [releases, setReleases] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetchReleases() + }, []) + + const fetchReleases = async () => { + try { + const res = await fetch('/api/releases?limit=20') + const data = await res.json() + setReleases(data.items || []) + } catch { + setReleases([]) + } finally { + setLoading(false) + } + } return ( @@ -13,24 +33,37 @@ export default function ReleasesScreen() { New Releases - {[1, 2, 3].map((i) => ( - - - - Artist {i} - - ➕ Add - - - - {[...Array(6)].map((_, j) => ( - - ))} - + + {loading ? ( + + + Loading releases... - ))} + ) : releases.length > 0 ? ( + releases.map((release: any, i: number) => ( + + + + {release.artist || 'Artist'} + + ➕ Add + + + + {[...Array(Math.min(6, release.tracks || 3))].map((_, j) => ( + + ))} + + + )) + ) : ( + + 🎵 + No new releases found + + )} ) -} \ No newline at end of file +} diff --git a/mobile/app/search.tsx b/mobile/app/search.tsx index 149b208..13fa54a 100644 --- a/mobile/app/search.tsx +++ b/mobile/app/search.tsx @@ -1,13 +1,17 @@ -import { View, Text, ScrollView, TouchableOpacity, TextInput } from 'react-native' +import { View, Text, ScrollView, TouchableOpacity, FlatList, ActivityIndicator } from 'react-native' import { useRouter } from 'expo-router' import { BottomNavBar } from '../src/components/BottomNavBar' import { usePlayerStore } from '../src/store/playerStore' +import { useState, useEffect } from 'react' export default function SearchScreen() { const router = useRouter() const currentSong = usePlayerStore(s => s.currentSong) const isPlaying = usePlayerStore(s => s.isPlaying) const togglePlay = usePlayerStore(s => s.togglePlay) + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) const features = [ { id: 'music', name: 'Music', icon: '🎵', route: '/library' }, @@ -15,8 +19,28 @@ export default function SearchScreen() { { id: 'events', name: 'Live Events', icon: '🎪', route: '/' }, { id: 'radio', name: 'Internet Radio', icon: '📻', route: '/radio' }, { id: 'mood', name: 'Mood Radio', icon: '😊', route: '/mood' }, + { id: 'lofi', name: 'LoFi', icon: '🌙', route: '/lofi' }, + { id: 'shareplay', name: 'SharePlay', icon: '📢', route: '/shareplay' }, ] + const handleSearch = async (text: string) => { + setQuery(text) + if (!text) { + setResults([]) + return + } + setLoading(true) + try { + const res = await fetch(`/api/search?q=${encodeURIComponent(text)}`) + const data = await res.json() + setResults(data.items || []) + } catch { + setResults([]) + } finally { + setLoading(false) + } + } + return ( @@ -24,40 +48,60 @@ export default function SearchScreen() { router.push('/account' as any)} className="w-10 h-10 rounded-full bg-music-card items-center justify-center"> 👤 - + Search 🔍 - + handleSearch(query)} + className="text-sm text-music-text ml-2 flex-1" + numberOfLines={1} + > + {query || 'What music is calling to you?'} + - - {features.map((f) => ( - router.push(f.route as any)} className="flex-1 min-w-[30%] p-4 rounded-xl bg-music-card items-center"> - {f.icon} - {f.name} - - ))} - + {loading && ( + + + + )} - Music Suggestions - - {[...Array(10)].map((_, i) => ( - - ))} - + {!loading && results.length > 0 && ( + + Search Results + {results.map((item: any, i: number) => ( + + + 🎵 + + + {item.title || item.name} + {item.artist || 'Unknown'} + + + ))} + + )} - Your Library - - {[1, 2, 3, 4].map((i) => ( - - - Playlist {i} + {(!query || results.length === 0) && ( + <> + Browse + + {features.map((f) => ( + router.push(f.route as any)} className="flex-1 min-w-[30%] p-4 rounded-xl bg-music-card items-center"> + {f.icon} + {f.name} + + ))} - ))} - + + )} {currentSong && ( @@ -77,4 +121,4 @@ export default function SearchScreen() { ) -} \ No newline at end of file +} diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..c27081e --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host"]