From 71a42ea169a2d9ce975c2d185ac7bbec4d0dce6b Mon Sep 17 00:00:00 2001 From: Marcel Meier Date: Sun, 1 Feb 2026 15:20:30 +0100 Subject: [PATCH 1/2] feat: Implement server library for persistent track storage --- app/library.py | 403 +++++++++++++++++++++++++++++++++++++++++++++ app/main.py | 195 +++++++++++++++++++++- docker-compose.yml | 7 + static/app.js | 343 +++++++++++++++++++++++++++++++++++++- static/index.html | 24 +++ static/styles.css | 229 ++++++++++++++++++++++++++ 6 files changed, 1191 insertions(+), 10 deletions(-) create mode 100644 app/library.py 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

+
+ `; + return; + } + + libraryContainer.innerHTML = libraryTracks.map((track, i) => { + const name = track.name || track.isrc; + const artist = track.artist || 'Unknown Artist'; + const sizeMb = (track.size / (1024 * 1024)).toFixed(1); + const added = track.added ? new Date(track.added).toLocaleDateString() : ''; + + return ` +
+
+ ${name} + ${artist} +
+
+ ${track.format?.toUpperCase() || 'FLAC'} + ${sizeMb} MB +
+
+ + +
+
+ `; + }).join(''); + + // Add event listeners + libraryContainer.querySelectorAll('.library-item').forEach(item => { + const isrc = item.dataset.isrc; + const idx = parseInt(item.dataset.index); + + item.querySelector('.library-play-btn').addEventListener('click', (e) => { + e.stopPropagation(); + playLibraryTrack(idx); + }); + + item.querySelector('.library-delete-btn').addEventListener('click', (e) => { + e.stopPropagation(); + deleteFromLibrary(isrc, idx); + }); + + // Click on item to play + item.addEventListener('click', () => playLibraryTrack(idx)); + }); +} + +async function playLibraryTrack(index) { + const track = libraryTracks[index]; + if (!track) return; + + // Build a track object compatible with the player + const playableTrack = { + id: track.isrc, + isrc: track.isrc, + name: track.name || track.isrc, + artists: track.artist || 'Library Track', + album: track.album || 'Library', + album_art: track.album_art || '/static/icon.svg', + source: 'library' + }; + + // Add to queue and play + state.queue = [playableTrack]; + state.currentIndex = 0; + updateQueueUI(); + playTrack(playableTrack); + hideLibrary(); + showToast(`Playing from library: ${playableTrack.name}`); +} + +async function deleteFromLibrary(isrc, index) { + if (!confirm('Remove this track from the server library?')) return; + + try { + const response = await fetch(`/api/library/${encodeURIComponent(isrc)}`, { + method: 'DELETE' + }); + + if (!response.ok) throw new Error('Failed to delete'); + + // Remove from local state + libraryTracks.splice(index, 1); + delete libraryLookup[isrc]; + renderLibrary(); + showToast('Removed from library'); + } catch (error) { + console.error('Library delete error:', error); + showToast('Failed to remove from library'); + } +} + +// Save to Server Library button handler +if (downloadLibraryBtn) { + downloadLibraryBtn.addEventListener('click', async () => { + const format = downloadFormat.value; + const track = trackToDownload; + const isBatch = isBatchDownload; + + closeDownloadModal(); + + if (isBatch) { + // Batch save to library + const tracks = state.detailTracks; + updateDownloadUI(2, `Saving ${tracks.length} tracks to server library...`); + + let saved = 0; + let failed = 0; + + for (let i = 0; i < tracks.length; i++) { + const t = tracks[i]; + try { + const response = await fetch('/api/library/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + isrc: t.isrc || t.id, + q: `${t.name} ${t.artists}`, + format: format.replace('_24', ''), // Use base format + name: t.name, + artist: t.artists, + album: t.album, + album_art: t.album_art + }) + }); + + if (response.ok) { + saved++; + libraryLookup[t.isrc || t.id] = true; + } else { + failed++; + } + } catch (e) { + failed++; + } + + updateDownloadUI(Math.round((i + 1) / tracks.length * 100), + `Saving to library: ${i + 1}/${tracks.length}`); + } + + hideDownloadUI(); + showToast(`Saved ${saved} tracks to library` + (failed ? ` (${failed} failed)` : '')); + + } else if (track) { + // Single track save + updateDownloadUI(10, `Saving "${track.name}" to server library...`); + + try { + const response = await fetch('/api/library/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + isrc: track.isrc || track.id, + q: `${track.name} ${track.artists}`, + format: format.replace('_24', ''), + name: track.name, + artist: track.artists, + album: track.album, + album_art: track.album_art + }) + }); + + updateDownloadUI(90, 'Finalizing...'); + + if (!response.ok) { + const err = await response.json(); + throw new Error(err.detail || 'Save failed'); + } + + const result = await response.json(); + libraryLookup[track.isrc || track.id] = true; + + hideDownloadUI(); + showToast(`Saved to library: ${track.name} (${result.size_mb} MB)`); + + } catch (error) { + hideDownloadUI(); + console.error('Library save error:', error); + showError(`Failed to save to library: ${error.message}`); + } + } + }); +} + +// Check if tracks are in library (for displaying badges) +async function checkLibraryStatus(isrcs) { + if (!isrcs || isrcs.length === 0) return {}; + + try { + const response = await fetch(`/api/library/check?isrcs=${encodeURIComponent(isrcs.join(','))}`); + if (!response.ok) return {}; + return await response.json(); + } catch (e) { + console.error('Library check error:', e); + return {}; + } +} + +// Helper to check if a track is in library +function isInLibrary(isrc) { + return !!libraryLookup[isrc]; +} + // ========== KEYBOARD SHORTCUTS ========== document.addEventListener('keydown', (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; @@ -2302,31 +2590,70 @@ function updatePlayerUI() { // Update audio format badge (FLAC/MP3) async function updateFormatBadge(audioSrc) { const badge = document.getElementById('audio-format-badge'); + const sourceBadge = document.getElementById('audio-source-badge'); if (!badge) return; - + // For local files, show nothing if (!audioSrc || audioSrc.startsWith('blob:') || audioSrc.startsWith('file:')) { badge.classList.add('hidden'); + if (sourceBadge) sourceBadge.classList.add('hidden'); return; } - + // Get current track source to determine actual quality const currentTrack = state.queue[state.currentIndex]; const source = currentTrack?.source || ''; - + // Determine format based on source const isHiResSource = source === 'dab' || source === 'qobuz'; const isHiFiSource = source === 'deezer' || source === 'jamendo'; const isLossySource = source === 'ytmusic' || source === 'youtube' || source === 'podcast' || source === 'import'; - + const isLibrarySource = source === 'library'; + badge.classList.remove('hidden', 'mp3', 'flac', 'hi-res'); - + + // Check if playing from server library + // First check our local lookup (updated immediately when saving) + const trackIsrc = currentTrack?.isrc || currentTrack?.id; + let isFromLibrary = isLibrarySource || (trackIsrc && isInLibrary(trackIsrc)); + + // If not in local lookup, verify via HEAD request (with cache-busting) + if (!isFromLibrary && audioSrc.includes('/api/stream/')) { + try { + const cacheBuster = `_cb=${Date.now()}`; + const separator = audioSrc.includes('?') ? '&' : '?'; + const response = await fetch(audioSrc + separator + cacheBuster, { + method: 'HEAD', + cache: 'no-store' + }); + isFromLibrary = response.headers.get('X-Source') === 'library'; + // Update local lookup if found in library + if (isFromLibrary && trackIsrc) { + libraryLookup[trackIsrc] = true; + } + } catch (e) { + // Ignore errors + } + } + + // Update source badge (LOCAL indicator) + if (sourceBadge) { + if (isFromLibrary) { + sourceBadge.classList.remove('hidden'); + sourceBadge.textContent = 'LOCAL'; + sourceBadge.title = 'Playing from server library'; + } else { + sourceBadge.classList.add('hidden'); + } + } + + // Update format badge (quality indicator) if (isHiResSource && state.hiResMode) { // Hi-Res 24-bit (Dab/Qobuz with Hi-Res mode) badge.classList.add('flac', 'hi-res'); badge.textContent = 'Hi-Res'; - } else if (isHiResSource || isHiFiSource) { - // HiFi 16-bit FLAC (Deezer, Jamendo, or Dab without Hi-Res mode) + } else if (isHiResSource || isHiFiSource || isFromLibrary) { + // HiFi 16-bit FLAC (Deezer, Jamendo, Dab without Hi-Res mode, or Library) badge.classList.add('flac'); badge.textContent = 'FLAC'; } else if (isLossySource) { @@ -2341,7 +2668,7 @@ async function updateFormatBadge(audioSrc) { } badge.textContent = 'FLAC'; } - + // Also update the HiFi button in header if (typeof updateHifiButtonUI === 'function') { updateHifiButtonUI(); diff --git a/static/index.html b/static/index.html index a1a6a5b..41ef533 100644 --- a/static/index.html +++ b/static/index.html @@ -65,6 +65,7 @@

Freedify

+ @@ -175,6 +176,7 @@

Download Track

@@ -241,6 +243,27 @@

Queue (0)

+ + + diff --git a/static/styles.css b/static/styles.css index 95ef1c6..d95276d 100644 --- a/static/styles.css +++ b/static/styles.css @@ -620,6 +620,23 @@ body { color: var(--text-tertiary); } +/* Source badge (LOCAL indicator) */ +.audio-source-badge { + font-size: 0.6rem; + font-weight: 700; + padding: 2px 5px; + border-radius: 4px; + text-transform: uppercase; + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + color: white; + box-shadow: 0 0 8px rgba(245, 158, 11, 0.4); + margin-left: 4px; +} + +.audio-source-badge.hidden { + display: none; +} + /* Progress Bar */ .player-progress-container { display: flex; @@ -1666,6 +1683,218 @@ body { padding: var(--spacing-sm); } +/* ==================== Server Library Section ==================== */ +.library-section { + position: fixed; + top: 0; + left: 0; + right: 0; + max-height: 70vh; + background: var(--bg-secondary); + border-bottom-left-radius: var(--radius-lg); + border-bottom-right-radius: var(--radius-lg); + z-index: 200; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 4px 30px rgba(0, 0, 0, 0.5); + border-bottom: 1px solid var(--bg-tertiary); +} + +.library-section.hidden { + display: none; +} + +.library-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-md); + background: var(--bg-elevated); + border-bottom: 1px solid var(--bg-tertiary); +} + +.library-header h3 { + margin: 0; + font-size: 1rem; + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.library-controls { + display: flex; + gap: var(--spacing-md); + align-items: center; +} + +.library-size { + font-size: 0.75rem; + color: var(--text-secondary); + background: var(--bg-tertiary); + padding: 2px 8px; + border-radius: var(--radius-sm); +} + +.library-refresh-btn, +.library-close-btn { + width: 28px; + height: 28px; + border-radius: 50%; + border: none; + background: var(--bg-tertiary); + color: var(--text-primary); + font-size: 1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.library-refresh-btn:hover, +.library-close-btn:hover { + background: var(--accent); +} + +.library-hint { + font-size: 0.75rem; + color: var(--text-secondary); + padding: var(--spacing-xs) var(--spacing-md); + margin: 0; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--bg-secondary); +} + +.library-container { + flex: 1; + overflow-y: auto; + padding: var(--spacing-sm); +} + +.library-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--spacing-xl); + text-align: center; + color: var(--text-secondary); +} + +.library-empty .empty-icon { + font-size: 3rem; + margin-bottom: var(--spacing-md); +} + +.library-empty .hint { + font-size: 0.75rem; + margin-top: var(--spacing-sm); +} + +.library-item { + display: flex; + align-items: center; + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + cursor: pointer; + transition: background 0.2s; + gap: var(--spacing-md); +} + +.library-item:hover { + background: var(--bg-tertiary); +} + +.library-item-info { + flex: 1; + min-width: 0; +} + +.library-item-name { + display: block; + font-weight: 500; + font-size: 0.875rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.library-item-artist { + display: block; + font-size: 0.75rem; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.library-item-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; +} + +.library-item-format { + font-size: 0.625rem; + font-weight: 600; + color: var(--accent); + background: rgba(var(--accent-rgb), 0.15); + padding: 1px 6px; + border-radius: var(--radius-sm); +} + +.library-item-size { + font-size: 0.625rem; + color: var(--text-secondary); +} + +.library-item-actions { + display: flex; + gap: var(--spacing-xs); +} + +.library-play-btn, +.library-delete-btn { + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background: var(--bg-elevated); + color: var(--text-primary); + font-size: 0.875rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.library-play-btn:hover { + background: var(--accent); + color: white; +} + +.library-delete-btn:hover { + background: var(--error); + color: white; +} + +/* Library badge on tracks */ +.library-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + font-size: 0.625rem; + background: var(--accent); + color: white; + border-radius: 50%; + margin-left: 4px; + vertical-align: middle; +} + /* Spinner */ .spinner { width: 48px; From b70e880b0a1bb5f59e018387ebf033f1e71e75bd Mon Sep 17 00:00:00 2001 From: Marcel Meier Date: Sun, 1 Feb 2026 15:28:43 +0100 Subject: [PATCH 2/2] updated README docu --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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)