From c7377e4ac77055c37999beaed20ae10564da2bf9 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Wed, 1 Oct 2025 14:50:03 -0700 Subject: [PATCH] beatport progress --- beatport_unified_scraper.py | 268 +++++++++++++++++------ webui/index.html | 31 +++ webui/static/script.js | 410 ++++++++++++++++++++++++++++++++++++ webui/static/style.css | 311 +++++++++++++++++++++++++++ 4 files changed, 958 insertions(+), 62 deletions(-) diff --git a/beatport_unified_scraper.py b/beatport_unified_scraper.py index 01204489..4061581e 100644 --- a/beatport_unified_scraper.py +++ b/beatport_unified_scraper.py @@ -26,28 +26,8 @@ class BeatportUnifiedScraper: # Dynamic genres - will be populated by scraping homepage self.all_genres = [] - def clean_text(self, text): - """Clean and normalize text from HTML elements""" - if not text: - return text - - # Fix common spacing issues - text = re.sub(r'([a-z$!@#%&*])([A-Z])', r'\1 \2', text) # Add space between lowercase/symbols and uppercase - text = re.sub(r'([a-zA-Z])(\d)', r'\1 \2', text) # Add space between letter and number - text = re.sub(r'(\d)([a-zA-Z])', r'\1 \2', text) # Add space between number and letter - text = re.sub(r'([a-zA-Z]),([a-zA-Z])', r'\1, \2', text) # Add space after comma - text = re.sub(r'([a-zA-Z])Mix\b', r'\1 Mix', text) # Fix "hitMix" -> "hit Mix" - text = re.sub(r'([a-zA-Z])Remix\b', r'\1 Remix', text) # Fix "hitRemix" -> "hit Remix" - text = re.sub(r'([a-zA-Z])Extended\b', r'\1 Extended', text) # Fix "hitExtended" -> "hit Extended" - text = re.sub(r'([a-zA-Z])Version\b', r'\1 Version', text) # Fix "hitVersion" -> "hit Version" - text = re.sub(r'\s+', ' ', text) # Collapse multiple spaces - text = text.strip() - - return text - - # Comprehensive fallback genres based on current Beatport dropdown (39 genres) + # Current Beatport genres with correct URLs and IDs (updated from live site) self.fallback_genres = [ - # Electronic genres {'name': '140 / Deep Dubstep / Grime', 'slug': '140-deep-dubstep-grime', 'id': '95', 'url': f'{self.base_url}/genre/140-deep-dubstep-grime/95'}, {'name': 'Afro House', 'slug': 'afro-house', 'id': '89', 'url': f'{self.base_url}/genre/afro-house/89'}, {'name': 'Amapiano', 'slug': 'amapiano', 'id': '98', 'url': f'{self.base_url}/genre/amapiano/98'}, @@ -84,7 +64,7 @@ class BeatportUnifiedScraper: {'name': 'Trance (Raw / Deep / Hypnotic)', 'slug': 'trance-raw-deep-hypnotic', 'id': '99', 'url': f'{self.base_url}/genre/trance-raw-deep-hypnotic/99'}, {'name': 'Trap / Future Bass', 'slug': 'trap-future-bass', 'id': '38', 'url': f'{self.base_url}/genre/trap-future-bass/38'}, {'name': 'UK Garage / Bassline', 'slug': 'uk-garage-bassline', 'id': '86', 'url': f'{self.base_url}/genre/uk-garage-bassline/86'}, - # Open Format genres + # Additional genres from current Beatport {'name': 'African', 'slug': 'african', 'id': '102', 'url': f'{self.base_url}/genre/african/102'}, {'name': 'Caribbean', 'slug': 'caribbean', 'id': '103', 'url': f'{self.base_url}/genre/caribbean/103'}, {'name': 'Hip-Hop', 'slug': 'hip-hop', 'id': '105', 'url': f'{self.base_url}/genre/hip-hop/105'}, @@ -93,6 +73,53 @@ class BeatportUnifiedScraper: {'name': 'R&B', 'slug': 'rb', 'id': '108', 'url': f'{self.base_url}/genre/rb/108'} ] + def clean_text(self, text): + """Clean and normalize text from HTML elements""" + if not text: + return text + + # Fix common spacing issues + text = re.sub(r'([a-z$!@#%&*])([A-Z])', r'\1 \2', text) # Add space between lowercase/symbols and uppercase + text = re.sub(r'([a-zA-Z])(\d)', r'\1 \2', text) # Add space between letter and number + text = re.sub(r'(\d)([a-zA-Z])', r'\1 \2', text) # Add space between number and letter + text = re.sub(r'([a-zA-Z]),([a-zA-Z])', r'\1, \2', text) # Add space after comma + text = re.sub(r'([a-zA-Z])Mix\b', r'\1 Mix', text) # Fix "hitMix" -> "hit Mix" + text = re.sub(r'([a-zA-Z])Remix\b', r'\1 Remix', text) # Fix "hitRemix" -> "hit Remix" + text = re.sub(r'([a-zA-Z])Extended\b', r'\1 Extended', text) # Fix "hitExtended" -> "hit Extended" + text = re.sub(r'([a-zA-Z])Version\b', r'\1 Version', text) # Fix "hitVersion" -> "hit Version" + text = re.sub(r'\s+', ' ', text) # Collapse multiple spaces + text = text.strip() + + return text + + def _is_valid_genre_name(self, name: str) -> bool: + """Check if a name is a valid genre name and not a section title""" + # Filter out common section titles + section_titles = { + 'open format', 'electronic', 'genres', 'browse', 'charts', + 'new releases', 'trending', 'featured', 'popular', 'top', + 'main', 'explore', 'discover', 'all genres' + } + + name_lower = name.lower().strip() + + # Reject if it's a section title + if name_lower in section_titles: + return False + + # Reject if it's too short or too generic + if len(name_lower) < 3: + return False + + # Reject if it contains only common words + common_words = {'the', 'and', 'or', 'of', 'in', 'on', 'at', 'to', 'for'} + words = name_lower.split() + if len(words) == 1 and words[0] in common_words: + return False + + # Accept everything else + return True + def get_page(self, url: str) -> Optional[BeautifulSoup]: """Fetch and parse a page with error handling""" try: @@ -154,50 +181,140 @@ class BeatportUnifiedScraper: genres = [] - # Method 1: Look for genres dropdown menu (multiple selectors) - potential_dropdowns = [ - soup.find('div', {'id': 'genres-dropdown-menu'}), - soup.find('div', class_=re.compile(r'genres.*dropdown', re.I)), - soup.find('nav', class_=re.compile(r'genres', re.I)), - soup.find('div', class_=re.compile(r'dropdown.*genres', re.I)), - soup.find('ul', class_=re.compile(r'genres', re.I)), - soup.find('div', {'data-testid': 'genres-dropdown'}), - soup.find('div', {'aria-label': re.compile(r'genres', re.I)}) - ] + # Method 1: Look for the specific genres dropdown menu structure + genres_dropdown = soup.find('div', {'id': 'genres-dropdown-menu'}) - for dropdown in potential_dropdowns: - if dropdown: - print(f"βœ… Found potential genres dropdown: {dropdown.name} with class {dropdown.get('class')}") - # Extract genre links from dropdown - look for the specific pattern - genre_links = dropdown.find_all('a', href=re.compile(r'/genre/[^/]+/\d+')) + if genres_dropdown: + print("βœ… Found genres-dropdown-menu") - if genre_links: - print(f"πŸ”— Found {len(genre_links)} genre links in dropdown") - for link in genre_links: - href = link.get('href', '') - # Get text content, handling nested elements - name_text = link.get_text(strip=True) + # Look for the two main div containers as described + genre_containers = genres_dropdown.find_all('div', recursive=False) + print(f"πŸ” Found {len(genre_containers)} top-level containers in dropdown") - # Clean up the name - remove "New" tags and extra whitespace - name = re.sub(r'\s*New\s*', '', name_text).strip() + for container_idx, container in enumerate(genre_containers): + print(f"πŸ“¦ Processing container {container_idx + 1}") - if href and name and len(name) > 1: # Filter out empty or single char names - # Parse URL: /genre/house/5 -> slug='house', id='5' - url_parts = href.strip('/').split('/') - if len(url_parts) >= 3 and url_parts[0] == 'genre': - slug = url_parts[1] - genre_id = url_parts[2] + # Look specifically for .dropdown_menu classes + dropdown_menus = container.find_all(class_='dropdown_menu') - genres.append({ - 'name': name, - 'slug': slug, - 'id': genre_id, - 'url': urljoin(self.base_url, href) - }) + if not dropdown_menus: + # Fallback: Look for any element with class containing 'dropdown' and 'menu' + dropdown_menus = container.find_all(class_=re.compile(r'dropdown.*menu', re.I)) - if genres: - print(f"🎯 Successfully extracted {len(genres)} genres from dropdown") - break # Stop after first successful dropdown + if not dropdown_menus: + print(f"⚠️ No .dropdown_menu found in container {container_idx + 1}") + continue + + for menu_idx, menu in enumerate(dropdown_menus): + print(f"πŸ“‹ Processing dropdown_menu {menu_idx + 1} in container {container_idx + 1}") + + # Look for
  • elements first, then elements within them + list_items = menu.find_all('li') + + if list_items: + print(f"πŸ“ Found {len(list_items)} list items in menu") + + for li in list_items: + # Find anchor tag within the list item + link = li.find('a', href=re.compile(r'/genre/[^/]+/\d+')) + + if link: + href = link.get('href', '') + name_text = link.get_text(strip=True) + + # Keep the name as-is (don't remove "New" prefix) + name = name_text.strip() + + # Filter out section titles and non-genre items + if href and name and len(name) > 1 and self._is_valid_genre_name(name): + # Parse URL: /genre/house/5 -> slug='house', id='5' + url_parts = href.strip('/').split('/') + if len(url_parts) >= 3 and url_parts[0] == 'genre': + slug = url_parts[1] + genre_id = url_parts[2] + + genres.append({ + 'name': name, + 'slug': slug, + 'id': genre_id, + 'url': urljoin(self.base_url, href) + }) + print(f" βœ… Added: {name} ({slug}/{genre_id})") + else: + print(f" 🚫 Filtered out: '{name}' (appears to be a section title)") + else: + # Fallback: try the old method if no
  • elements found + print(f"⚠️ No
  • elements found, trying direct search...") + genre_links = menu.find_all('a', href=re.compile(r'/genre/[^/]+/\d+')) + + if genre_links: + print(f"πŸ”— Found {len(genre_links)} genre links in menu (fallback method)") + for link in genre_links: + href = link.get('href', '') + name_text = link.get_text(strip=True) + name = name_text.strip() + + if href and name and len(name) > 1 and self._is_valid_genre_name(name): + url_parts = href.strip('/').split('/') + if len(url_parts) >= 3 and url_parts[0] == 'genre': + slug = url_parts[1] + genre_id = url_parts[2] + + genres.append({ + 'name': name, + 'slug': slug, + 'id': genre_id, + 'url': urljoin(self.base_url, href) + }) + print(f" βœ… Added: {name} ({slug}/{genre_id})") + else: + print(f"⚠️ No genre links found in dropdown_menu {menu_idx + 1}") + + if genres: + print(f"🎯 Successfully extracted {len(genres)} genres from dropdown menu") + else: + print("⚠️ No genre links found in dropdown menu structure") + else: + print("❌ Could not find genres-dropdown-menu, trying fallback methods...") + + # Fallback: Look for other potential dropdown structures + potential_dropdowns = [ + soup.find('div', class_=re.compile(r'genres.*dropdown', re.I)), + soup.find('nav', class_=re.compile(r'genres', re.I)), + soup.find('div', class_=re.compile(r'dropdown.*genres', re.I)), + soup.find('ul', class_=re.compile(r'genres', re.I)), + soup.find('div', {'data-testid': 'genres-dropdown'}), + soup.find('div', {'aria-label': re.compile(r'genres', re.I)}) + ] + + for dropdown in potential_dropdowns: + if dropdown: + print(f"βœ… Found fallback dropdown: {dropdown.name} with class {dropdown.get('class')}") + genre_links = dropdown.find_all('a', href=re.compile(r'/genre/[^/]+/\d+')) + + if genre_links: + print(f"πŸ”— Found {len(genre_links)} genre links in fallback dropdown") + for link in genre_links: + href = link.get('href', '') + name_text = link.get_text(strip=True) + name = re.sub(r'\s*New\s*', '', name_text).strip() + + if href and name and len(name) > 1: + url_parts = href.strip('/').split('/') + if len(url_parts) >= 3 and url_parts[0] == 'genre': + slug = url_parts[1] + genre_id = url_parts[2] + + genres.append({ + 'name': name, + 'slug': slug, + 'id': genre_id, + 'url': urljoin(self.base_url, href) + }) + + if genres: + print(f"🎯 Successfully extracted {len(genres)} genres from fallback dropdown") + break # Method 2: Look for any genre links on the page if not genres: @@ -405,7 +522,30 @@ class BeatportUnifiedScraper: if not soup: return None - # Look for hero release slideshow images + # Priority 1: Look for images in .artwork containers (new method) + artwork_imgs = soup.select('.artwork > img') + if artwork_imgs: + # First, try to find high-quality geo-media images in artwork containers + for img in artwork_imgs: + src = img.get('src', '') + if 'geo-media' in src and ('1050x508' in src or '500x500' in src): + print(f" βœ… Found high-quality artwork image: {src}") + return src + + # Second, try any geo-media images in artwork containers + for img in artwork_imgs: + src = img.get('src', '') + if 'geo-media' in src: + print(f" βœ… Found geo-media artwork image: {src}") + return src + + # Third, use any artwork image as fallback + first_artwork_src = artwork_imgs[0].get('src', '') + if first_artwork_src: + print(f" βœ… Found artwork image (fallback): {first_artwork_src}") + return first_artwork_src + + # Priority 2: Original method - Look for hero release slideshow images hero_images = soup.find_all('img', src=re.compile(r'geo-media\.beatport\.com/image_size/')) if hero_images: @@ -413,11 +553,15 @@ class BeatportUnifiedScraper: for img in hero_images: src = img.get('src', '') if '1050x508' in src or '500x500' in src: + print(f" βœ… Found high-quality hero image: {src}") return src # Fallback to any geo-media image - return hero_images[0].get('src', '') + fallback_src = hero_images[0].get('src', '') + print(f" βœ… Found hero image (fallback): {fallback_src}") + return fallback_src + print(f" ⚠️ No suitable images found on page") return None except Exception as e: diff --git a/webui/index.html b/webui/index.html index 947a26d6..04c63222 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1933,6 +1933,37 @@ + +
    +
    +
    +

    🎡 Browse by Genre

    + +
    + +
    +
    +
    + + πŸ” +
    +
    + +
    +
    + +
    +
    +

    πŸ” Discovering current Beatport genres...

    +
    +
    +
    +
    +
    +
    + \ No newline at end of file diff --git a/webui/static/script.js b/webui/static/script.js index 6fc891bf..d427e8b9 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -20815,3 +20815,413 @@ async function handleHypeTop100Click() { showToast(`Error loading Hype Top 100: ${error.message}`, 'error'); } } + +// ================================= // +// GENRE BROWSER MODAL FUNCTIONS // +// ================================= // + +// Cache for genre browser data to avoid re-loading +let genreBrowserCache = { + genres: null, + imagesLoaded: false, + lastLoaded: null, + imageLoadingActive: false, + imageWorkers: null +}; + +function initializeGenreBrowserModal() { + console.log('🎡 Initializing Genre Browser Modal...'); + + // Browse by Genre button click handler + const browseByGenreBtn = document.getElementById('browse-by-genre-btn'); + if (browseByGenreBtn) { + browseByGenreBtn.addEventListener('click', () => { + console.log('🎡 Browse by Genre button clicked'); + openGenreBrowserModal(); + }); + } + + // Modal close button handler + const modalCloseBtn = document.getElementById('genre-browser-modal-close'); + if (modalCloseBtn) { + modalCloseBtn.addEventListener('click', closeGenreBrowserModal); + } + + // Click outside modal to close + const modalOverlay = document.getElementById('genre-browser-modal'); + if (modalOverlay) { + modalOverlay.addEventListener('click', (e) => { + if (e.target === modalOverlay) { + closeGenreBrowserModal(); + } + }); + } + + // Search functionality + const searchInput = document.getElementById('genre-browser-search'); + if (searchInput) { + searchInput.addEventListener('input', (e) => { + filterGenreBrowserCards(e.target.value); + }); + } + + // ESC key to close modal + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && isGenreBrowserModalOpen()) { + closeGenreBrowserModal(); + } + }); + + console.log('βœ… Genre Browser Modal initialized'); +} + +function openGenreBrowserModal() { + console.log('🎡 Opening Genre Browser Modal...'); + + const modal = document.getElementById('genre-browser-modal'); + if (modal) { + modal.classList.add('active'); + document.body.style.overflow = 'hidden'; // Prevent background scrolling + + // Check cache before loading genres + if (genreBrowserCache.genres && genreBrowserCache.genres.length > 0) { + console.log('πŸ’Ύ Using cached genres data'); + displayCachedGenres(); + } else { + console.log('πŸ”„ No cached data, loading genres...'); + loadGenreBrowserGenres(); + } + + console.log('βœ… Genre Browser Modal opened'); + } +} + +function closeGenreBrowserModal() { + console.log('🎡 Closing Genre Browser Modal...'); + + const modal = document.getElementById('genre-browser-modal'); + if (modal) { + modal.classList.remove('active'); + document.body.style.overflow = ''; // Restore scrolling + + // Clear search input but keep the genre data cached + const searchInput = document.getElementById('genre-browser-search'); + if (searchInput) { + searchInput.value = ''; + // Also reset the display filter to show all genres + filterGenreBrowserCards(''); + } + + // Pause image loading workers if they're running + if (genreBrowserCache.imageLoadingActive) { + console.log('⏸️ Pausing image loading workers...'); + genreBrowserCache.imageLoadingActive = false; + } + + console.log('βœ… Genre Browser Modal closed (data preserved in cache)'); + } +} + +function isGenreBrowserModalOpen() { + const modal = document.getElementById('genre-browser-modal'); + return modal && modal.classList.contains('active'); +} + +async function loadGenreBrowserGenres() { + console.log('πŸ” Loading genres for Genre Browser Modal...'); + + const genresGrid = document.getElementById('genre-browser-genres-grid'); + if (!genresGrid) { + console.error('❌ Genre browser grid not found'); + return; + } + + // Show loading state + genresGrid.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) { + genresGrid.innerHTML = ` +
    +

    ⚠️ No genres available

    + +
    + `; + return; + } + + // Filter out unwanted genres (section titles, etc.) + const filteredGenres = genres.filter(genre => { + const name = genre.name.toLowerCase().trim(); + const unwantedGenres = [ + 'open format', + 'electronic', + 'genres', + 'browse', + 'charts', + 'new releases', + 'trending', + 'featured', + 'popular' + ]; + + const isUnwanted = unwantedGenres.includes(name); + if (isUnwanted) { + console.log(`🚫 Filtered out unwanted genre: "${genre.name}"`); + } + return !isUnwanted; + }); + + console.log(`πŸ“‹ Filtered genres: ${genres.length} β†’ ${filteredGenres.length} (removed ${genres.length - filteredGenres.length} unwanted)`); + + // Generate genre cards dynamically (without images first) + const genreCardsHTML = filteredGenres.map(genre => ` +
    +
    🎡
    +
    +

    ${genre.name}

    +

    Top 10 & Top 100 Charts

    +
    +
    + `).join(''); + + genresGrid.innerHTML = genreCardsHTML; + + // Cache the filtered genres data + genreBrowserCache.genres = filteredGenres; + genreBrowserCache.lastLoaded = new Date(); + genreBrowserCache.imagesLoaded = false; + + console.log(`βœ… Loaded ${filteredGenres.length} Beatport genres for modal (fast mode)`); + console.log(`πŸ’Ύ Cached ${filteredGenres.length} genres for future use`); + showToast(`Loaded ${filteredGenres.length} genres for browsing`, 'success'); + + // Now fetch images progressively in the background + if (filteredGenres.length > 5) { + console.log('πŸ–ΌοΈ Loading genre images progressively for modal...'); + loadGenreBrowserImagesProgressively(filteredGenres); + } + + } catch (error) { + console.error('❌ Error loading genres for modal:', error); + genresGrid.innerHTML = ` +
    +

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

    + +
    + `; + showToast(`Error loading genres: ${error.message}`, 'error'); + } +} + +function displayCachedGenres() { + console.log('πŸ’Ύ Displaying cached genres...'); + + const genresGrid = document.getElementById('genre-browser-genres-grid'); + if (!genresGrid) { + console.error('❌ Genre browser grid not found'); + return; + } + + const genres = genreBrowserCache.genres; + if (!genres || genres.length === 0) { + console.error('❌ No cached genres available'); + return; + } + + // Generate genre cards from cached data + const genreCardsHTML = genres.map(genre => ` +
    +
    🎡
    +
    +

    ${genre.name}

    +

    Top 10 & Top 100 Charts

    +
    +
    + `).join(''); + + genresGrid.innerHTML = genreCardsHTML; + + console.log(`βœ… Displayed ${genres.length} cached genres instantly`); + + // Handle image loading based on current state + if (genreBrowserCache.imagesLoaded) { + console.log('πŸ–ΌοΈ Images already loaded, restoring them...'); + restoreCachedImages(genres); + } else if (!genreBrowserCache.imageLoadingActive && genres.length > 5) { + // Resume or start image loading + const cachedCount = genres.filter(g => g.imageUrl).length; + if (cachedCount > 0) { + console.log(`πŸ”„ Resuming image loading (${cachedCount}/${genres.length} already cached)...`); + restoreCachedImages(genres); // Show already cached images + } else { + console.log('πŸ–ΌοΈ Starting fresh image loading for cached genres...'); + } + loadGenreBrowserImagesProgressively(genres); + } else { + console.log('πŸ“· Image loading in progress, showing cached images...'); + restoreCachedImages(genres); + } +} + +function restoreCachedImages(genres) { + // Restore images that were already loaded in previous sessions + genres.forEach(genre => { + if (genre.imageUrl) { + const genreCard = document.querySelector( + `.genre-browser-card[data-genre-slug="${genre.slug}"][data-genre-id="${genre.id}"]` + ); + + if (genreCard) { + const imageElement = genreCard.querySelector('.genre-browser-card-image'); + if (imageElement) { + imageElement.innerHTML = `${genre.name}`; + genreCard.classList.remove('genre-browser-card-fallback'); + } + } + } + }); +} + +async function loadGenreBrowserImagesProgressively(genres) { + // Load genre images with 2 concurrent workers for faster loading + // Only process genres that don't already have cached images + const imageQueue = genres.filter(genre => !genre.imageUrl); + let imagesLoaded = 0; + const maxWorkers = 2; + + // Mark loading as active + genreBrowserCache.imageLoadingActive = true; + + console.log(`πŸ–ΌοΈ Starting progressive image loading for modal with ${maxWorkers} workers for ${imageQueue.length} remaining genres (${genres.length - imageQueue.length} already cached)`); + + // If all images are already cached, mark as complete + if (imageQueue.length === 0) { + console.log('βœ… All images already cached, marking as complete'); + genreBrowserCache.imagesLoaded = true; + genreBrowserCache.imageLoadingActive = false; + return; + } + + // 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) { + // Cache the image URL in the genre object + genre.imageUrl = data.image_url; + + // Find the genre card in the modal + const genreCard = document.querySelector( + `.genre-browser-card[data-genre-slug="${genre.slug}"][data-genre-id="${genre.id}"]` + ); + + if (genreCard) { + const imageElement = genreCard.querySelector('.genre-browser-card-image'); + if (imageElement) { + // Replace the fallback emoji with the actual image + imageElement.innerHTML = `${genre.name}`; + genreCard.classList.remove('genre-browser-card-fallback'); + + console.log(`βœ… Loaded and cached image for ${genre.name} in modal`); + } + } + } + } + + imagesLoaded++; + console.log(`πŸ“· Progress: ${imagesLoaded}/${genres.length} images loaded for modal`); + + } catch (error) { + console.log(`⚠️ Could not load image for ${genre.name} in modal: ${error.message}`); + imagesLoaded++; + } + } + + // Worker function to process images from the queue + async function worker() { + while (imageQueue.length > 0 && genreBrowserCache.imageLoadingActive) { + const genre = imageQueue.shift(); + if (genre) { + await processImage(genre); + // Small delay to prevent overwhelming the server + await new Promise(resolve => setTimeout(resolve, 100)); + } + + // Check if we should pause + if (!genreBrowserCache.imageLoadingActive) { + console.log('⏸️ Worker paused - modal closed'); + break; + } + } + } + + // Start the workers + const workers = []; + for (let i = 0; i < maxWorkers; i++) { + workers.push(worker()); + } + + // Wait for all workers to complete + await Promise.all(workers); + + // Check if loading was completed or paused + if (genreBrowserCache.imageLoadingActive) { + // Completed successfully + genreBrowserCache.imagesLoaded = true; + genreBrowserCache.imageLoadingActive = false; + console.log(`πŸŽ‰ Completed loading all genre images for modal (${imagesLoaded}/${genres.length})`); + console.log(`πŸ’Ύ Marked images as loaded in cache`); + } else { + // Was paused + console.log(`⏸️ Image loading paused (${imagesLoaded}/${genres.length} completed)`); + console.log(`πŸ’Ύ Partial progress saved in cache`); + } +} + +function filterGenreBrowserCards(searchTerm) { + const genreCards = document.querySelectorAll('.genre-browser-card'); + const searchLower = searchTerm.toLowerCase(); + + genreCards.forEach(card => { + const genreName = card.dataset.genreName?.toLowerCase() || ''; + const shouldShow = genreName.includes(searchLower); + + card.style.display = shouldShow ? 'block' : 'none'; + }); + + console.log(`πŸ” Filtered genre cards with search term: "${searchTerm}"`); +} + +// Initialize the Genre Browser Modal when the page loads +document.addEventListener('DOMContentLoaded', () => { + initializeGenreBrowserModal(); +}); diff --git a/webui/static/style.css b/webui/static/style.css index 3ca38fec..d4911a54 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -14616,3 +14616,314 @@ body { height: 18px; } } + +/* ================================= */ +/* GENRE BROWSER MODAL STYLES */ +/* ================================= */ + +.genre-browser-modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(10px); + display: none; + justify-content: center; + align-items: center; + z-index: 9999; + animation: genreBrowserFadeIn 0.3s ease-out; +} + +.genre-browser-modal-overlay.active { + display: flex; +} + +.genre-browser-modal-container { + background: linear-gradient(135deg, + rgba(15, 15, 15, 0.95) 0%, + rgba(20, 20, 20, 0.95) 50%, + rgba(25, 25, 25, 0.95) 100%); + border-radius: 20px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.8); + width: 95vw; + height: 90vh; + max-width: 1600px; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.1); + animation: genreBrowserSlideIn 0.4s ease-out; +} + +.genre-browser-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 25px 30px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + background: linear-gradient(90deg, + rgba(30, 30, 30, 0.8) 0%, + rgba(40, 40, 40, 0.8) 100%); +} + +.genre-browser-modal-title { + font-size: 28px; + font-weight: 700; + color: #ffffff; + margin: 0; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); +} + +.genre-browser-modal-close { + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: 50%; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.3s ease; + backdrop-filter: blur(10px); +} + +.genre-browser-modal-close:hover { + background: rgba(255, 255, 255, 0.2); + transform: scale(1.1); +} + +.genre-browser-close-icon { + font-size: 24px; + color: #ffffff; + font-weight: 300; +} + +.genre-browser-modal-content { + flex: 1; + padding: 30px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 25px; +} + +.genre-browser-search-section { + width: 100%; +} + +.genre-browser-search-container { + position: relative; + max-width: 500px; + margin: 0 auto; +} + +.genre-browser-search-input { + width: 100%; + padding: 15px 20px 15px 50px; + border: 2px solid rgba(255, 255, 255, 0.1); + border-radius: 25px; + background: rgba(20, 20, 20, 0.8); + color: #ffffff; + font-size: 16px; + outline: none; + transition: all 0.3s ease; + backdrop-filter: blur(10px); +} + +.genre-browser-search-input:focus { + border-color: #1db954; + box-shadow: 0 0 20px rgba(29, 185, 84, 0.3); +} + +.genre-browser-search-input::placeholder { + color: rgba(255, 255, 255, 0.5); +} + +.genre-browser-search-icon { + position: absolute; + left: 18px; + top: 50%; + transform: translateY(-50%); + font-size: 18px; + color: rgba(255, 255, 255, 0.6); +} + +.genre-browser-genres-section { + flex: 1; +} + +.genre-browser-genres-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; + padding: 10px 0; +} + +.genre-browser-loading-container { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + color: rgba(255, 255, 255, 0.7); +} + +.genre-browser-loading-spinner { + width: 40px; + height: 40px; + border: 3px solid rgba(255, 255, 255, 0.1); + border-top: 3px solid #1db954; + border-radius: 50%; + animation: genreBrowserSpin 1s linear infinite; + margin-bottom: 20px; +} + +.genre-browser-loading-text { + font-size: 16px; + text-align: center; + margin: 0; +} + +.genre-browser-card { + background: linear-gradient(135deg, + rgba(25, 25, 25, 0.9) 0%, + rgba(35, 35, 35, 0.9) 100%); + border-radius: 16px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.1); + transition: all 0.3s ease; + cursor: pointer; + position: relative; + height: 200px; + backdrop-filter: blur(10px); +} + +.genre-browser-card:hover { + transform: translateY(-5px); + box-shadow: 0 15px 40px rgba(0, 0, 0, 0.6); + border-color: rgba(29, 185, 84, 0.5); +} + +.genre-browser-card-image { + width: 100%; + height: 120px; + object-fit: cover; + transition: all 0.3s ease; +} + +.genre-browser-card:hover .genre-browser-card-image { + transform: scale(1.05); +} + +.genre-browser-card-content { + padding: 20px; + position: relative; + z-index: 2; +} + +.genre-browser-card-title { + font-size: 18px; + font-weight: 600; + color: #ffffff; + margin: 0 0 8px 0; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.7); +} + +.genre-browser-card-subtitle { + font-size: 14px; + color: rgba(255, 255, 255, 0.7); + margin: 0; +} + +.genre-browser-card-fallback { + background: linear-gradient(135deg, + rgba(30, 30, 30, 0.9) 0%, + rgba(45, 45, 45, 0.9) 100%); +} + +.genre-browser-card-fallback .genre-browser-card-image { + background: linear-gradient(135deg, + rgba(40, 40, 40, 1) 0%, + rgba(60, 60, 60, 1) 100%); + display: flex; + align-items: center; + justify-content: center; + font-size: 48px; + color: rgba(255, 255, 255, 0.6); +} + +/* Responsive Design */ +@media (max-width: 768px) { + .genre-browser-modal-container { + width: 95vw; + height: 90vh; + border-radius: 15px; + } + + .genre-browser-modal-header { + padding: 20px; + } + + .genre-browser-modal-title { + font-size: 22px; + } + + .genre-browser-modal-content { + padding: 20px; + gap: 20px; + } + + .genre-browser-genres-grid { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 15px; + } + + .genre-browser-card { + height: 180px; + } +} + +@media (max-width: 480px) { + .genre-browser-genres-grid { + grid-template-columns: 1fr; + gap: 12px; + } + + .genre-browser-card { + height: 160px; + } +} + +/* Animations */ +@keyframes genreBrowserFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes genreBrowserSlideIn { + from { + opacity: 0; + transform: scale(0.9) translateY(20px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes genreBrowserSpin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +}