diff --git a/database/music_library.db-shm b/database/music_library.db-shm new file mode 100644 index 00000000..fe9ac284 Binary files /dev/null and b/database/music_library.db-shm differ diff --git a/database/music_library.db-wal b/database/music_library.db-wal new file mode 100644 index 00000000..e69de29b diff --git a/web_server.py b/web_server.py index 6adaf4da..92807dab 100644 --- a/web_server.py +++ b/web_server.py @@ -39,6 +39,7 @@ from services.sync_service import PlaylistSyncService from datetime import datetime import yt_dlp from core.matching_engine import MusicMatchingEngine +from beatport_unified_scraper import BeatportUnifiedScraper # --- Flask App Setup --- base_dir = os.path.abspath(os.path.dirname(__file__)) @@ -11855,6 +11856,161 @@ def get_active_media_server(): print(f"Error getting active media server: {e}") return jsonify({"success": False, "error": str(e)}), 500 +# ================================= # +# BEATPORT API ENDPOINTS # +# ================================= # + +@app.route('/api/beatport/genres', methods=['GET']) +def get_beatport_genres(): + """Get current Beatport genres with images dynamically scraped from homepage""" + try: + logger.info("🔍 API request for Beatport genres") + + # Initialize the Beatport scraper + scraper = BeatportUnifiedScraper() + + # Get query parameters + include_images = request.args.get('include_images', 'false').lower() == 'true' + + # Discover genres dynamically + if include_images: + logger.info("🖼️ Including genre images in response (slower)") + genres = scraper.discover_genres_with_images(include_images=True) + else: + logger.info("📝 Returning genres without images (faster)") + genres = scraper.discover_genres_from_homepage() + + logger.info(f"✅ Successfully discovered {len(genres)} Beatport genres") + + return jsonify({ + "success": True, + "genres": genres, + "count": len(genres), + "includes_images": include_images + }) + + except Exception as e: + logger.error(f"❌ Error fetching Beatport genres: {e}") + return jsonify({ + "success": False, + "error": str(e), + "genres": [], + "count": 0 + }), 500 + +@app.route('/api/beatport/genre///tracks', methods=['GET']) +def get_beatport_genre_tracks(genre_slug, genre_id): + """Get tracks for a specific Beatport genre""" + try: + logger.info(f"🎵 API request for {genre_slug} genre tracks (ID: {genre_id})") + + # Initialize the Beatport scraper + scraper = BeatportUnifiedScraper() + + # Get query parameters + limit = int(request.args.get('limit', '100')) + + # Create genre dict for scraper + genre = { + 'name': genre_slug.replace('-', ' ').title(), + 'slug': genre_slug, + 'id': genre_id + } + + # Scrape tracks for this genre + tracks = scraper.scrape_genre_charts(genre, limit=limit) + + logger.info(f"✅ Successfully scraped {len(tracks)} tracks for {genre_slug}") + + return jsonify({ + "success": True, + "tracks": tracks, + "genre": genre, + "count": len(tracks) + }) + + except Exception as e: + logger.error(f"❌ Error fetching tracks for {genre_slug}: {e}") + return jsonify({ + "success": False, + "error": str(e), + "tracks": [], + "count": 0 + }), 500 + +@app.route('/api/beatport/top-100', methods=['GET']) +def get_beatport_top_100(): + """Get Beatport Top 100 tracks""" + try: + logger.info("🔥 API request for Beatport Top 100") + + # Initialize the Beatport scraper + scraper = BeatportUnifiedScraper() + + # Get query parameters + limit = int(request.args.get('limit', '100')) + + # Scrape Top 100 + tracks = scraper.scrape_top_100(limit=limit) + + logger.info(f"✅ Successfully scraped {len(tracks)} tracks from Beatport Top 100") + + return jsonify({ + "success": True, + "tracks": tracks, + "chart_name": "Beatport Top 100", + "count": len(tracks) + }) + + except Exception as e: + logger.error(f"❌ Error fetching Beatport Top 100: {e}") + return jsonify({ + "success": False, + "error": str(e), + "tracks": [], + "count": 0 + }), 500 + +@app.route('/api/beatport/genre-image//', methods=['GET']) +def get_beatport_genre_image(genre_slug, genre_id): + """Get image for a specific Beatport genre""" + try: + logger.info(f"🖼️ API request for {genre_slug} genre image") + + # Initialize the Beatport scraper + scraper = BeatportUnifiedScraper() + + # Construct genre URL + genre_url = f"{scraper.base_url}/genre/{genre_slug}/{genre_id}" + + # Get genre image + image_url = scraper.get_genre_image(genre_url) + + if image_url: + logger.info(f"✅ Found image for {genre_slug}") + return jsonify({ + "success": True, + "image_url": image_url, + "genre_slug": genre_slug, + "genre_id": genre_id + }) + else: + logger.info(f"⚠️ No image found for {genre_slug}") + return jsonify({ + "success": False, + "image_url": None, + "genre_slug": genre_slug, + "genre_id": genre_id + }) + + except Exception as e: + logger.error(f"❌ Error fetching image for {genre_slug}: {e}") + return jsonify({ + "success": False, + "error": str(e), + "image_url": None + }), 500 + class WebMetadataUpdateWorker: """Web-based metadata update worker - EXACT port of dashboard.py MetadataUpdateWorker""" diff --git a/webui/index.html b/webui/index.html index 185c80a1..05413167 100644 --- a/webui/index.html +++ b/webui/index.html @@ -409,26 +409,183 @@
-
-
-
-

🔥 Top Charts

-

Beatport Top 100, Hype Top 10, New Releases

- 3 Charts + +
+
+
+
+

🔥 Top Charts

+

Beatport Top 100, Hype Top 10, New Releases

+ 3 Charts +
+ +
+
+

🎵 Genre Explorer

+

House, Techno, Trance, and 36 more genres

+ 39 Genres +
+ +
+
+

📊 Staff Picks

+

Curated selections and secret weapons

+ 5 Collections +
+
+
+ + +
+
+ + Browse Charts > Top Charts
+
+
+
🏆
+
+

Beatport Top 100

+

The hottest electronic tracks right now

+ 100 tracks +
+
+
+
🚀
+
+

Hype Top 10

+

Rising stars and breakthrough tracks

+ 10 tracks +
+
+
+
+
+

New Releases

+

Fresh tracks added to Beatport

+ 50 tracks +
+
+
+
-
-
-

🎵 Genre Explorer

-

House, Techno, Trance, and 36 more genres

- 39 Genres + +
+
+ + Browse Charts > Genre Explorer +
+
+
+
🏠
+

House

+ Top 100 +
+
+
🔧
+

Tech House

+ Top 100 +
+
+
+

Techno

+ Top 100 +
+
+
🌊
+

Deep House

+ Top 100 +
+
+
🌀
+

Trance

+ Top 100 +
+
+
🥁
+

Drum & Bass

+ Top 100 +
+
+
🎵
+

Dubstep

+ Top 100 +
+
+
📈
+

Progressive House

+ Top 100 +
+
+
🎼
+

Melodic House & Techno

+ Top 100 +
+
+
🌍
+

Afro House

+ Top 100 +
+
+
+

Minimal

+ Top 100 +
+
+
+

Nu Disco

+ Top 100 +
+
-
-
-

📊 Staff Picks

-

Curated selections and secret weapons

- 5 Collections + +
+
+ + Browse Charts > Staff Picks +
+
+
+
+
+

Best New Tracks

+

Editor's picks of the week

+ 20 tracks +
+
+
+
🔫
+
+

Secret Weapons

+

Underground gems for DJs

+ 15 tracks +
+
+
+
🌅
+
+

Closing Essentials

+

Perfect tracks to end the night

+ 12 tracks +
+
+
+
🔥
+
+

Peak Time Driving

+

High-energy dancefloor destroyers

+ 25 tracks +
+
+
+
💎
+
+

Underground Gems

+

Hidden treasures from emerging artists

+ 18 tracks +
+
diff --git a/webui/static/script.js b/webui/static/script.js index 7315d8c8..7dfec0e8 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -9223,6 +9223,34 @@ function initializeSyncPage() { }); }); + // Logic for Beatport breadcrumb back buttons + const beatportBackButtons = document.querySelectorAll('.breadcrumb-back'); + beatportBackButtons.forEach(button => { + button.addEventListener('click', () => { + showBeatportMainView(); + }); + }); + + // Logic for Beatport chart items + const beatportChartItems = document.querySelectorAll('.beatport-chart-item'); + beatportChartItems.forEach(item => { + item.addEventListener('click', () => { + const chartType = item.dataset.chartType; + const chartId = item.dataset.chartId; + handleBeatportChartClick(chartType, chartId); + }); + }); + + // Logic for Beatport genre items + const beatportGenreItems = document.querySelectorAll('.beatport-genre-item'); + beatportGenreItems.forEach(item => { + item.addEventListener('click', () => { + const genreSlug = item.dataset.genreSlug; + const genreId = item.dataset.genreId; + handleBeatportGenreClick(genreSlug, genreId); + }); + }); + // Logic for the Start Sync button const startSyncBtn = document.getElementById('start-sync-btn'); if (startSyncBtn) { @@ -9541,22 +9569,236 @@ async function loadBeatportCharts() { function handleBeatportCategoryClick(category) { console.log(`🎵 Beatport category clicked: ${category}`); - // Placeholder functionality for category navigation + // Show the appropriate sub-view based on category switch(category) { case 'top-charts': - showToast('🔥 Top Charts navigation coming soon!', 'info'); + showBeatportSubView('top-charts'); break; case 'genres': - showToast('🎵 Genre Explorer navigation coming soon!', 'info'); + showBeatportSubView('genres'); + loadBeatportGenres(); // Load genres dynamically break; case 'staff-picks': - showToast('📊 Staff Picks navigation coming soon!', 'info'); + showBeatportSubView('staff-picks'); break; default: - showToast(`Category "${category}" clicked`, 'info'); + showToast(`Unknown category: ${category}`, 'error'); + } +} + +async function loadBeatportGenres() { + console.log('🔍 Loading Beatport genres dynamically...'); + + const genreGrid = document.querySelector('#beatport-genres-view .beatport-genre-grid'); + if (!genreGrid) { + console.error('❌ Could not find genre grid element'); + return; + } + + // Show loading state + genreGrid.innerHTML = ` +
+
+

🔍 Discovering current Beatport genres...

+
+ `; + + try { + // First, fetch genres quickly without images + console.log('🚀 Fetching genres without images for fast loading...'); + const fastResponse = await fetch('/api/beatport/genres'); + if (!fastResponse.ok) { + throw new Error(`API returned ${fastResponse.status}: ${fastResponse.statusText}`); + } + + const fastData = await fastResponse.json(); + const genres = fastData.genres || []; + + if (genres.length === 0) { + genreGrid.innerHTML = ` +
+

⚠️ No genres available

+ +
+ `; + return; + } + + // Generate genre cards dynamically (without images first) + const genreCardsHTML = genres.map(genre => ` +
+
🎵
+

${genre.name}

+ Top 100 +
+ `).join(''); + + genreGrid.innerHTML = genreCardsHTML; + + // Add click handlers to dynamically created genre items + const genreItems = genreGrid.querySelectorAll('.beatport-genre-item'); + genreItems.forEach(item => { + item.addEventListener('click', () => { + const genreSlug = item.dataset.genreSlug; + const genreId = item.dataset.genreId; + const genreName = item.dataset.genreName; + handleBeatportGenreClick(genreSlug, genreId, genreName); + }); + }); + + console.log(`✅ Loaded ${genres.length} Beatport genres dynamically (fast mode)`); + showToast(`Loaded ${genres.length} current Beatport genres`, 'success'); + + // Now fetch images progressively in the background if there are many genres + if (genres.length > 10) { + console.log('🖼️ Loading genre images progressively...'); + loadGenreImagesProgressively(genres); + } + + } catch (error) { + console.error('❌ Error loading Beatport genres:', error); + genreGrid.innerHTML = ` +
+

❌ Failed to load genres: ${error.message}

+ +
+ `; + showToast(`Error loading Beatport genres: ${error.message}`, 'error'); } } +async function loadGenreImagesProgressively(genres) { + // Load genre images with 2 concurrent workers for faster loading + + const imageQueue = [...genres]; // Create a copy for processing + let imagesLoaded = 0; + const maxWorkers = 2; + + console.log(`🖼️ Starting progressive image loading with ${maxWorkers} workers for ${imageQueue.length} genres`); + + // Function to process a single image + async function processImage(genre) { + try { + // Fetch individual genre image from backend + const response = await fetch(`/api/beatport/genre-image/${genre.slug}/${genre.id}`); + + if (response.ok) { + const data = await response.json(); + + if (data.success && data.image_url) { + // Find the genre item in the DOM + const genreItem = document.querySelector( + `[data-genre-slug="${genre.slug}"][data-genre-id="${genre.id}"]` + ); + + if (genreItem) { + const iconElement = genreItem.querySelector('.genre-icon'); + if (iconElement) { + // Create new image element with smooth transition + const imageDiv = document.createElement('div'); + imageDiv.className = 'genre-image'; + imageDiv.style.backgroundImage = `url('${data.image_url}')`; + imageDiv.style.opacity = '0'; + imageDiv.style.transition = 'opacity 0.3s ease'; + + // Replace icon with image + iconElement.replaceWith(imageDiv); + + // Trigger fade-in animation + setTimeout(() => { + imageDiv.style.opacity = '1'; + }, 50); + + imagesLoaded++; + console.log(`🖼️ [${imagesLoaded}/${imageQueue.length}] Loaded image for ${genre.name}`); + } + } + } + } + } catch (error) { + console.warn(`⚠️ Failed to load image for ${genre.name}:`, error); + } + } + + // Worker function that processes images from the queue + async function imageWorker(workerId) { + while (imageQueue.length > 0) { + const genre = imageQueue.shift(); // Take next image from queue + if (genre) { + await processImage(genre); + + // Small delay between requests to be respectful (500ms per worker = ~2 images per second total) + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + console.log(`✅ Worker ${workerId} finished`); + } + + // Start the workers + const workers = []; + for (let i = 0; i < maxWorkers; i++) { + workers.push(imageWorker(i + 1)); + } + + // Wait for all workers to complete + await Promise.all(workers); + + console.log(`✅ Progressive image loading complete: ${imagesLoaded}/${genres.length} images loaded`); +} + +function showBeatportSubView(viewType) { + // Hide main category view + const mainView = document.getElementById('beatport-main-view'); + if (mainView) { + mainView.classList.remove('active'); + } + + // Hide all sub-views + document.querySelectorAll('.beatport-sub-view').forEach(view => { + view.classList.remove('active'); + }); + + // Show the requested sub-view + const targetView = document.getElementById(`beatport-${viewType}-view`); + if (targetView) { + targetView.classList.add('active'); + console.log(`🎵 Showing Beatport ${viewType} view`); + } else { + console.error(`🎵 Could not find view: beatport-${viewType}-view`); + } +} + +function showBeatportMainView() { + // Hide all sub-views + document.querySelectorAll('.beatport-sub-view').forEach(view => { + view.classList.remove('active'); + }); + + // Show main category view + const mainView = document.getElementById('beatport-main-view'); + if (mainView) { + mainView.classList.add('active'); + console.log('🎵 Showing Beatport main view'); + } +} + +function handleBeatportChartClick(chartType, chartId) { + console.log(`🎵 Beatport chart clicked: ${chartType} - ${chartId}`); + + // Placeholder for Phase 2 - will open discovery modal + showToast(`🎵 Chart "${chartId}" selected - Discovery modal coming in Phase 2!`, 'info'); +} + +function handleBeatportGenreClick(genreSlug, genreId) { + console.log(`🎵 Beatport genre clicked: ${genreSlug} (${genreId})`); + + // Placeholder for Phase 2 - will open discovery modal + showToast(`🎵 Genre "${genreSlug}" selected - Discovery modal coming in Phase 2!`, 'info'); +} + // =============================== // YOUTUBE PLAYLIST FUNCTIONALITY // =============================== diff --git a/webui/static/style.css b/webui/static/style.css index 3d2c5075..6dce90e3 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -4542,6 +4542,359 @@ body { z-index: 2; } +/* ================================= */ +/* BEATPORT NAVIGATION VIEWS */ +/* ================================= */ + +.beatport-main-view, +.beatport-sub-view { + display: none; + height: 100%; + overflow-y: auto; + padding: 0 5px; +} + +.beatport-main-view.active, +.beatport-sub-view.active { + display: block; +} + +/* ================================= */ +/* BEATPORT BREADCRUMB NAVIGATION */ +/* ================================= */ + +.beatport-breadcrumb { + display: flex; + align-items: center; + gap: 15px; + margin-bottom: 25px; + padding: 15px 20px; + background: rgba(25, 25, 25, 0.6); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.breadcrumb-back { + background: linear-gradient(135deg, #01FF95 0%, #00E085 100%); + border: none; + border-radius: 8px; + color: #000; + font-size: 13px; + font-weight: 600; + padding: 8px 16px; + cursor: pointer; + transition: all 0.3s ease; + white-space: nowrap; +} + +.breadcrumb-back:hover { + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(1, 255, 149, 0.3); +} + +.breadcrumb-path { + color: #888; + font-size: 14px; + font-weight: 500; +} + +/* ================================= */ +/* BEATPORT CHART LIST VIEW */ +/* ================================= */ + +.beatport-chart-list { + display: flex; + flex-direction: column; + gap: 15px; +} + +.beatport-chart-item { + background: linear-gradient(135deg, + rgba(25, 25, 25, 0.95) 0%, + rgba(15, 15, 15, 0.98) 100%); + backdrop-filter: blur(20px); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-top: 1px solid rgba(255, 255, 255, 0.12); + padding: 20px; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 20px; + position: relative; + overflow: hidden; +} + +.beatport-chart-item::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, + rgba(1, 255, 149, 0.1) 0%, + transparent 50%); + opacity: 0; + transition: opacity 0.3s ease; +} + +.beatport-chart-item:hover { + transform: translateY(-3px); + border-color: rgba(1, 255, 149, 0.3); + box-shadow: + 0 15px 30px rgba(0, 0, 0, 0.3), + 0 0 20px rgba(1, 255, 149, 0.2); +} + +.beatport-chart-item:hover::before { + opacity: 1; +} + +.chart-icon { + font-size: 32px; + width: 60px; + height: 60px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #01FF95 0%, #00E085 100%); + border-radius: 12px; + flex-shrink: 0; + position: relative; + z-index: 2; +} + +.chart-info { + flex: 1; + position: relative; + z-index: 2; +} + +.chart-info h3 { + font-size: 18px; + font-weight: 600; + color: #ffffff; + margin: 0 0 5px 0; +} + +.chart-info p { + font-size: 14px; + color: #888; + margin: 0 0 8px 0; + line-height: 1.4; +} + +.track-count { + display: inline-block; + background: rgba(1, 255, 149, 0.15); + color: #01FF95; + font-size: 12px; + font-weight: 600; + padding: 4px 10px; + border-radius: 15px; + border: 1px solid rgba(1, 255, 149, 0.3); +} + +/* ================================= */ +/* BEATPORT GENRE GRID VIEW */ +/* ================================= */ + +.beatport-genre-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 20px; + padding: 10px 0; +} + +.beatport-genre-item { + background: linear-gradient(135deg, + rgba(25, 25, 25, 0.95) 0%, + rgba(15, 15, 15, 0.98) 100%); + backdrop-filter: blur(20px); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-top: 1px solid rgba(255, 255, 255, 0.12); + padding: 20px; + cursor: pointer; + transition: all 0.3s ease; + text-align: center; + position: relative; + overflow: hidden; + min-height: 120px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; +} + +.beatport-genre-item::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, + rgba(1, 255, 149, 0.1) 0%, + transparent 50%); + opacity: 0; + transition: opacity 0.3s ease; +} + +.beatport-genre-item:hover { + transform: translateY(-5px); + border-color: rgba(1, 255, 149, 0.3); + box-shadow: + 0 15px 30px rgba(0, 0, 0, 0.3), + 0 0 20px rgba(1, 255, 149, 0.2); +} + +.beatport-genre-item:hover::before { + opacity: 1; +} + +.genre-icon { + font-size: 28px; + width: 50px; + height: 50px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #01FF95 0%, #00E085 100%); + border-radius: 10px; + position: relative; + z-index: 2; +} + +.beatport-genre-item h3 { + font-size: 16px; + font-weight: 600; + color: #ffffff; + margin: 0; + position: relative; + z-index: 2; + text-align: center; + line-height: 1.2; +} + +.genre-track-count { + display: inline-block; + background: rgba(1, 255, 149, 0.15); + color: #01FF95; + font-size: 11px; + font-weight: 600; + padding: 4px 8px; + border-radius: 12px; + border: 1px solid rgba(1, 255, 149, 0.3); + position: relative; + z-index: 2; +} + +/* Dynamic Genre Loading States */ +.genre-loading-placeholder, +.genre-error-placeholder { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + text-align: center; + color: #888; +} + +.genre-loading-placeholder .loading-spinner { + width: 40px; + height: 40px; + border: 3px solid rgba(1, 255, 149, 0.2); + border-top: 3px solid #01FF95; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 15px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.refresh-genres-btn { + background: linear-gradient(135deg, #01FF95 0%, #00E085 100%); + color: #000; + border: none; + padding: 10px 20px; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + font-size: 14px; + margin-top: 15px; + transition: all 0.3s ease; +} + +.refresh-genres-btn:hover { + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(1, 255, 149, 0.3); +} + +/* Genre Images */ +.beatport-genre-item .genre-image { + width: 50px; + height: 50px; + border-radius: 12px; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + border: 2px solid rgba(1, 255, 149, 0.3); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + transition: all 0.3s ease; + position: relative; + z-index: 2; +} + +.beatport-genre-item:hover .genre-image { + transform: scale(1.1); + border-color: rgba(1, 255, 149, 0.6); + box-shadow: + 0 6px 20px rgba(0, 0, 0, 0.4), + 0 0 20px rgba(1, 255, 149, 0.3); +} + +/* ================================= */ +/* RESPONSIVE DESIGN */ +/* ================================= */ + +@media (max-width: 768px) { + .beatport-genre-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 15px; + } + + .beatport-chart-item { + flex-direction: column; + text-align: center; + gap: 15px; + } + + .chart-icon { + width: 50px; + height: 50px; + font-size: 24px; + } + + .beatport-breadcrumb { + flex-direction: column; + align-items: stretch; + gap: 10px; + } + + .breadcrumb-back { + text-align: center; + } +} + .playlist-scroll-container { flex-grow: 1; overflow-y: auto;