Signed-off-by: mbrucedogs <mbrucedogs@gmail.com>

This commit is contained in:
mbrucedogs 2025-07-24 12:39:56 -05:00
parent bcfa16aeed
commit ca36e2dbf5
21 changed files with 1345 additions and 52 deletions

67
backend/debug-html.js Normal file
View File

@ -0,0 +1,67 @@
const axios = require('axios');
const cheerio = require('cheerio');
async function debugHTML() {
try {
console.log('Fetching Shazam Country Chart HTML...');
const response = await axios.get('https://www.shazam.com/charts/genre/united-states/country', {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'Cache-Control': 'max-age=0'
},
timeout: 30000
});
const html = response.data;
const $ = cheerio.load(html);
console.log('HTML length:', html.length);
// Look for list elements
const ulElements = $('ul');
console.log('Found', ulElements.length, 'ul elements');
ulElements.each((i, ul) => {
const $ul = $(ul);
const liElements = $ul.find('li');
console.log(`UL ${i + 1}: Found ${liElements.length} li elements`);
if (liElements.length > 0) {
liElements.each((j, li) => {
const $li = $(li);
const text = $li.text().trim();
if (text.length > 0) {
console.log(` LI ${j + 1}: "${text}"`);
}
});
}
});
// Look for elements with numbers
console.log('\nLooking for elements with numbers...');
$('*').each((i, el) => {
const $el = $(el);
const text = $el.text().trim();
// Look for text that starts with a number and contains song-like content
if (text.match(/^\d+/) && text.length > 10 && text.length < 200) {
console.log(`Element ${i}: "${text}"`);
}
});
} catch (error) {
console.error('Error:', error.message);
}
}
debugHTML();

14
backend/debug-shazam.js Normal file
View File

@ -0,0 +1,14 @@
const ShazamService = require('./src/models/shazamService');
async function debugShazam() {
try {
console.log('Testing Shazam Country Chart...');
const data = await ShazamService.getChartData('country');
console.log('Found', data.length, 'songs:');
console.log(JSON.stringify(data, null, 2));
} catch (error) {
console.error('Error:', error.message);
}
}
debugShazam();

View File

@ -1,14 +1,23 @@
const ChartService = require('../models/chartService');
const KworbService = require('../models/kworbService');
const ShazamService = require('../models/shazamService');
// Get available dates for a specific year
const getChartDates = async (req, res) => {
try {
const { year } = req.params;
const source = req.query.source || 'musicchartsarchive';
let dates;
// Validate year parameter
const validatedYear = ChartService.validateYear(year);
const dates = await ChartService.getAvailableDates(validatedYear);
if (source === 'kworb') {
dates = await KworbService.getAvailableDates(year);
} else if (source === 'shazam') {
dates = await ShazamService.getAvailableDates(year);
} else {
// Validate year parameter
const validatedYear = ChartService.validateYear(year);
dates = await ChartService.getAvailableDates(validatedYear);
}
res.json(dates);
} catch (error) {
console.error('Error in getChartDates:', error);
@ -23,6 +32,8 @@ const getChartDates = async (req, res) => {
const getChartData = async (req, res) => {
try {
const { date } = req.params;
const source = req.query.source || 'musicchartsarchive';
const chartType = req.query.chartType || 'top-200'; // For Shazam
if (!date) {
return res.status(400).json({
@ -30,11 +41,17 @@ const getChartData = async (req, res) => {
message: 'Date parameter is required'
});
}
// Validate date format
ChartService.validateDate(date);
const data = await ChartService.getChartData(date);
let data;
if (source === 'kworb') {
data = await KworbService.getChartData(date);
} else if (source === 'shazam') {
data = await ShazamService.getChartData(chartType);
} else {
// Validate date format
ChartService.validateDate(date);
data = await ChartService.getChartData(date);
}
res.json(data);
} catch (error) {
console.error('Error in getChartData:', error);
@ -49,11 +66,19 @@ const getChartData = async (req, res) => {
const getYearlyTopSongs = async (req, res) => {
try {
const { year } = req.params;
const source = req.query.source || 'musicchartsarchive';
const chartType = req.query.chartType || 'top-200'; // For Shazam
// Validate year parameter
const validatedYear = ChartService.validateYear(year);
const yearlySongs = await ChartService.getYearlyTopSongs(validatedYear);
let yearlySongs;
if (source === 'kworb') {
yearlySongs = await KworbService.getYearlyTopSongs(year);
} else if (source === 'shazam') {
yearlySongs = await ShazamService.getYearlyTopSongs(year, chartType);
} else {
// Validate year parameter
const validatedYear = ChartService.validateYear(year);
yearlySongs = await ChartService.getYearlyTopSongs(validatedYear);
}
res.json(yearlySongs);
} catch (error) {
console.error('Error in getYearlyTopSongs:', error);
@ -64,8 +89,23 @@ const getYearlyTopSongs = async (req, res) => {
}
};
// Get available Shazam chart types
const getShazamChartTypes = async (req, res) => {
try {
const chartTypes = ShazamService.getAvailableCharts();
res.json(chartTypes);
} catch (error) {
console.error('Error in getShazamChartTypes:', error);
res.status(400).json({
error: 'Failed to fetch Shazam chart types',
message: error.message
});
}
};
module.exports = {
getChartDates,
getChartData,
getYearlyTopSongs
getYearlyTopSongs,
getShazamChartTypes
};

View File

@ -0,0 +1,165 @@
const axios = require('axios');
const cheerio = require('cheerio');
const BASE_URL = 'https://kworb.net/apple_songs/';
class KworbService {
// Get available dates from the archive (2022 and forward)
static async getAvailableDates(year) {
if (parseInt(year) < 2022) return [];
const archiveUrl = BASE_URL + 'archive/';
console.log('[kworb] Fetching archive:', archiveUrl);
const res = await axios.get(archiveUrl);
const $ = cheerio.load(res.data);
const dates = [];
$('a').each((i, el) => {
const href = $(el).attr('href');
// kworb archive links are in the format YYYYMMDD.html
const match = href && href.match(/(\d{8})\.html/);
if (match) {
// Convert to YYYY-MM-DD for API
const date = match[1].replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3');
const y = parseInt(date.substring(0, 4));
if (y >= 2022 && date.startsWith(year)) {
dates.push({
date,
formattedDate: date // You can format as needed
});
}
}
});
// Optionally add 'today' as the current chart if year is current year
const now = new Date();
if (parseInt(year) === now.getFullYear()) {
dates.unshift({
date: 'today',
formattedDate: 'Today'
});
}
// Sort dates ascending
dates.sort((a, b) => a.date.localeCompare(b.date));
console.log('[kworb] Available dates for', year, ':', dates.map(d => d.date));
return dates;
}
// Get chart data for a specific date
static async getChartData(date) {
let url;
if (date === 'today') {
url = BASE_URL + 'index.html';
} else {
// kworb archive URLs use YYYYMMDD (no dashes)
const dateNoDash = date.replace(/-/g, '');
url = BASE_URL + 'archive/' + dateNoDash + '.html';
}
console.log('[kworb] Fetching chart data:', url);
const res = await axios.get(url);
const $ = cheerio.load(res.data);
let songs = [];
$('table.sortable tbody tr').each((i, el) => {
const tds = $(el).find('td');
if (tds.length >= 13) {
const us = parseInt($(tds[9]).text().trim());
if (isNaN(us)) return; // Only include songs with a valid US position
const artistAndTitle = $(tds[2]).text().trim();
// Split "Artist - Title"
const [artist, ...titleParts] = artistAndTitle.split(' - ');
const title = titleParts.join(' - ');
if (artist && title) {
songs.push({
order: us, // Use US chart position as the order
artist: artist.trim(),
title: title.trim()
});
}
}
});
// Sort by US ranking (ascending) and take top 50
songs = songs.sort((a, b) => a.order - b.order).slice(0, 50);
console.log('[kworb] Found', songs.length, 'songs with US ranking');
return songs;
}
// Get yearly top songs for a year (sample charts throughout the year, aggregate by best US position)
static async getYearlyTopSongs(year) {
const dates = await this.getAvailableDates(year);
if (dates.length === 0) return [];
// Sample charts more frequently - every 3-4 days instead of weekly
const sampledDates = [];
const step = Math.max(1, Math.floor(dates.length / 52)); // Aim for ~52 samples per year
for (let i = 0; i < dates.length; i += step) {
sampledDates.push(dates[i].date);
}
// Also include the last date if not already included
if (sampledDates.length > 0 && sampledDates[sampledDates.length - 1] !== dates[dates.length - 1].date) {
sampledDates.push(dates[dates.length - 1].date);
}
console.log('[kworb] Sampled dates for yearly top:', sampledDates.length, 'charts');
console.log('[kworb] Sample dates:', sampledDates.slice(0, 10), '...');
// Aggregate songs by title+artist, track best US position and appearances
const songMap = {};
let totalSongsProcessed = 0;
for (const date of sampledDates) {
try {
const chart = await this.getChartData(date);
totalSongsProcessed += chart.length;
chart.forEach(song => {
if (!song.title || !song.artist) return;
const key = song.title + '|' + song.artist;
if (!songMap[key]) {
songMap[key] = {
title: song.title,
artist: song.artist,
bestUS: song.order,
appearances: 0
};
}
if (song.order < songMap[key].bestUS) {
songMap[key].bestUS = song.order;
}
songMap[key].appearances++;
});
} catch (error) {
console.log('[kworb] Error processing date', date, ':', error.message);
}
}
console.log('[kworb] Processed', totalSongsProcessed, 'total songs across', sampledDates.length, 'charts');
console.log('[kworb] Found', Object.keys(songMap).length, 'unique songs');
// Sort by best US position (ascending), then appearances (descending), then title for consistency
let topSongs = Object.values(songMap)
.sort((a, b) => {
if (a.bestUS !== b.bestUS) return a.bestUS - b.bestUS;
if (a.appearances !== b.appearances) return b.appearances - a.appearances;
return a.title.localeCompare(b.title); // Consistent tie-breaker
})
.slice(0, 50)
.map((song, index) => ({
order: index + 1, // Create new ranking based on aggregated performance
title: song.title,
artist: song.artist,
appearances: song.appearances,
bestUS: song.bestUS // Keep the best US position for reference
}));
console.log('[kworb] Yearly top songs:', topSongs.length, 'songs');
console.log('[kworb] Sample of yearly data:', topSongs.slice(0, 5).map(s => `${s.order}. ${s.title} - ${s.artist} (appearances: ${s.appearances}, best US: ${s.bestUS})`));
return topSongs;
}
}
// Helper: get ISO week number from date string (YYYY-MM-DD)
function getWeekOfYear(dateStr) {
const d = new Date(dateStr);
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + 4 - (d.getDay() || 7));
const yearStart = new Date(d.getFullYear(), 0, 1);
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
}
module.exports = KworbService;

View File

@ -0,0 +1,694 @@
const axios = require('axios');
const cheerio = require('cheerio');
const BASE_URL = 'https://www.shazam.com/charts';
class ShazamService {
// Get available chart types
static getAvailableCharts() {
return [
{ id: 'top-200', name: 'Top 200', url: `${BASE_URL}/top-200/united-states` },
{ id: 'pop', name: 'Pop', url: `${BASE_URL}/genre/united-states/pop` },
{ id: 'hip-hop-rap', name: 'Hip-Hop/Rap', url: `${BASE_URL}/genre/united-states/hip-hop-rap` },
{ id: 'country', name: 'Country', url: `${BASE_URL}/genre/united-states/country` }
];
}
// Get chart data for a specific chart type
static async getChartData(chartType = 'top-200') {
const charts = this.getAvailableCharts();
const chart = charts.find(c => c.id === chartType);
if (!chart) {
throw new Error(`Invalid chart type: ${chartType}. Available: ${charts.map(c => c.id).join(', ')}`);
}
console.log('[shazam] Fetching chart data from:', chart.url);
return await this.scrapeHTMLData(chart.url);
}
// Scrape HTML data from Shazam
static async scrapeHTMLData(url) {
console.log('[shazam] Scraping HTML from:', url);
const response = await axios.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'Cache-Control': 'max-age=0'
},
timeout: 30000
});
const html = response.data;
return this.parseHTML(html);
}
// Parse HTML data based on Shazam's structure
static parseHTML(html) {
const songs = [];
const $ = cheerio.load(html);
console.log('[shazam] Parsing HTML content...');
console.log('[shazam] HTML length:', html.length);
// Save raw HTML for debugging (first 5000 chars)
console.log('[shazam] Raw HTML preview:', html.substring(0, 5000));
// First, try the new Shazam-specific parsing method
console.log('[shazam] Trying Shazam-specific structure parsing...');
this.extractSongsFromShazamStructure($, songs);
// Also try the exact structure from the provided HTML
console.log('[shazam] Trying exact HTML structure parsing...');
this.extractSongsFromExactStructure($, songs);
// If no songs found, try the existing methods
if (songs.length === 0) {
console.log('[shazam] No songs found with Shazam structure, trying alternative methods...');
// Look for the specific chart list container class
const chartContainer = $('.ListShowMoreLess_container__t4TNB.page_chartList__aBclW');
console.log('[shazam] Chart container found:', chartContainer.length);
if (chartContainer.length === 0) {
console.log('[shazam] Chart container not found, trying alternative selectors...');
// Try alternative selectors
const alternativeSelectors = [
'.page_chartList__aBclW',
'.ListShowMoreLess_container__t4TNB',
'[class*="chartList"]',
'[class*="ListShowMoreLess"]',
'[class*="chart"]',
'[class*="list"]'
];
for (const selector of alternativeSelectors) {
const container = $(selector);
console.log('[shazam] Selector', selector, 'found:', container.length, 'elements');
if (container.length > 0) {
console.log('[shazam] Found container with selector:', selector);
this.extractSongsFromContainer(container, songs);
break;
}
}
} else {
console.log('[shazam] Found chart container with specific class');
this.extractSongsFromContainer(chartContainer, songs);
}
// If still no songs, try extracting from image alt text
if (songs.length === 0) {
console.log('[shazam] No songs found in containers, trying image alt text...');
this.extractSongsFromImages($, songs);
}
// If still no songs, try extracting from JSON-LD structured data
if (songs.length === 0) {
console.log('[shazam] No songs found in images, trying JSON-LD data...');
this.extractSongsFromJSONLD($, songs);
}
// If still no songs, try extracting from list elements (ul/li)
if (songs.length === 0) {
console.log('[shazam] No songs found in JSON-LD, trying list elements...');
this.extractSongsFromListElements($, songs);
}
// If still no songs, try looking for Shazam-specific elements
if (songs.length === 0) {
console.log('[shazam] No songs found in list elements, trying Shazam-specific elements...');
this.extractSongsFromShazamElements($, songs);
}
// If still no songs, try a more generic approach
if (songs.length === 0) {
console.log('[shazam] No songs found in Shazam elements, trying generic approach...');
this.extractSongsGeneric($, songs);
}
}
// Sort by order and take top 50
songs.sort((a, b) => a.order - b.order);
const topSongs = songs.slice(0, 50);
console.log('[shazam] Final result:', topSongs.length, 'songs');
// Log some examples for debugging
if (topSongs.length > 0) {
console.log('[shazam] Sample songs:', topSongs.slice(0, 3));
} else {
console.log('[shazam] No songs found. Checking for any numbers in text...');
const textContent = $.text();
const numberMatches = textContent.match(/\d+/g);
console.log('[shazam] Numbers found in text:', numberMatches ? numberMatches.slice(0, 10) : 'none');
// Look for any text that might contain song data
const lines = textContent.split('\n');
console.log('[shazam] Text lines (first 20):', lines.slice(0, 20));
}
return topSongs;
}
// Extract songs from a specific container
static extractSongsFromContainer(container, songs) {
console.log('[shazam] Extracting songs from container...');
// First, try to find chart items with more specific selectors
const chartItems = container.find('[class*="chart-item"], [class*="track-item"], [class*="song-item"], [class*="list-item"]');
console.log('[shazam] Found chart items:', chartItems.length);
if (chartItems.length > 0) {
chartItems.each((i, el) => {
const $el = cheerio.load(el);
const text = $el.text().trim();
console.log('[shazam] Chart item text:', text.substring(0, 100));
// Try different patterns for song data
const patterns = [
/^(\d+)\.?\s*(.+?)\s*[-]\s*(.+)$/, // "1. Song Title - Artist"
/^(\d+)\s+(.+?)\s+[-]\s+(.+)$/, // "1 Song Title - Artist" (no period)
/^(\d+)\.\s*(.+?)\s*[-]\s*(.+)$/, // "1. Song Title - Artist" (with period)
/^(\d+)\s*[-]\s*(.+?)\s*[-]\s*(.+)$/, // "1 - Song Title - Artist"
/^(\d+)\s+(.+?)\s+by\s+(.+)$/i, // "1 Song Title by Artist"
/^(\d+)\.\s*(.+?)\s+by\s+(.+)$/i // "1. Song Title by Artist"
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (match) {
const order = parseInt(match[1]);
const title = match[2].trim();
const artist = match[3].trim();
if (!isNaN(order) && title && artist && order <= 200) {
// Check if we already have this song
const exists = songs.some(s => s.order === order && s.title === title && s.artist === artist);
if (!exists) {
songs.push({ order, title, artist });
console.log('[shazam] Found song:', order, title, '-', artist);
}
}
break; // Found a match, no need to try other patterns
}
}
});
}
// If no chart items found, try looking for any elements with numbers and text
if (songs.length === 0) {
console.log('[shazam] No chart items found, trying generic elements...');
container.find('*').each((i, el) => {
const $el = cheerio.load(el);
const text = $el.text().trim();
// Only process elements with substantial text
if (text.length > 10 && text.length < 200) {
const patterns = [
/^(\d+)\.?\s*(.+?)\s*[-]\s*(.+)$/, // "1. Song Title - Artist"
/^(\d+)\s+(.+?)\s+(.+)$/, // "1 Song Title Artist" (no dash)
/^(\d+)\.\s*(.+?)\s*[-]\s*(.+)$/, // "1. Song Title - Artist" (with period)
/^(\d+)\s*[-]\s*(.+?)\s*[-]\s*(.+)$/ // "1 - Song Title - Artist"
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (match) {
const order = parseInt(match[1]);
const title = match[2].trim();
const artist = match[3].trim();
if (!isNaN(order) && title && artist && order <= 200) {
// Check if we already have this song
const exists = songs.some(s => s.order === order && s.title === title && s.artist === artist);
if (!exists) {
songs.push({ order, title, artist });
console.log('[shazam] Found song (generic):', order, title, '-', artist);
}
}
break; // Found a match, no need to try other patterns
}
}
}
});
}
}
// Extract songs from image alt text (Shazam uses this for song info)
static extractSongsFromImages($, songs) {
let songCount = 0;
// Look for images with alt text containing song information
$('img[alt*="by"]').each((i, el) => {
const alt = $(el).attr('alt');
if (alt) {
// Pattern: "Album artwork for album titled Song Title by Artist"
// or "Listen to Song Title by Artist"
const patterns = [
/album titled (.+?) by (.+)/i,
/Listen to (.+?) by (.+)/i,
/(.+?) by (.+)/i
];
for (const pattern of patterns) {
const match = alt.match(pattern);
if (match) {
const title = match[1].trim();
const artist = match[2].trim();
// Check if we already have this song
const exists = songs.some(s => s.title === title && s.artist === artist);
if (!exists) {
songCount++;
songs.push({ order: songCount, title, artist });
console.log('[shazam] Found song from image:', songCount, title, '-', artist);
}
break; // Found a match, no need to try other patterns
}
}
}
});
}
// Extract songs from JSON-LD structured data
static extractSongsFromJSONLD($, songs) {
try {
// Look for JSON-LD script tags
$('script[type="application/ld+json"]').each((i, el) => {
const scriptContent = $(el).html();
if (scriptContent) {
try {
const jsonData = JSON.parse(scriptContent);
console.log('[shazam] Found JSON-LD data:', typeof jsonData);
// Handle different JSON-LD structures
if (jsonData['@graph'] && Array.isArray(jsonData['@graph'])) {
// Schema.org MusicPlaylist structure
jsonData['@graph'].forEach(item => {
if (item['@type'] === 'MusicRecording' && item.name && item.byArtist) {
const title = item.name;
const artist = typeof item.byArtist === 'object' ? item.byArtist.name : item.byArtist;
const position = item.position || songs.length + 1;
const exists = songs.some(s => s.title === title && s.artist === artist);
if (!exists) {
songs.push({ order: position, title, artist });
console.log('[shazam] Found song from JSON-LD:', position, title, '-', artist);
}
}
});
} else if (jsonData['@type'] === 'MusicPlaylist' && jsonData.track) {
// Direct playlist structure
jsonData.track.forEach((track, index) => {
if (track.name && track.byArtist) {
const title = track.name;
const artist = typeof track.byArtist === 'object' ? track.byArtist.name : track.byArtist;
const position = track.position || index + 1;
const exists = songs.some(s => s.title === title && s.artist === artist);
if (!exists) {
songs.push({ order: position, title, artist });
console.log('[shazam] Found song from JSON-LD playlist:', position, title, '-', artist);
}
}
});
}
} catch (parseError) {
console.log('[shazam] Failed to parse JSON-LD:', parseError.message);
}
}
});
} catch (error) {
console.log('[shazam] Error extracting from JSON-LD:', error.message);
}
}
// Extract songs from list elements (ul/li structure)
static extractSongsFromListElements($, songs) {
console.log('[shazam] Looking for list elements (ul/li)...');
// Look for all ul elements
$('ul').each((ulIndex, ul) => {
const $ul = $(ul);
const liElements = $ul.find('li');
console.log('[shazam] Found ul with', liElements.length, 'li elements');
if (liElements.length > 0) {
liElements.each((liIndex, li) => {
const $li = $(li);
const text = $li.text().trim();
console.log('[shazam] Li text:', text);
// Try different patterns for song data in list items
const patterns = [
/^(\d+)\.?\s*(.+?)\s*[-]\s*(.+)$/, // "1. Song Title - Artist"
/^(\d+)\s+(.+?)\s+[-]\s+(.+)$/, // "1 Song Title - Artist" (no period)
/^(\d+)\.\s*(.+?)\s*[-]\s*(.+)$/, // "1. Song Title - Artist" (with period)
/^(\d+)\s*[-]\s*(.+?)\s*[-]\s*(.+)$/, // "1 - Song Title - Artist"
/^(\d+)\s+(.+?)\s+by\s+(.+)$/i, // "1 Song Title by Artist"
/^(\d+)\.\s*(.+?)\s+by\s+(.+)$/i, // "1. Song Title by Artist"
/^(\d+)\s+(.+?)\s*[-]\s*(.+)$/ // "1 Song Title - Artist" (flexible spacing)
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (match) {
const order = parseInt(match[1]);
const title = match[2].trim();
const artist = match[3].trim();
if (!isNaN(order) && title && artist && order <= 200) {
// Check if we already have this song
const exists = songs.some(s => s.order === order && s.title === title && s.artist === artist);
if (!exists) {
songs.push({ order, title, artist });
console.log('[shazam] Found song from list:', order, title, '-', artist);
}
}
break; // Found a match, no need to try other patterns
}
}
});
}
});
}
// Extract songs from Shazam-specific elements
static extractSongsFromShazamElements($, songs) {
console.log('[shazam] Looking for Shazam-specific elements...');
// Look for elements with Shazam-specific classes or attributes
const shazamSelectors = [
'[class*="track"]',
'[class*="song"]',
'[class*="chart-item"]',
'[class*="music"]',
'[data-testid*="track"]',
'[data-testid*="song"]'
];
for (const selector of shazamSelectors) {
const elements = $(selector);
console.log('[shazam] Selector', selector, 'found:', elements.length, 'elements');
elements.each((i, el) => {
const $el = $(el);
const text = $el.text().trim();
if (text.length > 5) {
console.log('[shazam] Shazam element text:', text.substring(0, 100));
// Try different patterns for song data
const patterns = [
/^(\d+)\.?\s*(.+?)\s*[-]\s*(.+)$/, // "1. Song Title - Artist"
/^(\d+)\s+(.+?)\s+[-]\s+(.+)$/, // "1 Song Title - Artist" (no period)
/^(\d+)\.\s*(.+?)\s*[-]\s*(.+)$/, // "1. Song Title - Artist" (with period)
/^(\d+)\s*[-]\s*(.+?)\s*[-]\s*(.+)$/, // "1 - Song Title - Artist"
/^(\d+)\s+(.+?)\s+by\s+(.+)$/i, // "1 Song Title by Artist"
/^(\d+)\.\s*(.+?)\s+by\s+(.+)$/i // "1. Song Title by Artist"
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (match) {
const order = parseInt(match[1]);
const title = match[2].trim();
const artist = match[3].trim();
if (!isNaN(order) && title && artist && order <= 200) {
// Check if we already have this song
const exists = songs.some(s => s.order === order && s.title === title && s.artist === artist);
if (!exists) {
songs.push({ order, title, artist });
console.log('[shazam] Found song from Shazam element:', order, title, '-', artist);
}
}
break; // Found a match, no need to try other patterns
}
}
}
});
}
}
// Generic song extraction as fallback
static extractSongsGeneric($, songs) {
const textContent = $.text();
const lines = textContent.split('\n');
console.log('[shazam] Text content length:', textContent.length);
for (const line of lines) {
const trimmedLine = line.trim();
// Try different patterns for song data
const patterns = [
/^(\d+)\.?\s*(.+?)\s*[-]\s*(.+)$/, // "1. Song Title - Artist"
/^(\d+)\s+(.+?)\s+(.+)$/, // "1 Song Title Artist" (no dash)
/^(\d+)\.\s*(.+?)\s*[-]\s*(.+)$/ // "1. Song Title - Artist" (with period)
];
for (const pattern of patterns) {
const match = trimmedLine.match(pattern);
if (match) {
const order = parseInt(match[1]);
const title = match[2].trim();
const artist = match[3].trim();
if (!isNaN(order) && title && artist && order <= 200) {
// Check if we already have this song
const exists = songs.some(s => s.order === order && s.title === title && s.artist === artist);
if (!exists) {
songs.push({ order, title, artist });
console.log('[shazam] Found song (generic):', order, title, '-', artist);
}
}
break; // Found a match, no need to try other patterns
}
}
}
}
// Extract songs from the specific Shazam HTML structure
static extractSongsFromShazamStructure($, songs) {
console.log('[shazam] Extracting songs from Shazam structure...');
// Look for the specific ul structure with li elements containing song data
$('ul').each((ulIndex, ul) => {
const $ul = $(ul);
const liElements = $ul.find('li');
console.log('[shazam] Found ul with', liElements.length, 'li elements');
if (liElements.length > 0) {
liElements.each((liIndex, li) => {
const $li = $(li);
// Look for the specific Shazam structure based on the provided HTML
const rankingNumber = $li.find('.SongItem-module_rankingNumber__3oDWK').text().trim();
// Find song title - try multiple approaches
let songTitle = '';
const songTitleLink = $li.find('a[data-test-id="charts_userevent_list_songTitle"]');
if (songTitleLink.length > 0) {
songTitle = songTitleLink.text().trim() || songTitleLink.attr('aria-label') || '';
}
// If no song title found, try alternative selectors
if (!songTitle) {
const titleSelectors = [
'a[aria-label]',
'.SongItem-module_ellipisLink__DsCMc a',
'a.common_link__7If7r'
];
for (const selector of titleSelectors) {
const titleElement = $li.find(selector);
if (titleElement.length > 0) {
const ariaLabel = titleElement.attr('aria-label');
if (ariaLabel && ariaLabel !== rankingNumber) {
songTitle = ariaLabel;
break;
}
}
}
}
// Find artist information - try multiple approaches
let artist = '';
// Method 1: Look for artist link with specific data-test-id
const artistLink = $li.find('a[data-test-id="charts_userevent_list_artistName"]');
if (artistLink.length > 0) {
artist = artistLink.text().trim() || artistLink.attr('aria-label') || '';
}
// Method 2: Look for artist in the metadata line
if (!artist) {
const metadataLine = $li.find('.SongItem-module_metadataLine__7Mm6B');
if (metadataLine.length > 0) {
// Look for any text that's not the song title
const metadataText = metadataLine.text().trim();
if (metadataText && metadataText !== songTitle) {
artist = metadataText;
}
}
}
// Method 3: Look for artist in the main items container
if (!artist) {
const mainContainer = $li.find('.SongItem-module_mainItemsContainer__9MRor');
if (mainContainer.length > 0) {
// Find all text elements and look for artist
const textElements = mainContainer.find('a, span');
for (let i = 0; i < textElements.length; i++) {
const element = $(textElements[i]);
const text = element.text().trim();
if (text && text !== songTitle && text !== rankingNumber && !text.match(/^\d+$/)) {
artist = text;
break;
}
}
}
}
// Method 4: Extract from image alt text
if (!artist) {
const img = $li.find('img');
if (img.length > 0) {
const altText = img.attr('alt') || '';
// Extract artist from alt text like "Listen to Song Title by Artist"
const altMatch = altText.match(/by (.+)$/);
if (altMatch) {
artist = altMatch[1].trim();
}
}
}
// Method 5: Extract from any text content as fallback
if (!artist) {
const liText = $li.text().trim();
// Try to find artist after the song title
if (songTitle && liText.includes(songTitle)) {
const afterTitle = liText.substring(liText.indexOf(songTitle) + songTitle.length).trim();
if (afterTitle) {
// Remove common prefixes and clean up
artist = afterTitle.replace(/^[-\s]+/, '').trim();
// Remove any remaining numbers or ranking info
artist = artist.replace(/\d+$/, '').trim();
}
}
}
console.log('[shazam] Parsed song data:', {
rankingNumber,
songTitle,
artist,
liText: $li.text().trim().substring(0, 100)
});
// Validate and add the song
if (rankingNumber && songTitle && artist) {
const order = parseInt(rankingNumber);
if (!isNaN(order) && order <= 200) {
// Clean up artist name (remove any remaining ranking numbers)
artist = artist.replace(/\d+$/, '').trim();
// Check if we already have this song
const exists = songs.some(s => s.order === order && s.title === songTitle && s.artist === artist);
if (!exists) {
songs.push({ order, title: songTitle, artist });
console.log('[shazam] Found song from Shazam structure:', order, songTitle, '-', artist);
}
}
}
});
}
});
console.log('[shazam] Extracted', songs.length, 'songs from Shazam structure');
}
// Extract songs from the exact HTML structure provided
static extractSongsFromExactStructure($, songs) {
console.log('[shazam] Extracting songs from exact HTML structure...');
// Look for li elements with the specific class structure
$('li').each((liIndex, li) => {
const $li = $(li);
// Check if this li contains the song item structure
const songItem = $li.find('.page_songItem__lAdHy');
if (songItem.length === 0) return;
// Extract ranking number from the specific class
const rankingElement = $li.find('.SongItem-module_rankingNumber__3oDWK');
const rankingNumber = rankingElement.text().trim();
// Extract song title from the aria-label attribute
const songTitleElement = $li.find('a[data-test-id="charts_userevent_list_songTitle"]');
let songTitle = '';
if (songTitleElement.length > 0) {
songTitle = songTitleElement.attr('aria-label') || songTitleElement.text().trim();
}
// Extract artist from the artist link
const artistElement = $li.find('a[data-test-id="charts_userevent_list_artistName"]');
let artist = '';
if (artistElement.length > 0) {
artist = artistElement.attr('aria-label') || artistElement.text().trim();
}
// If we have all the data, add the song
if (rankingNumber && songTitle && artist) {
const order = parseInt(rankingNumber);
if (!isNaN(order) && order <= 200) {
// Check if we already have this song
const exists = songs.some(s => s.order === order && s.title === songTitle && s.artist === artist);
if (!exists) {
songs.push({ order, title: songTitle, artist });
console.log('[shazam] Found song from exact structure:', order, songTitle, '-', artist);
}
}
}
});
console.log('[shazam] Extracted', songs.length, 'songs from exact structure');
}
// Get yearly top songs (for now, just return current chart data)
static async getYearlyTopSongs(year, chartType = 'top-200') {
console.log('[shazam] Getting yearly top songs for', year, 'chart type:', chartType);
// For now, return current chart data since Shazam doesn't provide historical data
const currentData = await this.getChartData(chartType);
// Add appearances count (all set to 1 for current data)
return currentData.map(song => ({
...song,
appearances: 1
}));
}
// Get available chart types for the API
static async getAvailableDates(year) {
// Shazam doesn't provide historical date lists
// Return current date as "today"
return [{
date: 'today',
formattedDate: 'Today'
}];
}
}
module.exports = ShazamService;

View File

@ -12,4 +12,7 @@ router.get('/data/:date', chartController.getChartData);
// Get yearly top songs
router.get('/yearly-top/:year', chartController.getYearlyTopSongs);
// Get available Shazam chart types
router.get('/shazam/chart-types', chartController.getShazamChartTypes);
module.exports = router;

View File

@ -0,0 +1,13 @@
{
"files": {
"main.css": "/static/css/main.6452a0d2.css",
"main.js": "/static/js/main.fe2339b5.js",
"index.html": "/index.html",
"main.6452a0d2.css.map": "/static/css/main.6452a0d2.css.map",
"main.fe2339b5.js.map": "/static/js/main.fe2339b5.js.map"
},
"entrypoints": [
"static/css/main.6452a0d2.css",
"static/js/main.fe2339b5.js"
]
}

View File

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Chart Data Visualization Application"/><title>Chart Data Visualization</title><script defer="defer" src="/static/js/main.fe2339b5.js"></script><link href="/static/css/main.6452a0d2.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,39 @@
/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

File diff suppressed because one or more lines are too long

View File

@ -6,18 +6,23 @@ import ChartTable from './components/ChartTable';
import DownloadButton from './components/DownloadButton';
import YearlyTopSongs from './components/YearlyTopSongs';
import YearlyDownloadButton from './components/YearlyDownloadButton';
import SourceSelector from './components/SourceSelector';
import { useChartDates } from './hooks/useChartDates';
import { useChartData } from './hooks/useChartData';
import { useYearlyTopSongs } from './hooks/useYearlyTopSongs';
import { useShazamChartTypes } from './hooks/useShazamChartTypes';
function App() {
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
const [selectedDate, setSelectedDate] = useState(null);
const [viewMode, setViewMode] = useState('weekly'); // 'weekly' or 'yearly'
const [selectedSource, setSelectedSource] = useState('musicchartsarchive');
const [selectedChartType, setSelectedChartType] = useState('top-200');
const { dates, loading: datesLoading, error: datesError } = useChartDates(selectedYear);
const { chartData, loading: dataLoading, error: dataError } = useChartData(selectedDate);
const { yearlySongs, loading: yearlyLoading, error: yearlyError } = useYearlyTopSongs(viewMode === 'yearly' ? selectedYear : null);
const { chartTypes: shazamChartTypes, loading: chartTypesLoading } = useShazamChartTypes();
const { dates, loading: datesLoading, error: datesError } = useChartDates(selectedSource === 'shazam' ? null : selectedYear, selectedSource);
const { chartData, loading: dataLoading, error: dataError } = useChartData(selectedSource === 'shazam' ? 'today' : selectedDate, selectedSource, selectedSource === 'shazam' ? selectedChartType : null);
const { yearlySongs, loading: yearlyLoading, error: yearlyError } = useYearlyTopSongs(selectedSource === 'shazam' ? null : (viewMode === 'yearly' ? selectedYear : null), selectedSource, selectedSource === 'shazam' ? selectedChartType : null);
const handleYearChange = (year) => {
setSelectedYear(year);
@ -35,6 +40,19 @@ function App() {
}
};
const handleSourceChange = (source) => {
setSelectedSource(source);
setSelectedDate(null); // Reset selected date when source changes
if (source !== 'shazam') {
setSelectedChartType('top-200'); // Reset chart type for non-Shazam sources
}
};
const handleChartTypeChange = (chartType) => {
setSelectedChartType(chartType);
setSelectedDate(null); // Reset selected date when chart type changes
};
return (
<Layout>
<div className="app">
@ -44,11 +62,30 @@ function App() {
<main className="app-main">
<div className="controls-section">
<SourceSelector
selectedSource={selectedSource}
onSourceChange={handleSourceChange}
shazamChartTypes={shazamChartTypes}
selectedChartType={selectedChartType}
onChartTypeChange={handleChartTypeChange}
/>
<div className="year-selector-container">
<YearSelector
selectedYear={selectedYear}
onYearChange={handleYearChange}
/>
{selectedSource !== 'shazam' && (
<YearSelector
selectedYear={selectedYear}
onYearChange={handleYearChange}
/>
)}
{selectedSource === 'shazam' && (
<div className="shazam-info">
<div className="info-message">
<strong>Current Data Only</strong>
<p>Shazam provides current trending songs. Historical data is not available.</p>
</div>
</div>
)}
<div className="view-mode-selector">
<label>View Mode:</label>
@ -57,19 +94,19 @@ function App() {
className={`view-mode-btn ${viewMode === 'weekly' ? 'active' : ''}`}
onClick={() => handleViewModeChange('weekly')}
>
Weekly Charts
{selectedSource === 'shazam' ? 'Current Charts' : 'Weekly Charts'}
</button>
<button
className={`view-mode-btn ${viewMode === 'yearly' ? 'active' : ''}`}
onClick={() => handleViewModeChange('yearly')}
>
Yearly Top Songs
{selectedSource === 'shazam' ? 'Current Top Songs' : 'Yearly Top Songs'}
</button>
</div>
</div>
</div>
{viewMode === 'weekly' && (
{viewMode === 'weekly' && selectedSource !== 'shazam' && (
<DateList
dates={dates}
selectedDate={selectedDate}
@ -87,11 +124,11 @@ function App() {
data={chartData}
loading={dataLoading}
error={dataError}
selectedDate={selectedDate}
selectedDate={selectedSource === 'shazam' ? 'Current' : selectedDate}
/>
{chartData && chartData.length > 0 && (
<DownloadButton data={chartData} selectedDate={selectedDate} />
<DownloadButton data={chartData} selectedDate={selectedSource === 'shazam' ? 'Current' : selectedDate} />
)}
</>
) : (
@ -100,11 +137,11 @@ function App() {
data={yearlySongs}
loading={yearlyLoading}
error={yearlyError}
year={selectedYear}
year={selectedSource === 'shazam' ? 'Current' : selectedYear}
/>
{yearlySongs && yearlySongs.length > 0 && (
<YearlyDownloadButton data={yearlySongs} year={selectedYear} />
<YearlyDownloadButton data={yearlySongs} year={selectedSource === 'shazam' ? 'Current' : selectedYear} />
)}
</>
)}

View File

@ -0,0 +1,44 @@
import React from 'react';
const SourceSelector = ({ selectedSource, onSourceChange, shazamChartTypes, selectedChartType, onChartTypeChange }) => {
const sources = [
{ id: 'musicchartsarchive', name: 'Music Charts Archive', description: 'Historical Billboard charts' },
{ id: 'kworb', name: 'Kworb', description: 'Real-time chart data' },
{ id: 'shazam', name: 'Shazam', description: 'Current trending songs (no historical data)' }
];
return (
<div className="source-selector">
<div className="source-tabs">
{sources.map((source) => (
<button
key={source.id}
className={`source-tab ${selectedSource === source.id ? 'active' : ''}`}
onClick={() => onSourceChange(source.id)}
>
<div className="source-name">{source.name}</div>
<div className="source-description">{source.description}</div>
</button>
))}
</div>
{selectedSource === 'shazam' && shazamChartTypes && (
<div className="chart-type-selector">
<label>Chart Type:</label>
<select
value={selectedChartType || 'top-200'}
onChange={(e) => onChartTypeChange(e.target.value)}
>
{shazamChartTypes.map((chartType) => (
<option key={chartType.id} value={chartType.id}>
{chartType.name}
</option>
))}
</select>
</div>
)}
</div>
);
};
export default SourceSelector;

View File

@ -1,14 +1,14 @@
import { useState, useEffect } from 'react';
import { getChartData } from '../services/chartApi';
export const useChartData = (selectedDate) => {
export const useChartData = (selectedDate, source, chartType = null) => {
const [chartData, setChartData] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const fetchChartData = async () => {
if (!selectedDate) {
if (!selectedDate || !source) {
setChartData([]);
setError(null);
return;
@ -18,7 +18,7 @@ export const useChartData = (selectedDate) => {
setError(null);
try {
const data = await getChartData(selectedDate);
const data = await getChartData(selectedDate, source, chartType);
setChartData(data);
} catch (err) {
setError(err.message || 'Failed to fetch chart data');
@ -29,7 +29,7 @@ export const useChartData = (selectedDate) => {
};
fetchChartData();
}, [selectedDate]);
}, [selectedDate, source, chartType]);
return { chartData, loading, error };
};

View File

@ -1,20 +1,20 @@
import { useState, useEffect } from 'react';
import { getChartDates } from '../services/chartApi';
export const useChartDates = (year) => {
export const useChartDates = (year, source) => {
const [dates, setDates] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const fetchDates = async () => {
if (!year) return;
if (!year || !source) return;
setLoading(true);
setError(null);
try {
const datesData = await getChartDates(year);
const datesData = await getChartDates(year, source);
setDates(datesData);
} catch (err) {
setError(err.message || 'Failed to fetch dates');
@ -25,7 +25,7 @@ export const useChartDates = (year) => {
};
fetchDates();
}, [year]);
}, [year, source]);
return { dates, loading, error };
};

View File

@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
import { getShazamChartTypes } from '../services/chartApi';
export const useShazamChartTypes = () => {
const [chartTypes, setChartTypes] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const fetchChartTypes = async () => {
setLoading(true);
setError(null);
try {
const data = await getShazamChartTypes();
setChartTypes(data);
} catch (err) {
setError(err.message || 'Failed to fetch Shazam chart types');
setChartTypes([]);
} finally {
setLoading(false);
}
};
fetchChartTypes();
}, []);
return { chartTypes, loading, error };
};

View File

@ -1,14 +1,14 @@
import { useState, useEffect } from 'react';
import { getYearlyTopSongs } from '../services/chartApi';
export const useYearlyTopSongs = (year) => {
export const useYearlyTopSongs = (year, source, chartType = null) => {
const [yearlySongs, setYearlySongs] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const fetchYearlyTopSongs = async () => {
if (!year) {
if (!year || !source) {
setYearlySongs([]);
setError(null);
return;
@ -18,7 +18,7 @@ export const useYearlyTopSongs = (year) => {
setError(null);
try {
const data = await getYearlyTopSongs(year);
const data = await getYearlyTopSongs(year, source, chartType);
setYearlySongs(data);
} catch (err) {
setError(err.message || 'Failed to fetch yearly top songs');
@ -29,7 +29,7 @@ export const useYearlyTopSongs = (year) => {
};
fetchYearlyTopSongs();
}, [year]);
}, [year, source, chartType]);
return { yearlySongs, loading, error };
};

View File

@ -12,9 +12,9 @@ const api = axios.create({
});
// Get available dates for a specific year
export const getChartDates = async (year) => {
export const getChartDates = async (year, source) => {
try {
const response = await api.get(`/chart/dates/${year}`);
const response = await api.get(`/chart/dates/${year}?source=${source}`);
return response.data;
} catch (error) {
console.error('Error fetching chart dates:', error);
@ -23,9 +23,13 @@ export const getChartDates = async (year) => {
};
// Get chart data for a specific date
export const getChartData = async (date) => {
export const getChartData = async (date, source, chartType = null) => {
try {
const response = await api.get(`/chart/data/${date}`);
let url = `/chart/data/${date}?source=${source}`;
if (chartType) {
url += `&chartType=${chartType}`;
}
const response = await api.get(url);
return response.data;
} catch (error) {
console.error('Error fetching chart data:', error);
@ -34,9 +38,13 @@ export const getChartData = async (date) => {
};
// Get yearly top songs
export const getYearlyTopSongs = async (year) => {
export const getYearlyTopSongs = async (year, source, chartType = null) => {
try {
const response = await api.get(`/chart/yearly-top/${year}`);
let url = `/chart/yearly-top/${year}?source=${source}`;
if (chartType) {
url += `&chartType=${chartType}`;
}
const response = await api.get(url);
return response.data;
} catch (error) {
console.error('Error fetching yearly top songs:', error);
@ -44,5 +52,16 @@ export const getYearlyTopSongs = async (year) => {
}
};
// Get available Shazam chart types
export const getShazamChartTypes = async () => {
try {
const response = await api.get('/chart/shazam/chart-types');
return response.data;
} catch (error) {
console.error('Error fetching Shazam chart types:', error);
throw new Error(error.response?.data?.message || 'Failed to fetch Shazam chart types');
}
};
// Export the api instance for potential future use
export default api;

View File

@ -54,20 +54,128 @@ body {
/* Controls section */
.controls-section {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 30px;
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 30px;
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
}
/* Year selector and view mode */
.year-selector-container {
/* Source selector */
.source-selector {
display: flex;
flex-direction: column;
gap: 15px;
}
.source-tabs {
display: flex;
gap: 10px;
border-bottom: 2px solid #e9ecef;
padding-bottom: 10px;
}
.source-tab {
display: flex;
flex-direction: column;
align-items: center;
padding: 15px 20px;
border: none;
background: transparent;
border-radius: 8px 8px 0 0;
cursor: pointer;
transition: all 0.2s ease;
min-width: 150px;
}
.source-tab:hover {
background: #e9ecef;
}
.source-tab.active {
background: #667eea;
color: white;
}
.source-name {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
}
.source-description {
font-size: 12px;
opacity: 0.8;
text-align: center;
}
.chart-type-selector {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background: white;
border-radius: 6px;
border: 1px solid #e9ecef;
}
.chart-type-selector label {
font-weight: 600;
color: #495057;
white-space: nowrap;
}
.chart-type-selector select {
padding: 8px 12px;
border: 1px solid #e9ecef;
border-radius: 4px;
font-size: 14px;
background: white;
min-width: 150px;
}
.chart-type-selector select:focus {
outline: none;
border-color: #667eea;
}
/* Shazam info message */
.shazam-info {
display: flex;
align-items: center;
padding: 15px;
background: #e3f2fd;
border: 1px solid #2196f3;
border-radius: 6px;
margin-bottom: 10px;
}
.info-message {
text-align: center;
width: 100%;
}
.info-message strong {
color: #1976d2;
font-size: 16px;
display: block;
margin-bottom: 5px;
}
.info-message p {
color: #424242;
margin: 0;
font-size: 14px;
}
/* Year selector and view mode */
.year-selector-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
align-items: start;
}
.year-selector {
@ -476,8 +584,21 @@ body {
}
.controls-section {
gap: 15px;
}
.source-tabs {
flex-direction: column;
gap: 5px;
}
.source-tab {
min-width: auto;
border-radius: 6px;
}
.year-selector-container {
grid-template-columns: 1fr;
gap: 20px;
}
.date-grid {