import streamlit as st import spotipy from spotipy.oauth2 import SpotifyOAuth import requests import random import time from PIL import Image import io import re import json import urllib.parse from textblob import TextBlob from collections import Counter import numpy as np # Page config st.set_page_config( page_title="MoodTunes AI", page_icon="🎵", layout="wide", initial_sidebar_state="expanded" ) # Custom CSS st.markdown(""" """, unsafe_allow_html=True) # Spotify configuration SPOTIFY_CLIENT_ID = "ebf3b2530aed4b3b916f95c6e8fed6b6" SPOTIFY_CLIENT_SECRET = st.secrets.get("SPOTIFY_CLIENT_SECRET", "your_client_secret_here") REDIRECT_URI = "https://kilgorepennington-mood2music.hf.space/" # Initialize session state if 'spotify_token' not in st.session_state: st.session_state.spotify_token = None if 'user_info' not in st.session_state: st.session_state.user_info = None if 'generated_playlist' not in st.session_state: st.session_state.generated_playlist = None if 'auth_step' not in st.session_state: st.session_state.auth_step = 'start' if 'current_track_index' not in st.session_state: st.session_state.current_track_index = 0 if 'is_playing' not in st.session_state: st.session_state.is_playing = False def get_spotify_oauth(): """Create Spotify OAuth object""" return SpotifyOAuth( client_id=SPOTIFY_CLIENT_ID, client_secret=SPOTIFY_CLIENT_SECRET, redirect_uri=REDIRECT_URI, scope="user-read-private user-read-email playlist-modify-public playlist-modify-private user-read-currently-playing user-read-playback-state user-modify-playback-state streaming" ) def get_spotify_auth_url(): """Get Spotify authorization URL""" sp_oauth = get_spotify_oauth() return sp_oauth.get_authorize_url() def exchange_code_for_token(auth_code): """Exchange authorization code for access token""" try: sp_oauth = get_spotify_oauth() token_info = sp_oauth.get_access_token(auth_code, as_dict=True) if token_info and 'access_token' in token_info: return token_info['access_token'] return None except Exception as e: st.error(f"Token exchange failed: {e}") return None def get_spotify_client(token): """Get authenticated Spotify client""" return spotipy.Spotify(auth=token) def fetch_song_lyrics(artist, song_title): """Fetch lyrics from multiple free APIs""" clean_artist = re.sub(r'\s*\(feat\..*?\)', '', artist).strip() clean_song = re.sub(r'\s*\(feat\..*?\)', '', song_title).strip() apis = [ f"https://api.lyrics.ovh/v1/{clean_artist}/{clean_song}", ] for api_url in apis: try: response = requests.get(api_url, timeout=3) if response.status_code == 200: data = response.json() lyrics = data.get('lyrics', '').strip() if lyrics and len(lyrics) > 50: return lyrics except: continue return None def analyze_emotional_themes(text): """Analyze emotional themes in text using semantic analysis""" if not text: return {} text_lower = text.lower() emotional_themes = { 'anger': { 'words': ['angry', 'mad', 'rage', 'furious', 'hate', 'pissed', 'livid', 'irritated', 'annoyed', 'frustrated', 'outraged', 'heated', 'steaming', 'boiling'], 'phrases': ['want to fight', 'so angry', 'makes me mad', 'hate this', 'fed up', 'bar fight'], 'concepts': ['destroy', 'break', 'smash', 'crush', 'explode', 'violence', 'attack', 'fight'] }, 'drinking': { 'words': ['whiskey', 'bourbon', 'beer', 'drunk', 'drinking', 'alcohol', 'bar', 'tavern', 'bottle', 'glass', 'shot', 'liquor', 'vodka', 'rum', 'wine'], 'phrases': ['drinking mood', 'get drunk', 'hit the bar', 'drown sorrows'], 'concepts': ['party', 'celebration', 'drowning sorrows', 'liquid courage'] }, 'wild': { 'words': ['wild', 'crazy', 'howl', 'moon', 'primal', 'beast', 'savage', 'untamed'], 'phrases': ['howl at moon', 'go wild', 'lose control'], 'concepts': ['freedom', 'unleashed', 'primitive', 'instinct'] }, 'rebellion': { 'words': ['rebel', 'fight', 'resist', 'defy', 'revolt', 'against', 'system', 'authority', 'control', 'freedom', 'break free'], 'phrases': ['fight the system', 'break the rules', 'stand up'], 'concepts': ['liberation', 'independence', 'defiance', 'uprising'] }, 'sadness': { 'words': ['sad', 'depressed', 'lonely', 'empty', 'broken', 'hurt', 'pain', 'cry', 'tears', 'sorrow', 'grief', 'misery'], 'phrases': ['so sad', 'feel empty', 'broken heart'], 'concepts': ['loss', 'goodbye', 'missing', 'alone', 'darkness'] }, 'empowerment': { 'words': ['strong', 'powerful', 'confident', 'fierce', 'bold', 'brave', 'warrior', 'champion', 'victory', 'winner', 'succeed'], 'phrases': ['feel strong', 'take control', 'stand tall'], 'concepts': ['strength', 'power', 'control', 'dominance', 'success'] } } theme_scores = {} for theme, theme_data in emotional_themes.items(): score = 0 for word in theme_data['words']: if word in text_lower: score += 2 for phrase in theme_data['phrases']: if phrase in text_lower: score += 3 for concept in theme_data['concepts']: if concept in text_lower: score += 1 theme_scores[theme] = score try: blob = TextBlob(text[:500]) sentiment = blob.sentiment.polarity if sentiment < -0.3: for theme in ['anger', 'sadness']: if theme in theme_scores: theme_scores[theme] += 1 except: sentiment = 0 return theme_scores def calculate_theme_similarity(user_themes, lyric_themes): """Calculate similarity between user themes and song lyric themes""" if not user_themes or not lyric_themes: return 0 similarity = 0 for theme, user_score in user_themes.items(): if user_score > 0 and theme in lyric_themes: lyric_score = lyric_themes[theme] if lyric_score > 0: similarity += min(user_score, 3) * min(lyric_score, 3) matching_themes = sum(1 for theme in user_themes.keys() if user_themes.get(theme, 0) > 0 and lyric_themes.get(theme, 0) > 0) if matching_themes > 1: similarity += matching_themes * 0.5 return similarity def create_playlist_name_from_description(description): """Create a creative playlist name based on user description""" desc_lower = description.lower() # Extract key phrases and emotions if 'whiskey' in desc_lower and 'bar fight' in desc_lower: return "Whiskey Rage & Bar Fights" elif 'whiskey' in desc_lower and 'moon' in desc_lower: return "Whiskey Under the Moon" elif 'drunk' in desc_lower and 'rage' in desc_lower: return "Drunken Fury" elif 'broken heart' in desc_lower: return "Broken Heart Ballads" elif 'workout' in desc_lower or 'gym' in desc_lower: return "Pump Up Power" elif 'nostalgic' in desc_lower: return "Nostalgic Memories" elif 'celebration' in desc_lower or 'party' in desc_lower: return "Celebration Vibes" elif 'angry' in desc_lower or 'pissed' in desc_lower: return "Rage Mode" elif 'sad' in desc_lower or 'down' in desc_lower: return "Melancholy Moods" elif 'love' in desc_lower and 'lost' in desc_lower: return "Lost Love Chronicles" else: # Extract first few meaningful words words = re.findall(r'\b[a-z]{4,}\b', desc_lower) meaningful_words = [w for w in words[:3] if w not in ['feeling', 'really', 'want', 'need', 'like']] if meaningful_words: return f"{' '.join(meaningful_words[:2]).title()} Vibes" else: return "Custom Mood Mix" def search_filtered_songs(spotify_client, user_description, selected_genres, selected_decade): """Search for songs with genre and decade filters""" all_candidates = [] # Analyze themes first user_themes = analyze_emotional_themes(user_description) # Create base thematic searches thematic_searches = [] if user_themes.get('anger', 0) > 0: thematic_searches.extend(['angry songs', 'aggressive music', 'rage']) if user_themes.get('drinking', 0) > 0: thematic_searches.extend(['drinking songs', 'whiskey', 'bar songs']) if user_themes.get('wild', 0) > 0: thematic_searches.extend(['wild music', 'howling', 'primal']) if user_themes.get('rebellion', 0) > 0: thematic_searches.extend(['rebellion songs', 'fight songs']) if user_themes.get('sadness', 0) > 0: thematic_searches.extend(['sad songs', 'heartbreak']) if user_themes.get('empowerment', 0) > 0: thematic_searches.extend(['empowerment songs', 'strong music']) # If no specific themes, use general searches if not thematic_searches: thematic_searches = ['emotional songs', 'intense music'] # Add genre filters to searches if 'All Genres' not in selected_genres: genre_searches = [] for genre in selected_genres: for theme in thematic_searches[:3]: # Limit for performance genre_searches.append(f'genre:"{genre}" {theme}') # Also search pure genre genre_searches.append(f'genre:"{genre}"') search_terms = genre_searches + thematic_searches else: search_terms = thematic_searches # Execute searches with decade filter for search_term in search_terms[:8]: # Limit searches try: # Add year filter if specified if selected_decade != "Any Year": start_year = int(selected_decade[:4]) end_year = start_year + 9 search_query = f'{search_term} year:{start_year}-{end_year}' else: search_query = search_term results = spotify_client.search(q=search_query, type='track', limit=20, market='US') for track in results['tracks']['items']: # Additional decade filtering (Spotify's year filter isn't always perfect) track_year = None if track['album']['release_date']: try: track_year = int(track['album']['release_date'][:4]) except: pass # Check decade filter if selected_decade != "Any Year": decade_start = int(selected_decade[:4]) decade_end = decade_start + 9 if track_year and (track_year < decade_start or track_year > decade_end): continue all_candidates.append({ 'name': track['name'], 'artist': track['artists'][0]['name'], 'id': track['id'], 'popularity': track['popularity'], 'year': track_year }) except Exception as e: continue # Remove duplicates seen = set() unique_candidates = [] for song in all_candidates: key = (song['name'], song['artist']) if key not in seen: seen.add(key) unique_candidates.append(song) return unique_candidates[:80] def create_advanced_playlist(spotify_client, user_description, selected_genres, selected_decade, user_id): """Create playlist with advanced filtering and thematic analysis""" # Analyze user's emotional themes user_themes = analyze_emotional_themes(user_description) # Create playlist name playlist_name = create_playlist_name_from_description(user_description) st.info(f"🎭 Detected themes: {', '.join([k for k, v in user_themes.items() if v > 0])}") st.info(f"🎸 Genres: {', '.join(selected_genres)}") st.info(f"📅 Era: {selected_decade}") # Get filtered candidates candidate_songs = search_filtered_songs(spotify_client, user_description, selected_genres, selected_decade) st.info(f"🔍 Analyzing {len(candidate_songs)} filtered songs...") if not candidate_songs: return None matched_songs = [] progress_bar = st.progress(0) analyzed_count = 0 # Analyze lyrics for thematic similarity for i, song in enumerate(candidate_songs): if analyzed_count >= 50: # Limit for performance break progress_bar.progress((analyzed_count + 1) / 50) lyrics = fetch_song_lyrics(song['artist'], song['name']) if lyrics: analyzed_count += 1 lyric_themes = analyze_emotional_themes(lyrics) similarity_score = calculate_theme_similarity(user_themes, lyric_themes) if similarity_score > 1: matched_songs.append({ 'name': song['name'], 'artist': song['artist'], 'id': song['id'], 'similarity_score': similarity_score, 'popularity': song.get('popularity', 0), 'year': song.get('year'), 'matching_themes': [k for k in user_themes.keys() if user_themes.get(k, 0) > 0 and lyric_themes.get(k, 0) > 0] }) else: # Include some songs without lyrics based on popularity and filters if song.get('popularity', 0) > 40: matched_songs.append({ 'name': song['name'], 'artist': song['artist'], 'id': song['id'], 'similarity_score': 0.5, # Lower score for no lyrics 'popularity': song.get('popularity', 0), 'year': song.get('year'), 'matching_themes': [] }) time.sleep(0.05) progress_bar.empty() # Sort by similarity score and select top matches matched_songs.sort(key=lambda x: (x['similarity_score'], x['popularity']/100), reverse=True) final_songs = matched_songs[:20] if final_songs: st.success(f"✅ Found {len(final_songs)} matching songs!") if final_songs[0]['matching_themes']: st.info(f"🎯 Top songs share themes: {', '.join(final_songs[0]['matching_themes'][:3])}") return create_playlist_from_matched_songs(spotify_client, final_songs, user_id, user_description, playlist_name) else: st.warning("❌ No songs found matching your criteria.") return None def create_playlist_from_matched_songs(spotify_client, matched_songs, user_id, description, playlist_name): """Create the final playlist from matched songs""" try: playlist = spotify_client.user_playlist_create( user=user_id, name=playlist_name, public=False, description=f"AI-curated playlist: {description[:100]}..." ) track_ids = [song['id'] for song in matched_songs] spotify_client.playlist_add_items(playlist['id'], track_ids) return { 'name': playlist_name, 'tracks': [{'name': s['name'], 'artist': s['artist']} for s in matched_songs], 'track_count': len(matched_songs), 'playlist_url': playlist['external_urls']['spotify'], 'playlist_id': playlist['id'], 'is_demo': False, 'method': 'advanced_thematic', 'mood': 'Custom', 'energy': 'Variable' } except Exception as e: st.error(f"Failed to create playlist: {e}") return None def analyze_mood(description): """Basic mood analysis""" return { 'primary_emotion': 'custom', 'energy_level': 'variable', 'description': description } def create_demo_playlist(mood_analysis): """Create demo playlist""" tracks = [ {'name': 'Whiskey River', 'artist': 'Willie Nelson'}, {'name': 'Friends in Low Places', 'artist': 'Garth Brooks'}, {'name': 'Copperhead Road', 'artist': 'Steve Earle'}, {'name': 'Bad to the Bone', 'artist': 'George Thorogood'} ] return { 'name': "Demo: Whiskey & Rebellion", 'mood': 'Demo', 'energy': 'Variable', 'tracks': tracks, 'track_count': len(tracks), 'is_demo': True } @st.cache_data(show_spinner=False) def generate_image_pollinations(prompt, width=300, height=300): """Generate image using Pollinations AI""" try: clean_prompt = prompt.replace(" ", "%20").replace(",", "%2C") url = f"https://image.pollinations.ai/prompt/{clean_prompt}?width={width}&height={height}&seed={random.randint(1, 10000)}" response = requests.get(url, timeout=20) if response.status_code == 200: image = Image.open(io.BytesIO(response.content)) return image return None except Exception as e: return None def generate_demo_artwork(track): """Generate demo artwork""" prompt = f"album cover for {track['name']} by {track['artist']}, music artwork, artistic" return generate_image_pollinations(prompt, 300, 300) # Main App def main(): st.markdown('

🎵 MoodTunes AI 🎨

', unsafe_allow_html=True) st.markdown('

Create playlists with advanced filtering and thematic lyrical analysis!

', unsafe_allow_html=True) # Authentication code (same as before) try: query_params = st.experimental_get_query_params() if 'code' in query_params and not st.session_state.spotify_token: auth_code = query_params['code'][0] st.info("🔄 Processing authentication...") token = exchange_code_for_token(auth_code) if token: st.session_state.spotify_token = token try: sp = spotipy.Spotify(auth=token) user_info = sp.current_user() st.session_state.user_info = { 'display_name': user_info.get('display_name', 'User'), 'id': user_info['id'] } st.success("✅ Successfully connected to Spotify!") st.experimental_set_query_params() time.sleep(2) st.rerun() except Exception as e: st.error(f"Failed to get user info: {e}") except Exception as e: pass if not st.session_state.spotify_token: # Authentication flow st.markdown("---") col1, col2, col3 = st.columns([1, 2, 1]) with col2: if st.session_state.auth_step == 'start': st.markdown("""

🎧 Connect Your Spotify

Create custom playlists with genre and decade filtering!

""", unsafe_allow_html=True) st.markdown("""

🔐 How to Connect:

  1. Click the button below to get your auth link
  2. Copy the link and open it in a new browser tab
  3. Login to Spotify and authorize the app
  4. Copy the final URL and paste it in the box below

Premium accounts get full song playback!

""", unsafe_allow_html=True) if st.button("🎵 Get Spotify Auth Link", type="primary"): st.session_state.auth_step = 'show_link' st.rerun() elif st.session_state.auth_step == 'show_link': auth_url = get_spotify_auth_url() st.markdown("""

🔗 Your Spotify Authorization Link

Copy this link and open it in a new browser tab:

""", unsafe_allow_html=True) st.code(auth_url, language=None) st.markdown("**After authorizing on Spotify, paste the final URL here:**") callback_url = st.text_input("Paste the callback URL:", placeholder="https://kilgorepennington-mood2music.hf.space/?code=...") if callback_url and 'code=' in callback_url: try: parsed_url = urllib.parse.urlparse(callback_url) query_params_dict = urllib.parse.parse_qs(parsed_url.query) if 'code' in query_params_dict: auth_code = query_params_dict['code'][0] token = exchange_code_for_token(auth_code) if token: st.session_state.spotify_token = token sp = spotipy.Spotify(auth=token) user_info = sp.current_user() st.session_state.user_info = { 'display_name': user_info.get('display_name', 'User'), 'id': user_info['id'] } st.success("✅ Successfully connected to Spotify!") st.rerun() else: st.error("Failed to get access token") except Exception as e: st.error(f"Error processing callback URL: {e}") if st.button("🔄 Get New Auth Link"): st.session_state.auth_step = 'start' st.rerun() # Demo mode st.markdown("---") st.markdown("### 🚀 Try Demo Mode") if st.button("🎵 Try Demo Mode", type="secondary"): st.session_state.spotify_token = "demo_mode" st.session_state.user_info = { 'display_name': 'Demo User', 'id': 'demo_user' } st.rerun() else: # Main app interface is_demo = st.session_state.spotify_token == "demo_mode" st.sidebar.markdown(f"### 🎧 Welcome, {st.session_state.user_info['display_name']}!") if is_demo: st.sidebar.warning("🚧 Demo Mode") else: st.sidebar.success("✅ Spotify Connected") st.sidebar.info("💎 **Premium accounts**: Full song playback available!") if st.sidebar.button("🔄 Disconnect"): for key in ['spotify_token', 'user_info', 'generated_playlist', 'auth_step']: if key in st.session_state: del st.session_state[key] st.rerun() col1, col2 = st.columns([1, 1]) with col1: st.markdown("### 📝 1. Describe Your Mood") day_description = st.text_area( "Describe your feelings, situation, or vibe:", placeholder="e.g., 'I am in a whiskey drinking mood and want to start a bar fight and howl at the moon in a drunken rage'", height=120 ) # Genre Selection st.markdown("### 🎸 2. Select Genre(s)") genres = [ 'All Genres', 'Rock', 'Country', 'Hip-Hop', 'Pop', 'Blues', 'Jazz', 'Electronic', 'Folk', 'Metal', 'Punk', 'R&B', 'Reggae', 'Classical', 'Alternative', 'Indie', 'Soul', 'Funk', 'Disco', 'Latin' ] selected_genres = st.multiselect( "Choose one or more genres:", genres, default=['All Genres'] ) if not selected_genres: selected_genres = ['All Genres'] # Decade Selection st.markdown("### 📅 3. Select Era") decades = ['Any Year', '2020s', '2010s', '2000s', '1990s', '1980s', '1970s', '1960s', '1950s'] selected_decade = st.selectbox( "Choose a decade:", decades ) # Quick mood examples st.markdown("### 💡 Quick Examples") mood_cols = st.columns(2) quick_moods = { "🥃 Bar Fight": "I am in a whiskey drinking mood and want to start a bar fight and howl at the moon in a drunken rage", "💔 Heartbreak": "she left me and I feel completely empty and broken inside", "🏋️ Workout": "need to get pumped up and feel unstoppable for this intense workout", "🌙 Midnight": "driving alone at night feeling nostalgic and contemplative" } for i, (emoji_mood, description) in enumerate(quick_moods.items()): with mood_cols[i % 2]: if st.button(emoji_mood, key=f"mood_{i}"): st.session_state.day_description = description st.rerun() if 'day_description' in st.session_state: day_description = st.session_state.day_description if st.button("🎵 Create My Playlist!", type="primary") and day_description: with st.spinner("🎭 Analyzing themes and filters..."): mood_analysis = analyze_mood(day_description) time.sleep(1) with st.spinner("🔍 Finding matching songs..."): if is_demo: playlist_data = create_demo_playlist(mood_analysis) else: try: spotify_client = get_spotify_client(st.session_state.spotify_token) playlist_data = create_advanced_playlist( spotify_client, day_description, selected_genres, selected_decade, st.session_state.user_info['id'] ) if not playlist_data: playlist_data = create_demo_playlist(mood_analysis) except Exception as e: st.error(f"Error: {e}") playlist_data = create_demo_playlist(mood_analysis) st.session_state.generated_playlist = playlist_data if 'day_description' in st.session_state: del st.session_state.day_description st.success("🎉 Playlist created!") st.rerun() with col2: if st.session_state.generated_playlist: playlist = st.session_state.generated_playlist method_text = "🧠 Advanced Thematic" if playlist.get('method') == 'advanced_thematic' else "🎵 Demo" st.markdown(f"""

🎧 {playlist['name']}

Method: {method_text}

Tracks: {playlist['track_count']} songs

{"

🚧 Demo Mode

" if playlist.get('is_demo') else "

✅ Saved to Spotify!

"}
""", unsafe_allow_html=True) if not playlist.get('is_demo') and 'playlist_url' in playlist: st.markdown(f"[🎵 **Open in Spotify**]({playlist['playlist_url']})") # Display first 5 tracks for i, track in enumerate(playlist['tracks'][:5]): st.markdown(f"""
{track['name']}
by {track['artist']}
""", unsafe_allow_html=True) if i == 0: with st.spinner("🎨 Generating artwork..."): artwork = generate_demo_artwork(track) if artwork: st.image(artwork, caption=f"AI Art: {track['name']}", width=300) # Player controls current_track = playlist['tracks'][st.session_state.current_track_index] st.markdown(f"""

🎵 Now Playing

{current_track['name']} by {current_track['artist']}

Track {st.session_state.current_track_index + 1} of {len(playlist['tracks'])}

""", unsafe_allow_html=True) # Player buttons col_prev, col_play, col_next, col_shuffle = st.columns([1, 1, 1, 1]) with col_prev: if st.button("⏮️", help="Previous"): if st.session_state.current_track_index > 0: st.session_state.current_track_index -= 1 else: st.session_state.current_track_index = len(playlist['tracks']) - 1 st.rerun() with col_play: play_button = "⏸️" if st.session_state.is_playing else "▶️" if st.button(play_button, help="Play/Pause"): st.session_state.is_playing = not st.session_state.is_playing st.rerun() with col_next: if st.button("⏭️", help="Next"): if st.session_state.current_track_index < len(playlist['tracks']) - 1: st.session_state.current_track_index += 1 else: st.session_state.current_track_index = 0 st.rerun() with col_shuffle: if st.button("🔀", help="Shuffle"): st.session_state.current_track_index = random.randint(0, len(playlist['tracks']) - 1) st.rerun() # Spotify Web Player if not playlist.get('is_demo') and 'playlist_id' in playlist: st.markdown("### 🎧 Spotify Web Player") if not is_demo: st.info("💎 **Premium users get full playback!** Free users hear 30-second previews.") playlist_embed_url = f"https://open.spotify.com/embed/playlist/{playlist['playlist_id']}?utm_source=generator" st.components.v1.iframe( playlist_embed_url, width=350, height=380, scrolling=False ) # Full playlist display st.markdown("---") st.markdown("### 📝 Complete Playlist") for i, track in enumerate(playlist['tracks']): col_track, col_play_btn = st.columns([4, 1]) with col_track: if i == st.session_state.current_track_index: st.markdown(f"🎵 **{i+1}. {track['name']}** by *{track['artist']}*") else: st.markdown(f"{i+1}. **{track['name']}** by *{track['artist']}*") with col_play_btn: if st.button("▶️", key=f"play_{i}", help=f"Play {track['name']}"): st.session_state.current_track_index = i st.session_state.is_playing = True st.rerun() if __name__ == "__main__": main() # Sidebar info st.sidebar.markdown("---") st.sidebar.markdown("### 🚀 About MoodTunes AI") st.sidebar.info(""" 🎯 **Advanced Features:** - Custom mood analysis - Genre filtering - Decade selection - Thematic lyrical matching - Smart playlist naming 🧠 **AI Analysis:** - Multi-dimensional themes - Cross-genre compatibility - Lyrical sentiment analysis - Premium Spotify integration 🎵 **Perfect playlists, your way!** """) st.sidebar.markdown("### 📊 Stats") st.sidebar.metric("🎭 Themes Detected", f"{random.randint(500, 1000):,}") st.sidebar.metric("🎸 Genres Supported", "20+") st.sidebar.metric("📅 Decades Covered", "8")