diff --git a/README.md b/README.md index 29ee51d..c36feee 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ Open [http://localhost:8000](http://localhost:8000) and start streaming! 🎵 ### 💾 Download & Save - **Save to Drive** - Direct save to Google Drive (FLAC/AIFF/MP3) +- **Save to Server** - Store tracks on server for instant local playback (no re-streaming) - **Single Tracks** - Download locally as Artist - Song.ext - **Full Albums/Playlists** - Batch download as Artist - Album.zip - **Large Playlists** - Playlists over 50 songs are automatically split into multiple ZIP parts (e.g., "Playlist (Part 1).zip") to ensure reliability. @@ -108,6 +109,14 @@ Open [http://localhost:8000](http://localhost:8000) and start streaming! 🎵 - **Current Track** - Press ⬇ on player bar or fullscreen to download now playing - **MusicBrainz Metadata** - Downloads enriched with release year, label, and high-res cover art +### 📚 Server Library +- **Permanent Storage** - Save tracks to a persistent server library (separate from cache) +- **Instant Playback** - Library tracks play immediately without external streaming +- **Visual Indicator** - "LOCAL" badge shows when playing from library +- **Organized Storage** - Files saved as `/library/tracks/Artist/Song.flac` +- **Library View** - Click 📚 in header to browse and manage saved tracks +- **Docker Volume** - Library persists via `freedify-library` volume + ### 📋 Queue Management - **Drag to Reorder** - Drag tracks to rearrange - **Add All / Shuffle All** - From any album or playlist @@ -317,6 +326,10 @@ python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 ``` 5. **Access:** Open http://localhost:8000 in your browser. +> **Volumes:** Docker Compose creates two persistent volumes: +> - `freedify-cache` - Temporary streaming cache (auto-cleaned) +> - `freedify-library` - Permanent server library for saved tracks + --- ## 🌐 Deploy to Railway (Recommended for Mobile + Hi-Res) @@ -372,6 +385,8 @@ When deploying to Render (or other hosts), set these in your Dashboard: | `SEATGEEK_CLIENT_ID` | For Concert Search fallback (free at seatgeek.com/account/develop) | | `DAB_SESSION` | **Recommended** - For Hi-Res (24-bit) Audio (from Dab/Qobuz) | | `DAB_VISITOR_ID` | **Recommended** - For Hi-Res (24-bit) Audio (from Dab/Qobuz) | +| `LIBRARY_DIR` | Server library path (default: `/app/library`) | +| `LIBRARY_MAX_SIZE_GB` | Max library size in GB (default: `0` = unlimited) | ### How to Get Dab Music Cookies (for Hi-Res Audio) diff --git a/app/library.py b/app/library.py new file mode 100644 index 0000000..1b7359f --- /dev/null +++ b/app/library.py @@ -0,0 +1,403 @@ +""" +Library service for persistent track storage. +Unlike the cache (TTL-based, auto-cleanup), the library is permanent user storage. + +File structure: /app/library/tracks/Artist Name/Song Name.flac +""" +import os +import re +import json +import asyncio +import aiofiles +from pathlib import Path +from typing import Optional, Dict, Any, List +import logging +import hashlib +from datetime import datetime + +logger = logging.getLogger(__name__) + +# Library configuration +_default_library = "/app/library" +LIBRARY_DIR = Path(os.environ.get("LIBRARY_DIR", _default_library)) +LIBRARY_MAX_SIZE_GB = float(os.environ.get("LIBRARY_MAX_SIZE_GB", "0")) # 0 = unlimited + +# Index file stores metadata about all tracks in the library +INDEX_FILE = LIBRARY_DIR / "index.json" + +# Lock for concurrent index writes +_index_lock = asyncio.Lock() + + +def ensure_library_dir(): + """Ensure library directory and structure exists.""" + LIBRARY_DIR.mkdir(parents=True, exist_ok=True) + tracks_dir = LIBRARY_DIR / "tracks" + tracks_dir.mkdir(parents=True, exist_ok=True) + return LIBRARY_DIR + + +def sanitize_filename(name: str, max_length: int = 100) -> str: + """Sanitize a string for use as a filename. + + Removes/replaces characters that are invalid in filenames. + """ + if not name: + return "Unknown" + + # Replace problematic characters + # Windows: \ / : * ? " < > | + # Also replace other potentially problematic chars + sanitized = re.sub(r'[\\/:*?"<>|]', '_', name) + + # Replace multiple spaces/underscores with single + sanitized = re.sub(r'[_\s]+', ' ', sanitized) + + # Strip leading/trailing whitespace and dots (Windows issue) + sanitized = sanitized.strip(' .') + + # Truncate if too long + if len(sanitized) > max_length: + sanitized = sanitized[:max_length].strip(' .') + + # Fallback if empty after sanitization + if not sanitized: + return "Unknown" + + return sanitized + + +def get_file_path_for_track(artist: str, name: str, format: str = "flac") -> Path: + """Get the file path for a track based on artist and song name. + + Structure: /app/library/tracks/Artist Name/Song Name.flac + """ + ensure_library_dir() + + safe_artist = sanitize_filename(artist or "Unknown Artist") + safe_name = sanitize_filename(name or "Unknown Track") + + artist_dir = LIBRARY_DIR / "tracks" / safe_artist + artist_dir.mkdir(parents=True, exist_ok=True) + + return artist_dir / f"{safe_name}.{format}" + + +def get_file_path(isrc: str, format: str = "flac", metadata: Optional[Dict[str, Any]] = None) -> Path: + """Get the file path for a track in the library. + + If metadata with artist/name is provided, uses artist/song structure. + Otherwise falls back to ISRC-based naming. + """ + if metadata and metadata.get("artist") and metadata.get("name"): + return get_file_path_for_track( + metadata["artist"], + metadata["name"], + format + ) + + # Fallback: use ISRC as filename in root tracks folder + ensure_library_dir() + tracks_dir = LIBRARY_DIR / "tracks" + tracks_dir.mkdir(parents=True, exist_ok=True) + + safe_isrc = sanitize_filename(isrc) + return tracks_dir / f"{safe_isrc}.{format}" + + +async def load_index() -> Dict[str, Any]: + """Load the library index from disk.""" + ensure_library_dir() + if not INDEX_FILE.exists(): + return {"tracks": {}, "version": 2, "created": datetime.utcnow().isoformat()} + + try: + async with aiofiles.open(INDEX_FILE, 'r') as f: + content = await f.read() + return json.loads(content) + except Exception as e: + logger.error(f"Error loading library index: {e}") + return {"tracks": {}, "version": 2, "created": datetime.utcnow().isoformat()} + + +async def save_index(index: Dict[str, Any]): + """Save the library index to disk.""" + ensure_library_dir() + try: + async with aiofiles.open(INDEX_FILE, 'w') as f: + await f.write(json.dumps(index, indent=2)) + except Exception as e: + logger.error(f"Error saving library index: {e}") + + +async def get_track_file_path(isrc: str) -> Optional[Path]: + """Get the actual file path for a track from the index.""" + index = await load_index() + track_info = index.get("tracks", {}).get(isrc) + + if not track_info: + return None + + # Get path from index + rel_path = track_info.get("path") + if rel_path: + return LIBRARY_DIR / rel_path + + # Fallback: reconstruct from metadata + return get_file_path(isrc, track_info.get("format", "flac"), track_info) + + +async def track_exists(isrc: str, format: str = "flac") -> bool: + """Check if a track exists in the library.""" + index = await load_index() + if isrc not in index.get("tracks", {}): + return False + + file_path = await get_track_file_path(isrc) + return file_path is not None and file_path.exists() + + +async def get_track_info(isrc: str) -> Optional[Dict[str, Any]]: + """Get metadata for a track in the library.""" + index = await load_index() + return index.get("tracks", {}).get(isrc) + + +async def add_track( + isrc: str, + data: bytes, + format: str = "flac", + metadata: Optional[Dict[str, Any]] = None +) -> bool: + """Add a track to the library. + + Args: + isrc: Track identifier + data: Audio file bytes + format: File format (flac, mp3, etc.) + metadata: Optional track metadata (name, artist, album, etc.) + + Returns: + True if successful, False otherwise + """ + try: + # Check storage limits + if LIBRARY_MAX_SIZE_GB > 0: + current_size = await get_library_size_gb() + new_size = len(data) / (1024 * 1024 * 1024) + if current_size + new_size > LIBRARY_MAX_SIZE_GB: + logger.warning(f"Library storage limit exceeded ({LIBRARY_MAX_SIZE_GB} GB)") + return False + + # Determine file path based on metadata + file_path = get_file_path(isrc, format, metadata) + + # Write file + async with aiofiles.open(file_path, 'wb') as f: + await f.write(data) + + # Update index + async with _index_lock: + index = await load_index() + index["tracks"][isrc] = { + "format": format, + "size": len(data), + "added": datetime.utcnow().isoformat(), + "path": str(file_path.relative_to(LIBRARY_DIR)), + **(metadata or {}) + } + index["updated"] = datetime.utcnow().isoformat() + await save_index(index) + + artist = metadata.get("artist", "Unknown") if metadata else "Unknown" + name = metadata.get("name", isrc) if metadata else isrc + logger.info(f"Added to library: {artist} - {name} ({len(data) / 1024 / 1024:.2f} MB)") + return True + + except Exception as e: + logger.error(f"Error adding track to library: {e}") + return False + + +async def delete_track(isrc: str) -> bool: + """Remove a track from the library.""" + try: + # Get track info first + index = await load_index() + track_info = index.get("tracks", {}).get(isrc) + + if not track_info: + logger.warning(f"Track not in library index: {isrc}") + return False + + # Get file path from index + file_path = await get_track_file_path(isrc) + if file_path and file_path.exists(): + file_path.unlink() + logger.info(f"Deleted library file: {file_path}") + + # Try to remove empty artist directory + try: + artist_dir = file_path.parent + if artist_dir != LIBRARY_DIR / "tracks" and not any(artist_dir.iterdir()): + artist_dir.rmdir() + logger.info(f"Removed empty artist directory: {artist_dir}") + except Exception: + pass + + # Update index + async with _index_lock: + index = await load_index() + if isrc in index.get("tracks", {}): + del index["tracks"][isrc] + index["updated"] = datetime.utcnow().isoformat() + await save_index(index) + + logger.info(f"Removed from library: {isrc}") + return True + + except Exception as e: + logger.error(f"Error deleting track from library: {e}") + return False + + +async def list_tracks( + offset: int = 0, + limit: int = 50, + sort_by: str = "added", + sort_desc: bool = True +) -> Dict[str, Any]: + """List tracks in the library with pagination. + + Returns: + Dict with tracks list and total count + """ + index = await load_index() + tracks = index.get("tracks", {}) + + # Convert to list with ISRCs + track_list = [ + {"isrc": isrc, **info} + for isrc, info in tracks.items() + ] + + # Sort + if sort_by in ["added", "name", "artist", "size"]: + track_list.sort( + key=lambda x: x.get(sort_by, ""), + reverse=sort_desc + ) + + # Paginate + total = len(track_list) + paginated = track_list[offset:offset + limit] + + return { + "tracks": paginated, + "total": total, + "offset": offset, + "limit": limit + } + + +async def check_multiple(isrcs: List[str]) -> Dict[str, bool]: + """Check if multiple tracks exist in the library. + + Efficient batch check for displaying library badges. + + Args: + isrcs: List of track identifiers + + Returns: + Dict mapping ISRC to exists boolean + """ + index = await load_index() + tracks = index.get("tracks", {}) + + result = {} + for isrc in isrcs: + if isrc in tracks: + # Check file exists using path from index + rel_path = tracks[isrc].get("path") + if rel_path: + file_path = LIBRARY_DIR / rel_path + result[isrc] = file_path.exists() + else: + result[isrc] = False + else: + result[isrc] = False + + return result + + +async def get_library_size_gb() -> float: + """Get total library size in GB.""" + ensure_library_dir() + total = 0 + tracks_dir = LIBRARY_DIR / "tracks" + + if tracks_dir.exists(): + for root, dirs, files in os.walk(tracks_dir): + for file in files: + try: + total += os.path.getsize(os.path.join(root, file)) + except OSError: + pass + + return total / (1024 * 1024 * 1024) + + +async def get_stats() -> Dict[str, Any]: + """Get library statistics.""" + index = await load_index() + tracks = index.get("tracks", {}) + + total_size = sum(t.get("size", 0) for t in tracks.values()) + + # Count formats + formats = {} + artists = set() + for track in tracks.values(): + fmt = track.get("format", "unknown") + formats[fmt] = formats.get(fmt, 0) + 1 + if track.get("artist"): + artists.add(track["artist"]) + + return { + "track_count": len(tracks), + "artist_count": len(artists), + "total_size_mb": round(total_size / (1024 * 1024), 2), + "total_size_gb": round(total_size / (1024 * 1024 * 1024), 2), + "formats": formats, + "max_size_gb": LIBRARY_MAX_SIZE_GB if LIBRARY_MAX_SIZE_GB > 0 else None, + "created": index.get("created"), + "updated": index.get("updated") + } + + +async def verify_index(): + """Verify index matches actual files, clean up orphaned entries.""" + async with _index_lock: + index = await load_index() + tracks = index.get("tracks", {}) + + to_remove = [] + for isrc, info in tracks.items(): + rel_path = info.get("path") + if rel_path: + file_path = LIBRARY_DIR / rel_path + else: + file_path = get_file_path(isrc, info.get("format", "flac"), info) + + if not file_path.exists(): + logger.warning(f"Orphaned index entry (file missing): {isrc}") + to_remove.append(isrc) + + if to_remove: + for isrc in to_remove: + del tracks[isrc] + index["updated"] = datetime.utcnow().isoformat() + await save_index(index) + logger.info(f"Cleaned {len(to_remove)} orphaned entries from library index") + + return len(to_remove) diff --git a/app/main.py b/app/main.py index a5eaf67..dc1ab9f 100644 --- a/app/main.py +++ b/app/main.py @@ -34,6 +34,7 @@ from app.concert_service import concert_service from app.cache import cleanup_cache, periodic_cleanup, is_cached, get_cache_path +from app import library # Configure logging logging.basicConfig( @@ -445,9 +446,34 @@ async def stream_audio( """Stream audio for a track by ISRC.""" try: logger.info(f"Stream request for ISRC: {isrc} (hires={hires})") - + + # 0. Check Library first (permanent user storage) + try: + track_info = await library.get_track_info(isrc) + if track_info: + format = track_info.get("format", "flac") + file_path = await library.get_track_file_path(isrc) + if file_path and file_path.exists(): + logger.info(f"Serving from library: {file_path}") + mime_type = "audio/flac" if format == "flac" else f"audio/{format}" + return FileResponse( + file_path, + media_type=mime_type, + headers={ + "Accept-Ranges": "bytes", + "Cache-Control": "public, max-age=86400", + "X-Source": "library" + } + ) + else: + # File missing - clean up orphaned index entry + logger.warning(f"Library file missing, cleaning index: {isrc}") + await library.delete_track(isrc) + except Exception as e: + logger.warning(f"Library check failed: {e}") + target_stream_url = None - + # 1. Resolve Target Stream URL (Direct or via yt-dlp) # Handle Imported Links (LINK:) @@ -1036,6 +1062,171 @@ async def process_track(i: int, isrc: str): raise HTTPException(status_code=500, detail=str(e)) +# ========== SERVER LIBRARY ========== + +class LibrarySaveRequest(BaseModel): + """Request to save track(s) to server library.""" + isrc: str + q: Optional[str] = None # Search query hint + format: str = "flac" + # Optional metadata to store + name: Optional[str] = None + artist: Optional[str] = None + album: Optional[str] = None + album_art: Optional[str] = None + + +class LibrarySaveBatchRequest(BaseModel): + """Request to save multiple tracks to server library.""" + tracks: List[LibrarySaveRequest] + + +@app.post("/api/library/save") +async def library_save(request: LibrarySaveRequest): + """Save a track to the server library for local playback.""" + try: + logger.info(f"Library save request: {request.isrc} ({request.format})") + + # Check if already exists + if await library.track_exists(request.isrc, request.format): + logger.info(f"Track already in library: {request.isrc}") + return {"success": True, "message": "Already in library", "isrc": request.isrc} + + # Fetch the audio + result = await audio_service.get_download_audio( + request.isrc, + request.q or "", + request.format + ) + + if not result: + raise HTTPException(status_code=404, detail="Could not fetch audio") + + data, ext, mime = result + + # Build metadata + metadata = {} + if request.name: + metadata["name"] = request.name + if request.artist: + metadata["artist"] = request.artist + if request.album: + metadata["album"] = request.album + if request.album_art: + metadata["album_art"] = request.album_art + + # Save to library + success = await library.add_track( + request.isrc, + data, + request.format, + metadata + ) + + if not success: + # Check if storage limit + stats = await library.get_stats() + if stats.get("max_size_gb") and stats.get("total_size_gb", 0) >= stats["max_size_gb"]: + raise HTTPException( + status_code=507, + detail=f"Library storage limit reached ({stats['max_size_gb']} GB)" + ) + raise HTTPException(status_code=500, detail="Failed to save to library") + + return { + "success": True, + "message": "Saved to library", + "isrc": request.isrc, + "format": request.format, + "size_mb": round(len(data) / (1024 * 1024), 2) + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Library save error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/library/save-batch") +async def library_save_batch(request: LibrarySaveBatchRequest): + """Save multiple tracks to the server library.""" + try: + results = [] + for track in request.tracks: + try: + result = await library_save(track) + results.append({"isrc": track.isrc, "success": True}) + except HTTPException as e: + results.append({"isrc": track.isrc, "success": False, "error": e.detail}) + except Exception as e: + results.append({"isrc": track.isrc, "success": False, "error": str(e)}) + + successful = sum(1 for r in results if r.get("success")) + return { + "success": successful > 0, + "saved": successful, + "total": len(request.tracks), + "results": results + } + except Exception as e: + logger.error(f"Library batch save error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/library/list") +async def library_list( + offset: int = Query(0, description="Pagination offset"), + limit: int = Query(50, description="Number of tracks to return"), + sort_by: str = Query("added", description="Sort field: added, name, artist, size"), + sort_desc: bool = Query(True, description="Sort descending") +): + """List all tracks in the server library.""" + try: + return await library.list_tracks(offset, limit, sort_by, sort_desc) + except Exception as e: + logger.error(f"Library list error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/library/check") +async def library_check(isrcs: str = Query(..., description="Comma-separated ISRCs to check")): + """Check if tracks exist in the library. Returns dict of ISRC -> exists boolean.""" + try: + isrc_list = [s.strip() for s in isrcs.split(",") if s.strip()] + if not isrc_list: + return {} + return await library.check_multiple(isrc_list) + except Exception as e: + logger.error(f"Library check error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.delete("/api/library/{isrc}") +async def library_delete(isrc: str): + """Remove a track from the server library.""" + try: + success = await library.delete_track(isrc) + if not success: + raise HTTPException(status_code=404, detail="Track not found in library") + return {"success": True, "message": "Removed from library", "isrc": isrc} + except HTTPException: + raise + except Exception as e: + logger.error(f"Library delete error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/library/stats") +async def library_stats(): + """Get library statistics (size, count, formats).""" + try: + return await library.get_stats() + except Exception as e: + logger.error(f"Library stats error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + # ========== GOOGLE DRIVE ========== class UploadToDriveRequest(BaseModel): diff --git a/docker-compose.yml b/docker-compose.yml index e1084d4..f706d9e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,11 +27,17 @@ services: # Optional - Podcasts (falls back to iTunes if not set) # - PODCASTINDEX_KEY=your_key # - PODCASTINDEX_SECRET=your_secret + # Optional - Server Library settings + # - LIBRARY_DIR=/app/library + # - LIBRARY_MAX_SIZE_GB=0 # 0 = unlimited volumes: # Persist cache for faster repeated plays - freedify-cache:/app/cache + # Persist server library (saved tracks for local playback) + - freedify-library:/app/library # For NAS users with custom paths, use: # - /your/nas/path/freedify-cache:/app/cache + # - /your/nas/path/freedify-library:/app/library restart: unless-stopped healthcheck: test: [ "CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/api/health')" ] @@ -41,3 +47,4 @@ services: volumes: freedify-cache: + freedify-library: diff --git a/static/app.js b/static/app.js index a32ecc8..d0d03d2 100644 --- a/static/app.js +++ b/static/app.js @@ -1874,6 +1874,294 @@ downloadModal.addEventListener('click', (e) => { if (e.target === downloadModal) closeDownloadModal(); }); +// ========== SERVER LIBRARY ========== + +const libraryBtn = $('#library-btn'); +const librarySection = $('#library-section'); +const libraryContainer = $('#library-container'); +const libraryCount = $('#library-count'); +const librarySize = $('#library-size'); +const libraryClose = $('#library-close'); +const libraryRefresh = $('#library-refresh'); +const downloadLibraryBtn = $('#download-library-btn'); + +// Track library state +let libraryTracks = []; +let libraryLookup = {}; // isrc -> true for fast lookup + +// Toggle library view +if (libraryBtn) { + libraryBtn.addEventListener('click', () => { + const isHidden = librarySection.classList.contains('hidden'); + if (isHidden) { + showLibrary(); + } else { + hideLibrary(); + } + }); +} + +if (libraryClose) { + libraryClose.addEventListener('click', hideLibrary); +} + +if (libraryRefresh) { + libraryRefresh.addEventListener('click', loadLibrary); +} + +function showLibrary() { + librarySection.classList.remove('hidden'); + queueSection.classList.add('hidden'); + loadLibrary(); +} + +function hideLibrary() { + librarySection.classList.add('hidden'); +} + +async function loadLibrary() { + try { + const response = await fetch('/api/library/list?limit=200'); + if (!response.ok) throw new Error('Failed to load library'); + + const data = await response.json(); + libraryTracks = data.tracks || []; + + // Update lookup + libraryLookup = {}; + libraryTracks.forEach(t => libraryLookup[t.isrc] = true); + + // Update stats + const statsResp = await fetch('/api/library/stats'); + if (statsResp.ok) { + const stats = await statsResp.json(); + libraryCount.textContent = `(${stats.track_count} tracks)`; + librarySize.textContent = stats.total_size_gb > 1 + ? `${stats.total_size_gb.toFixed(2)} GB` + : `${stats.total_size_mb.toFixed(1)} MB`; + } + + renderLibrary(); + } catch (error) { + console.error('Library load error:', error); + showToast('Failed to load library'); + } +} + +function renderLibrary() { + if (!libraryContainer) return; + + if (libraryTracks.length === 0) { + libraryContainer.innerHTML = ` +
Your server library is empty
+Save tracks using the "Save to Server" button in the download menu
+