aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--bot.py205
1 files changed, 190 insertions, 15 deletions
diff --git a/bot.py b/bot.py
index b6aa67c..4f53d02 100644
--- a/bot.py
+++ b/bot.py
@@ -1,7 +1,8 @@
-from collections import Counter
+from collections import Counter
import os
import sqlite3
import sys
+import warnings
import aiohttp
import discord
@@ -43,10 +44,16 @@ cursor.execute(
"""
CREATE TABLE IF NOT EXISTS user_tokens (
discord_id TEXT PRIMARY KEY,
- refresh_token TEXT NOT NULL
+ 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()
# ==============================================================================
@@ -81,13 +88,112 @@ def get_spotify_client(discord_id: str) -> spotipy.Spotify | None:
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) -> str | None:
- user = sp.current_user()
- images = user.get("images", [])
- return images[0]["url"] if images else None
+ 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:
@@ -301,8 +407,9 @@ async def update_discord_widget(discord_user_id: str, payload: dict) -> bool:
async with aiohttp.ClientSession() as session:
async with session.patch(url, headers=headers, json=payload) as resp:
- if resp.status == 200:
- print(f"Successfully updated widget for user: {discord_user_id}")
+ # 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()
@@ -316,12 +423,18 @@ async def process_user_widget_update(discord_user_id: str) -> bool:
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")
- profile_pic = SpotifyAPI.get_profile_picture(sp)
- top_song = SpotifyAPI.get_top_track(sp, time_range="short_term")
- top_album = SpotifyAPI.get_top_album(sp, time_range="short_term")
- top_artist = SpotifyAPI.get_top_artist(sp, time_range="short_term")
+ 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(
@@ -331,7 +444,7 @@ async def process_user_widget_update(discord_user_id: str) -> bool:
top_album=top_album,
top_artist=top_artist,
current_track=last_liked,
- time_range_label="4 weeks",
+ time_range_label=range_label,
)
return await update_discord_widget(discord_user_id, payload)
@@ -397,7 +510,9 @@ async def submit_code(interaction: discord.Interaction, link_or_code: str):
sp_oauth = get_oauth()
try:
- token_info = sp_oauth.get_access_token(clean_code, check_cache=False)
+ 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)
@@ -451,5 +566,65 @@ async def update_widget(interaction: discord.Interaction):
"❌ Failed to update widget identity via Discord API.", ephemeral=True
)
+
+ 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) \ No newline at end of file