beatport progress

This commit is contained in:
Broque Thomas 2025-10-01 14:50:03 -07:00
parent c421a3b02c
commit c7377e4ac7
4 changed files with 958 additions and 62 deletions

View file

@ -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 <li> elements first, then <a> 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 <li> elements found
print(f"⚠️ No <li> elements found, trying direct <a> 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:

View file

@ -1933,6 +1933,37 @@
</div>
</div>
<!-- Genre Browser Modal -->
<div class="genre-browser-modal-overlay" id="genre-browser-modal">
<div class="genre-browser-modal-container">
<div class="genre-browser-modal-header">
<h2 class="genre-browser-modal-title">🎵 Browse by Genre</h2>
<button class="genre-browser-modal-close" id="genre-browser-modal-close">
<span class="genre-browser-close-icon">×</span>
</button>
</div>
<div class="genre-browser-modal-content">
<div class="genre-browser-search-section">
<div class="genre-browser-search-container">
<input type="text" class="genre-browser-search-input" placeholder="Search genres..." id="genre-browser-search">
<span class="genre-browser-search-icon">🔍</span>
</div>
</div>
<div class="genre-browser-genres-section">
<div class="genre-browser-genres-grid" id="genre-browser-genres-grid">
<!-- Loading placeholder -->
<div class="genre-browser-loading-container">
<div class="genre-browser-loading-spinner"></div>
<p class="genre-browser-loading-text">🔍 Discovering current Beatport genres...</p>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='script.js') }}"></script>
</body>
</html>

View file

@ -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 = `
<div class="genre-browser-loading-container">
<div class="genre-browser-loading-spinner"></div>
<p class="genre-browser-loading-text">🔍 Discovering current Beatport genres...</p>
</div>
`;
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 = `
<div class="genre-browser-loading-container">
<p style="color: rgba(255, 255, 255, 0.7);"> No genres available</p>
<button onclick="loadGenreBrowserGenres()" style="margin-top: 10px; padding: 10px 20px; border: 1px solid rgba(255, 255, 255, 0.3); background: rgba(20, 20, 20, 0.8); color: white; border-radius: 8px; cursor: pointer;">🔄 Retry</button>
</div>
`;
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 => `
<div class="genre-browser-card genre-browser-card-fallback"
data-genre-slug="${genre.slug}"
data-genre-id="${genre.id}"
data-genre-name="${genre.name}"
data-url="${genre.url}">
<div class="genre-browser-card-image">🎵</div>
<div class="genre-browser-card-content">
<h3 class="genre-browser-card-title">${genre.name}</h3>
<p class="genre-browser-card-subtitle">Top 10 & Top 100 Charts</p>
</div>
</div>
`).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 = `
<div class="genre-browser-loading-container">
<p style="color: rgba(255, 255, 255, 0.7);"> Failed to load genres: ${error.message}</p>
<button onclick="loadGenreBrowserGenres()" style="margin-top: 10px; padding: 10px 20px; border: 1px solid rgba(255, 255, 255, 0.3); background: rgba(20, 20, 20, 0.8); color: white; border-radius: 8px; cursor: pointer;">🔄 Retry</button>
</div>
`;
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 => `
<div class="genre-browser-card genre-browser-card-fallback"
data-genre-slug="${genre.slug}"
data-genre-id="${genre.id}"
data-genre-name="${genre.name}"
data-url="${genre.url}">
<div class="genre-browser-card-image">🎵</div>
<div class="genre-browser-card-content">
<h3 class="genre-browser-card-title">${genre.name}</h3>
<p class="genre-browser-card-subtitle">Top 10 & Top 100 Charts</p>
</div>
</div>
`).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 = `<img src="${genre.imageUrl}" alt="${genre.name}" style="width: 100%; height: 100%; object-fit: cover;">`;
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 = `<img src="${data.image_url}" alt="${genre.name}" style="width: 100%; height: 100%; object-fit: cover;">`;
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();
});

View file

@ -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);
}
}