beatport progress
This commit is contained in:
parent
c421a3b02c
commit
c7377e4ac7
4 changed files with 958 additions and 62 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue