from collections import Counter import os import sqlite3 import sys import warnings import aiohttp import discord from discord.ext import commands from dotenv import load_dotenv import spotipy from spotipy.oauth2 import SpotifyOAuth from urllib.parse import parse_qs, urlparse load_dotenv() REQUIRED_ENV = [ "SPOTIFY_CLIENT_ID", "SPOTIFY_CLIENT_SECRET", "SPOTIFY_REDIRECT_URI", "DISCORD_BOT_TOKEN", "DISCORD_APP_ID", ] for var in REQUIRED_ENV: if not os.getenv(var): print(f"❌ Error: Missing required environment variable '{var}' in .env file.") sys.exit(1) SPOTIFY_CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID") SPOTIFY_CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET") SPOTIFY_REDIRECT_URI = os.getenv("SPOTIFY_REDIRECT_URI", "http://localhost:8888/callback") DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN") DISCORD_APP_ID = os.getenv("DISCORD_APP_ID") DISCORD_IDENTITY_ID = os.getenv("DISCORD_IDENTITY_ID", "0") SCOPE = "user-read-private user-top-read user-read-currently-playing user-read-playback-state user-read-recently-played" # ============================================================================== # 2. DATABASE SETUP # ============================================================================== conn = sqlite3.connect("tokens.db", check_same_thread=False) cursor = conn.cursor() cursor.execute( """ CREATE TABLE IF NOT EXISTS user_tokens ( discord_id TEXT PRIMARY KEY, refresh_token TEXT NOT NULL, time_range TEXT NOT NULL DEFAULT '4w' ) """ ) # Migrate existing tables that don't yet have the time_range column try: cursor.execute("ALTER TABLE user_tokens ADD COLUMN time_range TEXT NOT NULL DEFAULT '4w'") except sqlite3.OperationalError: pass # Column already exists conn.commit() # ============================================================================== # 3. SPOTIFY API & AUTHENTICATION HELPERS # ============================================================================== def get_oauth() -> SpotifyOAuth: return SpotifyOAuth( client_id=SPOTIFY_CLIENT_ID, client_secret=SPOTIFY_CLIENT_SECRET, redirect_uri=SPOTIFY_REDIRECT_URI, scope=SCOPE, ) def get_spotify_client(discord_id: str) -> spotipy.Spotify | None: """Retrieves user's refresh token from SQLite and returns an authenticated Spotify client.""" cursor.execute( "SELECT refresh_token FROM user_tokens WHERE discord_id = ?", (str(discord_id),), ) row = cursor.fetchone() if not row: return None try: sp_oauth = get_oauth() token_info = sp_oauth.refresh_access_token(row[0]) return spotipy.Spotify(auth=token_info["access_token"]) except Exception as e: print(f"Error refreshing token for user {discord_id}: {e}") return None def get_user_time_range(discord_id: str) -> str: """Returns the stored time range string for a user, defaulting to '4w'.""" cursor.execute( "SELECT time_range FROM user_tokens WHERE discord_id = ?", (str(discord_id),), ) row = cursor.fetchone() return row[0] if row else "4w" def parse_time_range(range_str: str) -> tuple[str, str, str]: """Parses a time range string (e.g. '4w', '30d', '6m', '1y') into a (spotify_time_range, human_label, tier_note) tuple. Spotify only supports three fixed ranges: short_term ≈ last 4 weeks medium_term ≈ last 6 months long_term ≈ several years (Spotify's maximum) Mapping: d (days): 1–28 → short_term, 29–180 → medium_term, >180 → long_term w (weeks): 1–4 → short_term, 5–26 → medium_term, >26 → long_term m (months): 1 → short_term, 2–6 → medium_term, >6 → long_term y (years): any → long_term """ range_str = range_str.strip().lower() if not range_str: raise ValueError("Empty range string.") unit = range_str[-1] if unit not in ("d", "w", "m", "y"): raise ValueError(f"Unknown unit '{unit}'. Use d (days), w (weeks), m (months), y (years).") try: value = int(range_str[:-1]) except ValueError: raise ValueError(f"Invalid number in range '{range_str}'.") if value <= 0: raise ValueError("Range value must be a positive integer.") # Human-readable label unit_names = {"d": "day", "w": "week", "m": "month", "y": "year"} unit_name = unit_names[unit] label = f"{value} {unit_name}{'s' if value != 1 else ''}" # Map to Spotify API time range if unit == "d": days = value elif unit == "w": days = value * 7 elif unit == "m": days = value * 30 else: # y days = value * 365 if days <= 28: spotify_range = "short_term" tier_note = "Spotify tier: **~last 4 weeks** (Spotify's shortest window)" elif days <= 180: spotify_range = "medium_term" tier_note = "Spotify tier: **~last 6 months**" else: spotify_range = "long_term" tier_note = "Spotify tier: **~last 1–2 years** (Spotify's maximum — going beyond 6 months always uses this cap)" return spotify_range, label, tier_note class SpotifyAPI: @staticmethod def get_profile_picture(sp: spotipy.Spotify, user_info: dict = None) -> str | None: try: user = user_info or sp.current_user() if not user or not isinstance(user, dict): print("[ProfilePic] No user_info returned from Spotify.") return None images = user.get("images") print(f"[ProfilePic] Private profile images: {images}") if isinstance(images, list) and len(images) > 0: first_image = images[0] if isinstance(first_image, dict): url = first_image.get("url") print(f"[ProfilePic] Resolved from private profile: {url}") return url # Fallback to public profile if images list is empty if "id" in user: print(f"[ProfilePic] Private images empty, trying public profile for id={user['id']}") public_user = sp.user(user["id"]) public_images = public_user.get("images") print(f"[ProfilePic] Public profile images: {public_images}") if isinstance(public_images, list) and len(public_images) > 0: first_public_image = public_images[0] if isinstance(first_public_image, dict): url = first_public_image.get("url") print(f"[ProfilePic] Resolved from public profile: {url}") return url print("[ProfilePic] No profile picture found in either private or public profile.") except Exception as e: print(f"[ProfilePic] Error fetching Spotify profile picture: {e}") return None @staticmethod def get_top_track(sp: spotipy.Spotify, time_range="short_term") -> dict | None: res = sp.current_user_top_tracks(limit=1, time_range=time_range) items = res.get("items", []) if not items: return None track = items[0] return { "title": track["name"], "artist": track["artists"][0]["name"], "album_art": track["album"]["images"][0]["url"] if track["album"]["images"] else None, "url": track["external_urls"]["spotify"], } @staticmethod def get_top_artist(sp: spotipy.Spotify, time_range="short_term") -> dict | None: res = sp.current_user_top_artists(limit=1, time_range=time_range) items = res.get("items", []) if not items: return None artist = items[0] return { "name": artist["name"], "image_url": artist["images"][0]["url"] if artist["images"] else None, "url": artist["external_urls"]["spotify"], } @staticmethod def get_top_album(sp: spotipy.Spotify, time_range="short_term") -> dict | None: res = sp.current_user_top_tracks(limit=50, time_range=time_range) tracks = res.get("items", []) if not tracks: return None album_counts = Counter(t["album"]["id"] for t in tracks) top_album_id = album_counts.most_common(1)[0][0] for t in tracks: if t["album"]["id"] == top_album_id: album = t["album"] return { "title": album["name"], "artist": album["artists"][0]["name"], "cover_art": album["images"][0]["url"] if album["images"] else None, "url": album["external_urls"]["spotify"], } return None @staticmethod def get_currently_playing(sp: spotipy.Spotify) -> dict: playback = sp.current_playback() if playback and playback.get("is_playing") and playback.get("item"): item = playback["item"] return { "status": "playing", "is_playing": True, "title": item["name"], "artist": item["artists"][0]["name"], "album_art": item["album"]["images"][0]["url"] if item["album"]["images"] else None, "url": item["external_urls"]["spotify"], } recently_played = sp.current_user_recently_played(limit=1) items = recently_played.get("items", []) if items: track = items[0]["track"] return { "status": "recently_played", "is_playing": False, "title": track["name"], "artist": track["artists"][0]["name"], "album_art": track["album"]["images"][0]["url"] if track["album"]["images"] else None, "url": track["external_urls"]["spotify"], } return { "status": "offline", "is_playing": False, "title": "No listening history", "artist": "N/A", "album_art": None, "url": None, } @staticmethod def get_last_liked_track(sp: spotipy.Spotify) -> dict | None: res = sp.current_user_saved_tracks(limit=1) items = res.get("items", []) if not items: return None track = items[0].get("track") if not track: return None return { "title": track["name"], "artist": track["artists"][0]["name"], # Renamed key to 'album_art' to match payload builder expectations "album_art": track["album"]["images"][0]["url"] if track["album"].get("images") else None, "url": track["external_urls"]["spotify"], } # ============================================================================== # 4. DISCORD PAYLOAD BUILDER & DISCORD IDENTITY API UPDATER # ============================================================================== def build_discord_profile_payload( spotify_username: str, spotify_profile_pic: str | None, top_song: dict | None, top_album: dict | None, top_artist: dict | None, current_track: dict, time_range_label: str = "4 weeks", ) -> dict: DEFAULT_IMAGE = "https://cdn.discordapp.com/embed/avatars/0.png" status_label = ( "Currently Playing" if current_track.get("is_playing") else "Last Played" ) return { "data": { "dynamic": [ { "type": 3, "name": "spotify_profile_picture", "value": {"url": spotify_profile_pic or DEFAULT_IMAGE}, }, { "type": 1, "name": "spotfy_user_name", "value": spotify_username or "Unknown User", }, { "type": 3, "name": "most_listened_song_picture", "value": { "url": top_song.get("album_art") if top_song else DEFAULT_IMAGE }, }, { "type": 1, "name": "most_listened_song_name", "value": top_song.get("title") if top_song else "None", }, { "type": 3, "name": "most_listened_album_picture", "value": { "url": top_album.get("cover_art") if top_album else DEFAULT_IMAGE }, }, { "type": 1, "name": "most_listened_album_name", "value": top_album.get("title") if top_album else "None", }, { "type": 3, "name": "most_listened_artist_picture", "value": { "url": top_artist.get("image_url") if top_artist else DEFAULT_IMAGE }, }, { "type": 1, "name": "most_listened_artist_name", "value": top_artist.get("name") if top_artist else "None", }, { "type": 3, "name": "current_song_picture", "value": { "url": (current_track.get("album_art") if current_track else None) or DEFAULT_IMAGE }, }, { "type": 1, "name": "current_song_name", "value": current_track.get("title", "None"), }, {"type": 1, "name": "status", "value": "recently liked"}, {"type": 1, "name": "range", "value": time_range_label}, ] } } async def update_discord_widget(discord_user_id: str, payload: dict) -> bool: url = f"https://discord.com/api/v9/applications/{DISCORD_APP_ID}/users/{discord_user_id}/identities/0/profile" headers = { "Content-Type": "application/json", "Authorization": f"Bot {DISCORD_BOT_TOKEN}", "User-Agent": "DiscordBot (https://github.com/discord/discord-api-docs, 1.0.0)", } async with aiohttp.ClientSession() as session: async with session.patch(url, headers=headers, json=payload) as resp: # Discord returns 204 No Content on success for this endpoint if resp.status in (200, 204): print(f"Successfully updated widget for user: {discord_user_id} (HTTP {resp.status})") return True else: response_text = await resp.text() print(f"Failed to update ({resp.status}): {response_text}") return False async def process_user_widget_update(discord_user_id: str) -> bool: """Helper function to orchestrate fetching Spotify stats and pushing to Discord.""" sp = get_spotify_client(discord_user_id) if not sp: return False raw_range = get_user_time_range(discord_user_id) try: spotify_range, range_label, _ = parse_time_range(raw_range) except ValueError: spotify_range, range_label = "short_term", "4 weeks" user_info = sp.current_user() spotify_username = user_info.get("display_name") or user_info.get("id") if user_info else None profile_pic = SpotifyAPI.get_profile_picture(sp, user_info) top_song = SpotifyAPI.get_top_track(sp, time_range=spotify_range) top_album = SpotifyAPI.get_top_album(sp, time_range=spotify_range) top_artist = SpotifyAPI.get_top_artist(sp, time_range=spotify_range) last_liked = SpotifyAPI.get_last_liked_track(sp) payload = build_discord_profile_payload( spotify_username=spotify_username, spotify_profile_pic=profile_pic, top_song=top_song, top_album=top_album, top_artist=top_artist, current_track=last_liked, time_range_label=range_label, ) return await update_discord_widget(discord_user_id, payload) # ============================================================================== # 5. DISCORD BOT COMMANDS SETUP # ============================================================================== bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) @bot.event async def on_ready(): await bot.tree.sync() print(f"Logged in as {bot.user} (ID: {bot.user.id})") @bot.tree.command( name="spotify_login", description="Get link to connect your Spotify account", ) async def spotify_login(interaction: discord.Interaction): sp_oauth = get_oauth() auth_url = sp_oauth.get_authorize_url() msg = ( f"1️⃣ [Click here to log into Spotify]({auth_url})\n" f"2️⃣ After logging in, you'll reach a page that fails to load (e.g. `localhost`).\n" f"3️⃣ Copy the long **`code`** parameter from the URL bar (e.g. `?code=ABC123...`).\n" f"4️⃣ Run `/submit_code code:` to link your account!" ) await interaction.response.send_message(msg, ephemeral=True) def extract_code(input_text: str) -> str: """Extracts the 'code' parameter from a full Spotify callback URL, query string, or raw input.""" input_text = input_text.strip() if "code=" in input_text: parsed_url = urlparse(input_text) query_params = parse_qs(parsed_url.query or parsed_url.path) if "code" in query_params: return query_params["code"][0] try: return input_text.split("code=")[1].split("&")[0] except IndexingError: pass return input_text @bot.tree.command( name="submit_code", description="Submit your Spotify auth code or full callback URL", ) async def submit_code(interaction: discord.Interaction, link_or_code: str): await interaction.response.defer(ephemeral=True) # Automatically extract the actual code string clean_code = extract_code(link_or_code) sp_oauth = get_oauth() try: with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) token_info = sp_oauth.get_access_token(clean_code, check_cache=False) refresh_token = token_info["refresh_token"] discord_id = str(interaction.user.id) cursor.execute( """ INSERT INTO user_tokens (discord_id, refresh_token) VALUES (?, ?) ON CONFLICT(discord_id) DO UPDATE SET refresh_token=excluded.refresh_token """, (discord_id, refresh_token), ) conn.commit() # Update widget immediately upon linking success = await process_user_widget_update(discord_id) if success: await interaction.followup.send( "✅ Spotify account linked and widget updated successfully!", ephemeral=True, ) else: await interaction.followup.send( "✅ Account linked, but failed to push initial widget update to Discord.", ephemeral=True, ) except Exception as e: await interaction.followup.send( f"❌ Invalid link/code or error linking account: {e}", ephemeral=True ) @bot.tree.command(name="update_widget", description="Force update your Spotify widget payload") async def update_widget(interaction: discord.Interaction): await interaction.response.defer(ephemeral=True) discord_id = str(interaction.user.id) sp = get_spotify_client(discord_id) if not sp: await interaction.followup.send( "❌ You haven't linked your Spotify account yet! Run `/spotify_login` first.", ephemeral=True, ) return success = await process_user_widget_update(discord_id) if success: await interaction.followup.send("✅ Widget successfully updated!", ephemeral=True) else: await interaction.followup.send( "❌ Failed to update widget identity via Discord API.", ephemeral=True ) @bot.tree.command( name="range", description="Set the time range for your Spotify stats. Format: 7d, 4w, 6m, 1y", ) async def set_range(interaction: discord.Interaction, range: str): await interaction.response.defer(ephemeral=True) discord_id = str(interaction.user.id) # Check the user is linked cursor.execute("SELECT refresh_token FROM user_tokens WHERE discord_id = ?", (discord_id,)) if not cursor.fetchone(): await interaction.followup.send( "❌ You haven't linked your Spotify account yet! Run `/spotify_login` first.", ephemeral=True, ) return # Validate the range string try: _, label, tier_note = parse_time_range(range) except ValueError as e: await interaction.followup.send( f"❌ Invalid range `{range}`: {e}\n" "Use a positive number followed by a unit:\n" "> `d` — days · `w` — weeks · `m` — months · `y` — years\n" "**Examples:** `7d` · `4w` · `3m` · `1y`", ephemeral=True, ) return # Save to DB cursor.execute( "UPDATE user_tokens SET time_range = ? WHERE discord_id = ?", (range.strip().lower(), discord_id), ) conn.commit() # Immediately refresh the widget with the new range success = await process_user_widget_update(discord_id) note = ( "\n\nℹ️ **Note:** Spotify only supports 3 fixed time windows, so your input is mapped to the closest one:\n" "> `≤4 weeks` → ~last 4 weeks\n" "> `>4 weeks up to 6 months` → ~last 6 months\n" "> `>6 months` → ~last 1–2 years *(Spotify's maximum — 1y and 2y will show identical results)*" ) if success: await interaction.followup.send( f"✅ Time range set to **{label}** and widget updated!\n{tier_note}{note}", ephemeral=True, ) else: await interaction.followup.send( f"✅ Time range set to **{label}**, but failed to push widget update to Discord.\n{tier_note}{note}", ephemeral=True, ) if __name__ == "__main__": bot.run(DISCORD_BOT_TOKEN)