Add Beatport genre Top 10 lists API and UI integration

Introduces backend scraping and API endpoint for Beatport genre Top 10 and Hype Top 10 tracks, with 1-hour caching. Updates frontend to load and display these lists in the genre browser modal, matching main page structure and click handling for chart discovery.
pull/49/head
Broque Thomas 8 months ago
parent fcd48793e0
commit 06f3d219b4

@ -3539,6 +3539,134 @@ class BeatportUnifiedScraper:
print(f" 📊 Successfully extracted {len(releases_data)} hero releases")
return releases_data
def scrape_genre_top10_tracks(self, genre_slug, genre_id):
"""Scrape Top 10 tracks lists from genre page (Beatport Top 10 + Hype Top 10 if available)"""
print(f"🎵 Scraping Top 10 tracks for {genre_slug} (ID: {genre_id})")
genre_url = f"https://www.beatport.com/genre/{genre_slug}/{genre_id}"
response = self.session.get(genre_url)
response.raise_for_status()
soup = BeautifulSoup(response.content, 'html.parser')
# Find all tracks-list-item elements
track_items = soup.find_all(attrs={'data-testid': 'tracks-list-item'})
if not track_items:
print(f"❌ No tracks-list-item elements found on {genre_url}")
return {
'beatport_top10': [],
'hype_top10': [],
'total_tracks': 0,
'has_hype_section': False
}
print(f"📊 Found {len(track_items)} total track items")
# Extract track data from all items
all_tracks = []
for index, item in enumerate(track_items):
track_data = self.extract_track_data_from_item(item, index + 1)
if track_data:
all_tracks.append(track_data)
# Separate into Beatport Top 10 and Hype Top 10 with proper ranking
beatport_top10 = []
hype_top10 = []
for i, track in enumerate(all_tracks):
if i < 10:
# First 10 tracks = Beatport Top 10 (ranks 1-10)
track_copy = track.copy()
track_copy['rank'] = i + 1
beatport_top10.append(track_copy)
else:
# Remaining tracks = Hype Top 10 (ranks 1-10, not continuing from 11)
track_copy = track.copy()
track_copy['rank'] = (i - 10) + 1 # Reset ranking for Hype (1, 2, 3...)
hype_top10.append(track_copy)
has_hype_section = len(all_tracks) > 10
print(f"✅ Extracted {len(beatport_top10)} Beatport Top 10 + {len(hype_top10)} Hype Top 10 tracks")
return {
'beatport_top10': beatport_top10,
'hype_top10': hype_top10,
'total_tracks': len(all_tracks),
'has_hype_section': has_hype_section
}
def extract_track_data_from_item(self, track_item, rank):
"""Extract structured data from a tracks-list-item element"""
try:
# Extract title
title_elem = track_item.find('a') or track_item.find(class_=re.compile(r'title', re.I))
title = title_elem.get_text(strip=True) if title_elem else "Unknown Title"
# Extract URL
url = None
if title_elem and title_elem.name == 'a':
url = title_elem.get('href', '')
if url and not url.startswith('http'):
url = urljoin("https://www.beatport.com", url)
# Extract artists
artist_links = track_item.find_all('a', href=re.compile(r'/artist/'))
artists = []
artists_string = ""
if artist_links:
for artist_link in artist_links:
artist_name = artist_link.get_text(strip=True)
artist_url = artist_link.get('href', '')
if not artist_url.startswith('http'):
artist_url = urljoin("https://www.beatport.com", artist_url)
if artist_name:
artists.append({
'name': artist_name,
'url': artist_url
})
artists_string = ', '.join([a['name'] for a in artists])
else:
# Fallback: try to find artist text without links
artist_elem = track_item.find(class_=re.compile(r'artist', re.I))
artists_string = artist_elem.get_text(strip=True) if artist_elem else "Unknown Artist"
# Extract label
label_elem = track_item.find('a', href=re.compile(r'/label/'))
label = label_elem.get_text(strip=True) if label_elem else "Unknown Label"
# Extract artwork
img_elem = track_item.find('img')
artwork_url = None
if img_elem:
artwork_url = img_elem.get('src') or img_elem.get('data-src', '')
if artwork_url and not artwork_url.startswith('http'):
artwork_url = urljoin("https://www.beatport.com", artwork_url)
# Extract any additional metadata
classes = track_item.get('class', [])
return {
'title': title,
'artist': artists_string,
'artists': artists,
'label': label,
'url': url,
'artwork_url': artwork_url,
'rank': rank,
'type': 'track',
'source': 'genre_page',
'classes': classes
}
except Exception as e:
print(f"❌ Error extracting track data: {e}")
return None
def extract_hero_release_data(self, release_element) -> Dict:
"""Extract structured data from a hero release element"""
data = {

@ -13208,6 +13208,77 @@ def get_beatport_genre_hero(genre_slug, genre_id):
"genre_id": genre_id
}), 500
@app.route('/api/beatport/genre/<genre_slug>/<genre_id>/top-10-lists', methods=['GET'])
def get_beatport_genre_top10_lists(genre_slug, genre_id):
"""Get Top 10 lists (Beatport + Hype) for a specific genre with 1-hour caching"""
try:
logger.info(f"🎵 API request for {genre_slug} Top 10 lists (ID: {genre_id})")
# Check cache first (1-hour TTL)
cached_data = get_cached_beatport_data('genre', 'top_10_lists', genre_slug)
if cached_data:
logger.info(f"✅ Returning cached Top 10 lists for {genre_slug}")
cached_data['success'] = True
cached_data['cached'] = True
return jsonify(cached_data)
# Initialize the Beatport scraper
scraper = BeatportUnifiedScraper()
# Scrape Top 10 lists from genre page
top10_data = scraper.scrape_genre_top10_tracks(genre_slug, genre_id)
if not top10_data['beatport_top10'] and not top10_data['hype_top10']:
return jsonify({
"success": False,
"error": "No Top 10 tracks found for this genre",
"beatport_top10": [],
"hype_top10": [],
"beatport_count": 0,
"hype_count": 0,
"has_hype_section": False,
"genre_slug": genre_slug,
"genre_id": genre_id,
"cached": False
})
# Prepare response data
response_data = {
"beatport_top10": top10_data['beatport_top10'],
"hype_top10": top10_data['hype_top10'],
"beatport_count": len(top10_data['beatport_top10']),
"hype_count": len(top10_data['hype_top10']),
"has_hype_section": top10_data['has_hype_section'],
"total_tracks": top10_data['total_tracks'],
"genre_slug": genre_slug,
"genre_id": genre_id,
"cached": False,
"cache_ttl": 3600 # 1 hour
}
# Cache the data (1-hour TTL)
set_cached_beatport_data('genre', 'top_10_lists', response_data, genre_slug)
logger.info(f"✅ Successfully fetched {response_data['beatport_count']} Beatport + {response_data['hype_count']} Hype Top 10 tracks for {genre_slug}")
response_data['success'] = True
return jsonify(response_data)
except Exception as e:
logger.error(f"❌ Error fetching Top 10 lists for {genre_slug}: {e}")
return jsonify({
"success": False,
"error": str(e),
"beatport_top10": [],
"hype_top10": [],
"beatport_count": 0,
"hype_count": 0,
"has_hype_section": False,
"genre_slug": genre_slug,
"genre_id": genre_id,
"cached": False
}), 500
@app.route('/api/beatport/genre/<genre_slug>/<genre_id>/sections', methods=['GET'])
def get_beatport_genre_sections(genre_slug, genre_id):
"""Discover all available sections for a specific Beatport genre"""

@ -21253,7 +21253,11 @@ async function handleGenreBrowserCardClick(genreSlug, genreId, genreName) {
showGenrePageView(genreSlug, genreId, genreName);
// Load the hero slider data
await loadGenreHeroSlider(genreSlug, genreId, genreName);
// Load hero slider and Top 10 lists in parallel
await Promise.all([
loadGenreHeroSlider(genreSlug, genreId, genreName),
loadGenreTop10Lists(genreSlug, genreId, genreName)
]);
} catch (error) {
console.error(`❌ Error loading genre page for ${genreName}:`, error);
@ -21309,6 +21313,12 @@ function showGenrePageView(genreSlug, genreId, genreName) {
</button>
</div>
</div>
<div class="genre-top10-lists-container" id="genre-top10-lists-container">
<div class="genre-top10-loading-container">
<div class="genre-loading-spinner"></div>
<p class="genre-loading-text">🎵 Loading Top 10 lists...</p>
</div>
</div>
`;
modal.querySelector('.genre-browser-modal-content').appendChild(genrePageContent);
@ -21679,6 +21689,325 @@ function startGenreHeroSliderAutoPlay() {
console.log(`▶️ Started auto-play for genre hero slider (${window.genreHeroSliderState.totalSlides} slides)`);
}
/**
* Load Top 10 lists for a specific genre (Beatport + Hype)
*/
async function loadGenreTop10Lists(genreSlug, genreId, genreName) {
console.log(`🎵 Loading Top 10 lists for ${genreName}...`);
const container = document.getElementById('genre-top10-lists-container');
if (!container) {
console.error('❌ Genre Top 10 lists container not found');
return;
}
try {
const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/top-10-lists`);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to load Top 10 lists');
}
console.log(`✅ Loaded ${data.beatport_count} Beatport + ${data.hype_count} Hype Top 10 tracks for ${genreName}`);
// Generate HTML using exact same structure as main page (but unique IDs)
const top10ListsHTML = createGenreTop10ListsHTML(data, genreName);
container.innerHTML = top10ListsHTML;
// Add container-level click handlers exactly like main page
addGenreTop10ClickHandlers();
console.log(`✅ Successfully populated genre Top 10 lists for ${genreName}`);
} catch (error) {
console.error(`❌ Error loading Top 10 lists for ${genreName}:`, error);
// Show error state
container.innerHTML = `
<div class="genre-top10-error">
<h3> Error Loading Top 10 Lists</h3>
<p>Could not load Top 10 tracks for ${genreName}</p>
<p class="error-detail">${error.message}</p>
</div>
`;
}
}
/**
* Create HTML for genre Top 10 lists (exact structure as main page, unique IDs)
*/
function createGenreTop10ListsHTML(data, genreName) {
const { beatport_top10, hype_top10, has_hype_section } = data;
// Use exact same structure as main page but with genre-specific IDs
let html = `
<div class="beatport-top10-section">
<div class="beatport-top10-header">
<h2 class="beatport-top10-title">🏆 ${genreName} Top 10 Lists</h2>
<p class="beatport-top10-subtitle">Current trending ${genreName.toLowerCase()} tracks</p>
</div>
<div class="beatport-top10-container">
<!-- Beatport Top 10 List (same classes, unique ID) -->
<div class="beatport-top10-list" id="genre-beatport-top10-list">
<div class="beatport-top10-list-header">
<h3 class="beatport-top10-list-title">🎵 Beatport Top 10</h3>
<p class="beatport-top10-list-subtitle">Most popular ${genreName.toLowerCase()} tracks</p>
</div>
<div class="beatport-top10-tracks">
`;
// Add Beatport Top 10 tracks (same classes as main page)
beatport_top10.forEach((track, index) => {
const cleanTitle = cleanTrackText(track.title || 'Unknown Title');
const cleanArtist = cleanTrackText(track.artist || 'Unknown Artist');
const cleanLabel = cleanTrackText(track.label || 'Unknown Label');
html += `
<div class="beatport-top10-card" data-url="${track.url || '#'}">
<div class="beatport-top10-card-rank">${track.rank || index + 1}</div>
<div class="beatport-top10-card-artwork">
${track.artwork_url ?
`<img src="${track.artwork_url}" alt="${cleanTitle}" loading="lazy">` :
'<div class="beatport-top10-card-placeholder">🎵</div>'
}
</div>
<div class="beatport-top10-card-info">
<h4 class="beatport-top10-card-title">${cleanTitle}</h4>
<p class="beatport-top10-card-artist">${cleanArtist}</p>
<p class="beatport-top10-card-label">${cleanLabel}</p>
</div>
</div>
`;
});
html += `
</div>
</div>
`;
// Add Hype Top 10 section (same classes, unique ID)
if (has_hype_section && hype_top10.length > 0) {
html += `
<!-- Hype Top 10 List (same classes, unique ID) -->
<div class="beatport-hype10-list" id="genre-beatport-hype10-list">
<div class="beatport-hype10-list-header">
<h3 class="beatport-hype10-list-title">🔥 Hype Top 10</h3>
<p class="beatport-hype10-list-subtitle">Editor's trending ${genreName.toLowerCase()} picks</p>
</div>
<div class="beatport-hype10-tracks">
`;
// Add Hype Top 10 tracks (same classes as main page)
hype_top10.forEach((track, index) => {
const cleanTitle = cleanTrackText(track.title || 'Unknown Title');
const cleanArtist = cleanTrackText(track.artist || 'Unknown Artist');
const cleanLabel = cleanTrackText(track.label || 'Unknown Label');
html += `
<div class="beatport-hype10-card" data-url="${track.url || '#'}">
<div class="beatport-hype10-card-rank">${track.rank || index + 1}</div>
<div class="beatport-hype10-card-artwork">
${track.artwork_url ?
`<img src="${track.artwork_url}" alt="${cleanTitle}" loading="lazy">` :
'<div class="beatport-hype10-card-placeholder">🔥</div>'
}
</div>
<div class="beatport-hype10-card-info">
<h4 class="beatport-hype10-card-title">${cleanTitle}</h4>
<p class="beatport-hype10-card-artist">${cleanArtist}</p>
<p class="beatport-hype10-card-label">${cleanLabel}</p>
</div>
</div>
`;
});
html += `
</div>
</div>
`;
} else {
// Add empty hype placeholder to maintain side-by-side layout
html += `
<!-- Empty Hype Placeholder -->
<div class="beatport-hype10-list" id="genre-beatport-hype10-list">
<div class="beatport-hype10-list-header">
<h3 class="beatport-hype10-list-title">🔥 Hype Top 10</h3>
<p class="beatport-hype10-list-subtitle">No trending picks available</p>
</div>
<div class="beatport-hype10-tracks">
<div class="beatport-hype10-empty">
<p>No Hype Top 10 tracks for this genre</p>
</div>
</div>
</div>
`;
}
html += `
</div>
</div>
`;
return html;
}
/**
* Add container-level click handlers for genre Top 10 lists (exact parity with main page)
*/
function addGenreTop10ClickHandlers() {
console.log('🔗 Adding container-level click handlers for genre Top 10 lists...');
// Add container-level click handler for Beatport Top 10 (exact match to main page)
const beatportContainer = document.getElementById('genre-beatport-top10-list');
if (beatportContainer) {
beatportContainer.addEventListener('click', () => {
console.log('🎵 Genre Beatport Top 10 container clicked');
handleGenreBeatportTop10Click();
});
console.log('✅ Added Beatport Top 10 container click handler');
}
// Add container-level click handler for Hype Top 10 (exact match to main page)
const hypeContainer = document.getElementById('genre-beatport-hype10-list');
if (hypeContainer) {
hypeContainer.addEventListener('click', () => {
console.log('🔥 Genre Hype Top 10 container clicked');
handleGenreHypeTop10Click();
});
console.log('✅ Added Hype Top 10 container click handler');
}
console.log(`✅ Set up container-level click handlers for genre Top 10 lists`);
}
/**
* Handle genre Beatport Top 10 container click (exact parity with main page)
*/
async function handleGenreBeatportTop10Click() {
console.log('🎵 Handling Genre Beatport Top 10 click');
// Use exact same pattern as main page
await handleGenreChartClick('genre_beatport_top10', 'Genre Beatport Top 10', 'genre_beatport_top10');
}
/**
* Handle genre Hype Top 10 container click (exact parity with main page)
*/
async function handleGenreHypeTop10Click() {
console.log('🔥 Handling Genre Hype Top 10 click');
// Use exact same pattern as main page
await handleGenreChartClick('genre_hype_top10', 'Genre Hype Top 10', 'genre_hype_top10');
}
/**
* Handle genre chart click (based on main page handleRebuildChartClick)
*/
async function handleGenreChartClick(trackDataKey, chartName, chartType) {
try {
// Create chart hash (following main page pattern)
const chartHash = `${chartType}_${Date.now()}`;
// Check if we already have an existing state (following main page pattern)
const existingState = Object.values(beatportChartStates).find(state =>
state.chart && state.chart.name === chartName && state.chart.chart_type === chartType
);
if (existingState) {
console.log(`🔄 Found existing ${chartName} card, opening existing modal`);
// Use existing card click handler (following main page pattern)
handleBeatportCardClick(existingState.chart.hash);
return;
}
// Extract track data from DOM cards (exact same pattern as main page)
const trackData = await getGenrePageTrackData(trackDataKey);
if (!trackData || trackData.length === 0) {
throw new Error(`No track data found for ${chartName}`);
}
// Transform DOM data to Browse Charts format EXACTLY like main page
const chartData = {
hash: chartHash,
name: chartName,
chart_type: chartType,
track_count: trackData.length,
tracks: trackData.map(track => ({
name: cleanTrackText(track.title || 'Unknown Title'),
artists: [cleanTrackText(track.artist || 'Unknown Artist')],
album: chartName,
duration_ms: 0,
external_urls: { beatport: track.url || '' },
source: 'beatport'
}))
};
// Follow main page pattern EXACTLY:
// 1. Add card to container (creates playlist card)
console.log(`🃏 Creating Beatport playlist card for: ${chartData.name}`);
addBeatportCardToContainer(chartData);
// 2. Automatically open discovery modal (like when you click a card in fresh state)
handleBeatportCardClick(chartHash);
console.log(`✅ Created ${chartName} card and opened discovery modal`);
} catch (error) {
console.error(`❌ Error handling ${chartName} click:`, error);
showToast(`Error loading ${chartName}: ${error.message}`, 'error');
}
}
/**
* Extract track data from genre page DOM (based on main page getRebuildPageTrackData)
*/
async function getGenrePageTrackData(trackDataKey) {
console.log(`🔍 Extracting ${trackDataKey} data from genre page DOM`);
let containerSelector, cardSelector;
if (trackDataKey === 'genre_beatport_top10') {
containerSelector = '#genre-beatport-top10-list';
cardSelector = '.beatport-top10-card[data-url]';
} else if (trackDataKey === 'genre_hype_top10') {
containerSelector = '#genre-beatport-hype10-list';
cardSelector = '.beatport-hype10-card[data-url]';
} else {
throw new Error(`Unknown track data key: ${trackDataKey}`);
}
const container = document.querySelector(containerSelector);
if (!container) {
throw new Error(`Container ${containerSelector} not found`);
}
const trackCards = container.querySelectorAll(cardSelector);
if (trackCards.length === 0) {
throw new Error(`No track cards found in ${containerSelector}`);
}
// Extract track data from DOM cards (exact same pattern as main page)
const tracks = Array.from(trackCards).map(card => {
const title = card.querySelector('.beatport-top10-card-title, .beatport-hype10-card-title')?.textContent?.trim() || 'Unknown Title';
const artist = card.querySelector('.beatport-top10-card-artist, .beatport-hype10-card-artist')?.textContent?.trim() || 'Unknown Artist';
const label = card.querySelector('.beatport-top10-card-label, .beatport-hype10-card-label')?.textContent?.trim() || 'Unknown Label';
const url = card.getAttribute('data-url') || '';
const rank = card.querySelector('.beatport-top10-card-rank, .beatport-hype10-card-rank')?.textContent?.trim() || '';
return {
title: title,
artist: artist,
label: label,
url: url,
rank: rank
};
});
console.log(`📋 Extracted ${tracks.length} tracks from ${containerSelector}`);
return tracks;
}
/**
* Handle genre-specific Top 100 button click - create discovery process for genre top 100 tracks
*/

Loading…
Cancel
Save