Files
FunguyBot/plugins/lastfm.py
T

1251 lines
58 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Comprehensive Last.fm plugin for FunguyBot code-block output for tabular commands.
Artist extraction now correctly handles both string and {name} dict formats.
"""
import logging, os, time, subprocess, tempfile, asyncio, aiohttp, aiosqlite
import simplematrixbotlib as botlib
from datetime import datetime, timedelta
from plugins.common import collapsible_summary, html_escape, code_block
# ---------- constants ----------
DB_PATH = "lastfm.db"
API_BASE = "http://ws.audioscrobbler.com/2.0/"
VALID_PERIODS = ["overall", "7day", "1month", "3month", "6month", "12month"]
PERIOD_LABELS = {
"overall": "All Time", "7day": "Last 7 Days", "1month": "Last Month",
"3month": "Last 3 Months", "6month": "Last 6 Months", "12month": "Last Year",
}
HEADERS = {"User-Agent": "FunguyBot/1.0 (Matrix last.fm plugin)"}
# ---------- database ----------
async def init_db():
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"CREATE TABLE IF NOT EXISTS user_lastfm (matrix_user TEXT PRIMARY KEY, lastfm_user TEXT NOT NULL)"
)
await db.commit()
async def get_lastfm_username(matrix_user):
async with aiosqlite.connect(DB_PATH) as db:
async with db.execute("SELECT lastfm_user FROM user_lastfm WHERE matrix_user=?", (matrix_user,)) as cur:
row = await cur.fetchone()
return row[0] if row else None
async def set_lastfm_username(matrix_user, lastfm_user):
async with aiosqlite.connect(DB_PATH) as db:
cur = await db.execute("SELECT lastfm_user FROM user_lastfm WHERE matrix_user=?", (matrix_user,))
row = await cur.fetchone()
if row:
await db.execute("UPDATE user_lastfm SET lastfm_user=? WHERE matrix_user=?", (lastfm_user, matrix_user))
else:
await db.execute("INSERT INTO user_lastfm (matrix_user, lastfm_user) VALUES (?,?)", (matrix_user, lastfm_user))
await db.commit()
async def get_all_registered_users():
async with aiosqlite.connect(DB_PATH) as db:
async with db.execute("SELECT matrix_user, lastfm_user FROM user_lastfm") as cur:
return {row[0]: row[1] for row in await cur.fetchall()}
async def resolve_username(matrix_user, args, bot, room):
if args:
return args[0].strip(), args[0].strip()
user = await get_lastfm_username(matrix_user)
if not user:
if bot and room:
await bot.api.send_text_message(room.room_id,
"Please register your Last.fm username first with !register <username>\nOr specify a username: !command <username>")
return None, None
return user, matrix_user
# ---------- API helper ----------
def get_api_key():
return os.getenv("LASTFM_API_KEY")
async def call_lastfm_api(method, params, bot=None, room=None):
api_key = get_api_key()
if not api_key:
if bot and room:
await bot.api.send_text_message(room.room_id, "❌ Last.fm API key not configured. Set LASTFM_API_KEY.")
return None
full_params = {"method": method, "api_key": api_key, "format": "json", **params}
try:
async with aiohttp.ClientSession(headers=HEADERS) as session:
async with session.get(API_BASE, params=full_params, timeout=15) as resp:
if resp.status == 200:
data = await resp.json()
if "error" in data:
msg = data.get("message", "Unknown error")
logging.error(f"Last.fm API error ({method}): {msg}")
if bot and room:
await bot.api.send_text_message(room.room_id, f"❌ Last.fm error: {msg}")
return None
return data
logging.error(f"Last.fm API HTTP {resp.status} for {method}")
if bot and room:
await bot.api.send_text_message(room.room_id, f"❌ Last.fm API error: HTTP {resp.status}")
return None
except aiohttp.ClientError as e:
logging.error(f"HTTP error calling Last.fm API ({method}): {e}")
if bot and room:
await bot.api.send_text_message(room.room_id, f"❌ Network error contacting Last.fm: {e}")
return None
except Exception as e:
logging.error(f"Error calling Last.fm API ({method}): {e}")
if bot and room:
await bot.api.send_text_message(room.room_id, f"❌ Error: {e}")
return None
async def get_youtube_link(artist, track_name):
yt_key = os.getenv("YOUTUBE_API_KEY")
if not yt_key: return None
try:
async with aiohttp.ClientSession() as session:
async with session.get("https://www.googleapis.com/youtube/v3/search", params={
"part": "snippet", "q": f"{artist} {track_name}", "type": "video",
"key": yt_key, "maxResults": "1"
}) as resp:
if resp.status == 200:
data = await resp.json()
items = data.get("items", [])
if items:
vid = items[0].get("id", {}).get("videoId")
if vid:
return f"https://www.youtube.com/watch?v={vid}"
except Exception as e:
logging.error(f"YouTube search error: {e}")
return None
# ---------- safe extraction ----------
def safe_text(obj, key, default="Unknown"):
if isinstance(obj, dict):
val = obj.get(key, {})
if isinstance(val, dict):
return val.get("#text", default)
if isinstance(val, str):
return val
return default
def safe_int(obj, key, default=0):
try:
val = safe_text(obj, key, str(default))
return int(val)
except (ValueError, TypeError):
return default
def _artist_name(track_or_artist_obj):
"""Extract artist name from a track object (or a direct artist object).
Handles both string artist and {name}/{#text} dict formats.
"""
artist = track_or_artist_obj.get("artist") if isinstance(track_or_artist_obj, dict) else track_or_artist_obj
if isinstance(artist, str):
return artist
if isinstance(artist, dict):
# try 'name' first (for lists), then '#text' (for recent tracks), then fallback
return artist.get("name") or artist.get("#text") or "Unknown"
return "Unknown"
# ---------- code-block output helper ----------
def _output(title, rows):
sections = [{"title": "", "rows": rows}]
block = code_block(title, sections)
return collapsible_summary(title, block)
# ---------- collage helpers (unchanged) ----------
def album_artist_name(album):
artist = album.get("artist", {})
if isinstance(artist, str): return artist
if isinstance(artist, dict):
return artist.get("name", artist.get("#text", "Unknown"))
return "Unknown"
async def download_album_art_to_file(session, album_data):
album_name = safe_text(album_data, "name", "Unknown Album")
artist = album_artist_name(album_data)
# direct url
direct = None
for img in album_data.get("image", []):
if img.get("size") == "extralarge": direct = img.get("#text"); break
if not direct:
for img in album_data.get("image", []):
direct = img.get("#text")
if direct: break
if direct:
try:
async with session.get(direct, timeout=15) as resp:
if resp.status == 200:
content = await resp.read()
if len(content) >= 500:
fd, tmp = tempfile.mkstemp(suffix=".jpg")
os.close(fd)
with open(tmp, "wb") as f: f.write(content)
return (artist, album_name, tmp)
except Exception: pass
# fallback album.getInfo
if artist != "Unknown":
try:
params = {"method": "album.getInfo", "artist": artist, "album": album_name,
"autocorrect": "1", "api_key": get_api_key(), "format": "json"}
async with session.get(API_BASE, params=params, timeout=10) as resp:
if resp.status == 200:
data = await resp.json()
album_info = data.get("album", {})
if album_info:
img_url = None
for img in album_info.get("image", []):
if img.get("size") == "extralarge": img_url = img.get("#text"); break
if not img_url:
for img in album_info.get("image", []):
img_url = img.get("#text")
if img_url: break
if img_url:
async with session.get(img_url, timeout=15) as ir:
if ir.status == 200:
content = await ir.read()
if len(content) >= 500:
fd, tmp = tempfile.mkstemp(suffix=".jpg")
os.close(fd)
with open(tmp, "wb") as f: f.write(content)
return (artist, album_name, tmp)
except Exception: pass
return (artist, album_name, None)
# ===================================================================
# COMMAND HANDLERS (all now use _artist_name)
# ===================================================================
async def cmd_register(room, message, bot, args):
if len(args) < 1:
await bot.api.send_text_message(room.room_id, "Usage: !register <lastfm_username>")
return
lastfm_user = args[0].strip()
matrix_user = str(message.sender)
await set_lastfm_username(matrix_user, lastfm_user)
await bot.api.send_text_message(room.room_id, f"✅ Registered Last.fm user **{lastfm_user}** for {matrix_user}")
# ---- !np ----
async def cmd_np(room, message, bot, args):
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
if not lastfm_user:
return
data = await call_lastfm_api("user.getRecentTracks", {"user": lastfm_user, "limit": "1"}, bot, room)
if not data:
return
tracks = data.get("recenttracks", {}).get("track", [])
if not tracks:
await bot.api.send_text_message(room.room_id, f"🔍 No recent tracks found for {lastfm_user}.")
return
track = tracks[0] if isinstance(tracks, list) else tracks
now_playing = track.get("@attr", {}).get("nowplaying", "false") == "true"
artist = _artist_name(track)
name = safe_text(track, "name")
album = safe_text(track, "album", "")
if now_playing:
prefix_icon = "🎵"
action = "is currently playing"
else:
prefix_icon = "🎵"
action = "last played"
if album:
message_text = f"{prefix_icon} **{display_name}** {action}: **{name}** by **{artist}** from *{album}*"
else:
message_text = f"{prefix_icon} **{display_name}** {action}: **{name}** by **{artist}**"
youtube_link = await get_youtube_link(artist, name)
if youtube_link:
message_text += f" | [▶️ YouTube]({youtube_link})"
# ---- Genre tags: track-level first, fall back to artist ----
genre_tags = []
# 1) Try track top tags
track_tag_data = await call_lastfm_api("track.getTopTags",
{"artist": artist, "track": name, "autocorrect": "1"})
if track_tag_data:
tags = track_tag_data.get("toptags", {}).get("tag", [])
genre_tags = [safe_text(t, "name") for t in tags if safe_text(t, "name")]
# 2) If empty, fall back to artist top tags
if not genre_tags:
artist_tag_data = await call_lastfm_api("artist.getTopTags",
{"artist": artist, "autocorrect": "1"})
if artist_tag_data:
tags = artist_tag_data.get("toptags", {}).get("tag", [])
genre_tags = [safe_text(t, "name") for t in tags if safe_text(t, "name")]
if genre_tags:
genre_str = " | 🏷️ " + ", ".join(genre_tags[:3])
message_text += genre_str
# ---- Track duration ----
track_info = await call_lastfm_api("track.getInfo", {
"artist": artist,
"track": name,
"autocorrect": "1"
})
if track_info:
track_obj = track_info.get("track", {})
duration_ms = safe_int(track_obj, "duration")
if duration_ms > 0:
mins = duration_ms // 60000
secs = (duration_ms % 60000) // 1000
message_text += f" | ⏱️ {mins}:{secs:02d}"
await bot.api.send_markdown_message(room.room_id, message_text)
logging.info(f"Sent now playing for {lastfm_user}")
# ---- !recent ----
async def cmd_recent(room, message, bot, args):
matrix_user = str(message.sender)
limit = 10
user_arg = list(args)
cleaned = []
i = 0
while i < len(args):
if args[i] == "--limit" and i+1 < len(args):
limit = min(int(args[i+1]), 50); i += 2
else:
cleaned.append(args[i]); i += 1
user_arg = cleaned
if user_arg and user_arg[-1].isdigit():
limit = min(int(user_arg[-1]), 50); user_arg.pop()
lastfm_user, display_name = await resolve_username(matrix_user, user_arg, bot, room)
if not lastfm_user: return
data = await call_lastfm_api("user.getRecentTracks", {"user": lastfm_user, "limit": str(limit)}, bot, room)
if not data: return
tracks = data.get("recenttracks", {}).get("track", [])
if not tracks:
await bot.api.send_text_message(room.room_id, f"🔍 No recent tracks for {lastfm_user}.")
return
total = int(data.get("recenttracks", {}).get("@attr", {}).get("total", "0"))
rows = []
for i, t in enumerate(tracks[:limit], 1):
artist = _artist_name(t)
name = safe_text(t, "name")
album = safe_text(t, "album", "")
now = "🔊 " if t.get("@attr", {}).get("nowplaying") == "true" else ""
date_str = ""
if "date" in t and "#text" in t["date"]:
date_str = t["date"]["#text"]
rows.append(("🎵", f"{now}{name}", f"{artist}{' | '+album if album else ''} {date_str}"))
output = _output(f"🎵 {display_name} — Recent Tracks ({min(limit, len(tracks))} of {total})", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !toptracks ----
async def cmd_toptracks(room, message, bot, args):
matrix_user = str(message.sender)
period = "overall"
user_arg = list(args)
if user_arg and user_arg[-1] in VALID_PERIODS:
period = user_arg.pop()
else:
cleaned = []; i = 0
while i < len(args):
if args[i] in VALID_PERIODS: period = args[i]; i += 1
else: cleaned.append(args[i]); i += 1
user_arg = cleaned
lastfm_user, display_name = await resolve_username(matrix_user, user_arg, bot, room)
if not lastfm_user: return
data = await call_lastfm_api("user.getTopTracks", {"user": lastfm_user, "period": period, "limit": "10"}, bot, room)
if not data: return
tracks = data.get("toptracks", {}).get("track", [])
if not tracks:
await bot.api.send_text_message(room.room_id, f"🔍 No top tracks for {lastfm_user}.")
return
period_label = PERIOD_LABELS.get(period, period)
rows = []
for i, t in enumerate(tracks[:10], 1):
artist = _artist_name(t)
name = safe_text(t, "name")
plays = safe_int(t, "playcount")
rows.append(("🎶", name, f"{artist}{plays} plays"))
output = _output(f"🏆 {display_name} — Top Tracks ({period_label})", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !topartists ----
async def cmd_topartists(room, message, bot, args):
matrix_user = str(message.sender)
period = "overall"
user_arg = list(args)
if user_arg and user_arg[-1] in VALID_PERIODS: period = user_arg.pop()
else:
cleaned = []; i = 0
while i < len(args):
if args[i] in VALID_PERIODS: period = args[i]; i += 1
else: cleaned.append(args[i]); i += 1
user_arg = cleaned
lastfm_user, display_name = await resolve_username(matrix_user, user_arg, bot, room)
if not lastfm_user: return
data = await call_lastfm_api("user.getTopArtists", {"user": lastfm_user, "period": period, "limit": "10"}, bot, room)
if not data: return
artists = data.get("topartists", {}).get("artist", [])
if not artists:
await bot.api.send_text_message(room.room_id, f"🔍 No top artists for {lastfm_user}.")
return
period_label = PERIOD_LABELS.get(period, period)
rows = []
for i, a in enumerate(artists[:10], 1):
# artist object directly
name = a.get("name", _artist_name(a))
plays = safe_int(a, "playcount")
rows.append(("🎤", name, f"{plays} plays"))
output = _output(f"🎤 {display_name} — Top Artists ({period_label})", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !topalbums ----
async def cmd_topalbums(room, message, bot, args):
matrix_user = str(message.sender)
period = "overall"
user_arg = list(args)
if user_arg and user_arg[-1] in VALID_PERIODS: period = user_arg.pop()
else:
cleaned = []; i = 0
while i < len(args):
if args[i] in VALID_PERIODS: period = args[i]; i += 1
else: cleaned.append(args[i]); i += 1
user_arg = cleaned
lastfm_user, display_name = await resolve_username(matrix_user, user_arg, bot, room)
if not lastfm_user: return
data = await call_lastfm_api("user.getTopAlbums", {"user": lastfm_user, "period": period, "limit": "10"}, bot, room)
if not data: return
albums = data.get("topalbums", {}).get("album", [])
if not albums:
await bot.api.send_text_message(room.room_id, f"🔍 No top albums for {lastfm_user}.")
return
period_label = PERIOD_LABELS.get(period, period)
rows = []
for i, alb in enumerate(albums[:10], 1):
artist = album_artist_name(alb)
name = safe_text(alb, "name")
plays = safe_int(alb, "playcount")
rows.append(("💿", name, f"{artist}{plays} plays"))
output = _output(f"💿 {display_name} — Top Albums ({period_label})", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !loved ----
async def cmd_loved(room, message, bot, args):
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
if not lastfm_user: return
data = await call_lastfm_api("user.getLovedTracks", {"user": lastfm_user, "limit": "10"}, bot, room)
if not data: return
tracks = data.get("lovedtracks", {}).get("track", [])
if not tracks:
await bot.api.send_text_message(room.room_id, f"💔 No loved tracks for {lastfm_user}.")
return
total = int(data.get("lovedtracks", {}).get("@attr", {}).get("total", "0"))
rows = []
for t in tracks[:10]:
artist = _artist_name(t)
name = safe_text(t, "name")
date_str = ""
if "date" in t and "#text" in t["date"]:
date_str = f"{t['date']['#text']}"
rows.append(("❤️", f"{name}", f"{artist}{date_str}"))
output = _output(f"❤️ {display_name} — Loved Tracks ({len(tracks)} of {total})", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !profile ----
async def cmd_profile(room, message, bot, args):
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
if not lastfm_user: return
data = await call_lastfm_api("user.getInfo", {"user": lastfm_user}, bot, room)
if not data: return
ui = data.get("user", {})
if not ui:
await bot.api.send_text_message(room.room_id, f"🔍 User {lastfm_user} not found.")
return
rows = [
("👤", "Last.fm", lastfm_user),
("📃", "Real Name", ui.get("realname", "")),
("🌍", "Country", ui.get("country", "Unknown")),
("📅", "Registered", ui.get("registered", {}).get("#text", "Unknown")),
("🎵", "Total Plays", f"{safe_int(ui, 'playcount'):,}"),
("📋", "Playlists", str(safe_int(ui, "playlists"))),
("", "Subscriber", "" if ui.get("subscriber", "0") == "1" else ""),
]
rows = [r for r in rows if r[1]]
output = _output(f"👤 Profile: {display_name}", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !playcount (unchanged) ----
async def cmd_playcount(room, message, bot, args):
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
if not lastfm_user: return
data = await call_lastfm_api("user.getInfo", {"user": lastfm_user}, bot, room)
if not data: return
pc = safe_int(data.get("user", {}), "playcount")
await bot.api.send_markdown_message(room.room_id, f"🔢 **{display_name}** has scrobbled **{pc:,}** tracks total.")
# ---- !scrobbles ----
async def cmd_scrobbles(room, message, bot, args):
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
if not lastfm_user: return
info = await call_lastfm_api("user.getInfo", {"user": lastfm_user}, bot, room)
if not info: return
ui = info.get("user", {})
playcount = safe_int(ui, "playcount")
registered = ui.get("registered", {}).get("#text", "Unknown")
artist_count = safe_int(ui, "artist_count", 0)
recent = await call_lastfm_api("user.getRecentTracks", {"user": lastfm_user, "limit": "200"}, bot, room)
today = 0
if recent:
tracks = recent.get("recenttracks", {}).get("track", [])
today_str = datetime.utcnow().strftime("%d %b %Y")
for t in tracks:
if "date" in t and "#text" in t["date"] and today_str in t["date"]["#text"]:
today += 1
try:
reg_date = datetime.strptime(registered, "%d %b %Y")
days_since = max((datetime.utcnow() - reg_date).days, 1)
avg = round(playcount / days_since, 1)
except: avg = "?"
rows = [
("🎵", "Total Scrobbles", f"{playcount:,}"),
("🎤", "Unique Artists", f"{artist_count:,}") if artist_count else None,
("📅", "Registered", registered),
("📊", "Avg Scrobbles/Day", str(avg)),
("📅", "Today's Scrobbles", str(today)),
]
rows = [r for r in rows if r]
output = _output(f"📊 {display_name} — Scrobbling Stats", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !compare ----
async def cmd_compare(room, message, bot, args):
if len(args) < 2:
await bot.api.send_text_message(room.room_id, "Usage: !compare <user1> <user2>")
return
u1, u2 = args[0].strip(), args[1].strip()
d1 = await call_lastfm_api("user.getTopArtists", {"user": u1, "period": "overall", "limit": "50"}, bot, room)
d2 = await call_lastfm_api("user.getTopArtists", {"user": u2, "period": "overall", "limit": "50"}, bot, room)
if not d1 or not d2: return
a1 = {a.get("name", _artist_name(a)).lower(): safe_int(a, "playcount") for a in d1.get("topartists", {}).get("artist", [])}
a2 = {a.get("name", _artist_name(a)).lower(): safe_int(a, "playcount") for a in d2.get("topartists", {}).get("artist", [])}
s1, s2 = set(a1.keys()), set(a2.keys())
common = s1 & s2
similarity = round(len(common) / max(len(s1|s2),1)*100, 1) if (s1|s2) else 0
rows = [
("🔄", "Taste Similarity", f"{similarity}%"),
("🎶", "Common Artists", str(len(common))),
("👤", f"Only {u1}", str(len(s1 - s2))),
("👤", f"Only {u2}", str(len(s2 - s1))),
]
if common:
top_shared = sorted(common, key=lambda x: a1[x]+a2.get(x,0), reverse=True)[:5]
rows.append(("🏆", "Top Shared", ", ".join(top_shared)))
output = _output(f"🔄 Musical Taste Comparison: {u1} vs {u2}", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !taste ----
async def cmd_taste(room, message, bot, args):
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
if not lastfm_user: return
data = await call_lastfm_api("user.getTopArtists", {"user": lastfm_user, "period": "overall", "limit": "15"}, bot, room)
if not data: return
artists = data.get("topartists", {}).get("artist", [])
if not artists:
await bot.api.send_text_message(room.room_id, f"🔍 No artists found for {lastfm_user}.")
return
total = sum(safe_int(a, "playcount") for a in artists) or 1
rows = []
for a in artists[:15]:
name = a.get("name", _artist_name(a))
pc = safe_int(a, "playcount")
pct = round(pc/total*100, 1)
bar = ""*min(int(pct*2), 20)
rows.append(("🎯", name, f"{bar} {pct}%"))
output = _output(f"🎯 {display_name} — Taste-o-Meter", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !friends ----
async def cmd_friends(room, message, bot, args):
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
if not lastfm_user: return
data = await call_lastfm_api("user.getFriends", {"user": lastfm_user, "recenttracks": "1", "limit": "20"}, bot, room)
if not data: return
friends = data.get("friends", {}).get("user", [])
if not friends:
await bot.api.send_text_message(room.room_id, f"👥 No friends found for {lastfm_user}.")
return
total = int(data.get("friends", {}).get("@attr", {}).get("total", "0"))
rows = []
for f in friends[:15]:
fname = safe_text(f, "name")
rname = f.get("realname", "")
now = ""
if "recenttrack" in f:
rt = f["recenttrack"]
now = f" — 🎵 {_artist_name(rt)} - {safe_text(rt, 'name')}"
rows.append(("👥", fname + (f" ({rname})" if rname else ""), now))
output = _output(f"👥 {display_name} — Friends ({len(friends)} of {total})", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !recommend ----
async def cmd_recommend(room, message, bot, args):
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
if not lastfm_user: return
top = await call_lastfm_api("user.getTopArtists", {"user": lastfm_user, "period": "3month", "limit": "5"}, bot, room)
if not top: return
top_artists = [a.get("name", _artist_name(a)) for a in top.get("topartists", {}).get("artist", [])]
if not top_artists:
await bot.api.send_text_message(room.room_id, f"🔍 Not enough data for {lastfm_user}.")
return
seen = set(a.lower() for a in top_artists)
recs = []
for aname in top_artists[:3]:
sim = await call_lastfm_api("artist.getSimilar", {"artist": aname, "limit": "5", "autocorrect": "1"}, bot)
if sim:
for a in sim.get("similarartists", {}).get("artist", []):
name = a.get("name", _artist_name(a))
match = float(a.get("match", "0"))
if name.lower() not in seen:
seen.add(name.lower())
recs.append((name, match, aname))
recs.sort(key=lambda x: x[1], reverse=True)
if not recs:
await bot.api.send_text_message(room.room_id, "No recommendations found.")
return
rows = [(name, f"{round(match*100)}% match via {src}") for name, match, src in recs[:15]]
rows = [("💡", a, b) for a,b in rows]
output = _output(f"💡 Recommendations for {display_name}", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !similar ----
async def cmd_similar(room, message, bot, args):
if not args:
await bot.api.send_text_message(room.room_id, "Usage: !similar <artist>")
return
aname = " ".join(args)
data = await call_lastfm_api("artist.getSimilar", {"artist": aname, "limit": "15", "autocorrect": "1"}, bot, room)
if not data: return
artists = data.get("similarartists", {}).get("artist", [])
if not artists:
await bot.api.send_text_message(room.room_id, f"🔍 No similar artists found for **{aname}**.")
return
rows = [(a.get("name", _artist_name(a)), f"{round(float(a.get('match','0'))*100)}% match") for a in artists[:15]]
rows = [("🔗", a, b) for a,b in rows]
output = _output(f"🔗 Similar to {aname}", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !tag ----
async def cmd_tag(room, message, bot, args):
if not args:
await bot.api.send_text_message(room.room_id, "Usage: !tag <tag/genre>")
return
tag = " ".join(args)
data = await call_lastfm_api("tag.getTopArtists", {"tag": tag, "limit": "15"}, bot, room)
if not data: return
artists = data.get("topartists", {}).get("artist", [])
if not artists:
await bot.api.send_text_message(room.room_id, f"🔍 No artists found for tag **{tag}**.")
return
rows = [(a.get("name", _artist_name(a)), f"{safe_int(a, 'count')} taggings") for a in artists[:15]]
rows = [("🏷️", a, b) for a,b in rows]
output = _output(f"🏷️ Top Artists tagged '{tag}'", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !charts ----
async def cmd_charts(room, message, bot, args):
data = await call_lastfm_api("chart.getTopTracks", {"limit": "10"}, bot, room)
if not data: return
tracks = data.get("tracks", {}).get("track", [])
if not tracks:
await bot.api.send_text_message(room.room_id, "No chart data available.")
return
rows = []
for t in tracks[:10]:
artist = _artist_name(t) # t.artist is an object with name
name = safe_text(t, "name")
listeners = safe_int(t, "listeners")
rows.append(("🌍", name, f"{artist}{listeners:,} listeners"))
output = _output("🌍 Global Top Tracks", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !tagcloud ----
async def cmd_tagcloud(room, message, bot, args):
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
if not lastfm_user: return
data = await call_lastfm_api("user.getTopTags", {"user": lastfm_user, "limit": "30"}, bot, room)
if not data: return
tags = data.get("toptags", {}).get("tag", [])
if not tags:
await bot.api.send_text_message(room.room_id, f"🔍 No tags found for {lastfm_user}.")
return
rows = [(safe_text(t, "name"), str(safe_int(t, "count"))) for t in tags[:20]]
rows = [("☁️", a, b) for a,b in rows]
output = _output(f"☁️ {display_name} — Tag Cloud", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !now ----
async def cmd_now(room, message, bot, args):
all_users = await get_all_registered_users()
if not all_users:
await bot.api.send_text_message(room.room_id, "No users registered yet.")
return
rows = []
found = False
for mx_user, lfm_user in all_users.items():
data = await call_lastfm_api("user.getRecentTracks", {"user": lfm_user, "limit": "1"}, bot)
if not data: continue
tracks = data.get("recenttracks", {}).get("track", [])
if not tracks: continue
t = tracks[0] if isinstance(tracks, list) else tracks
if t.get("@attr", {}).get("nowplaying") == "true":
artist = _artist_name(t)
name = safe_text(t, "name")
rows.append(("🎵", lfm_user, f"{name} by {artist}"))
found = True
if not found:
rows.append(("🎵", "Nobody", "is currently scrobbling"))
output = _output("🎵 Now Playing Across Registered Users", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !decades ----
async def cmd_decades(room, message, bot, args):
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
if not lastfm_user: return
top = await call_lastfm_api("user.getTopArtists", {"user": lastfm_user, "period": "overall", "limit": "20"}, bot, room)
if not top: return
artists = top.get("topartists", {}).get("artist", [])
if not artists:
await bot.api.send_text_message(room.room_id, f"Not enough data for {lastfm_user}.")
return
decade_counts = {}
for a in artists[:10]:
aname = a.get("name", _artist_name(a))
pc = safe_int(a, "playcount")
tag_data = await call_lastfm_api("artist.getTopTags", {"artist": aname, "autocorrect": "1"}, bot)
if tag_data:
for tag in tag_data.get("toptags", {}).get("tag", []):
tn = safe_text(tag, "name").lower()
if (len(tn)==3 and tn[:2].isdigit() and tn.endswith("s")) or (len(tn)==5 and tn[:4].isdigit() and tn.endswith("s")):
decade_counts[tn] = decade_counts.get(tn,0) + pc
if not decade_counts:
await bot.api.send_text_message(room.room_id, f"Could not determine decade preferences for {lastfm_user}.")
return
sorted_d = sorted(decade_counts.items(), key=lambda x: x[1], reverse=True)
total = sum(decade_counts.values())
rows = []
for dec, cnt in sorted_d[:8]:
pct = round(cnt/total*100,1) if total else 0
bar = ""*min(int(pct*2),20)
rows.append(("📅", dec, f"{bar} {pct}%"))
output = _output(f"📅 {display_name} — Favorite Decades", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !genres ----
async def cmd_genres(room, message, bot, args):
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
if not lastfm_user: return
data = await call_lastfm_api("user.getTopTags", {"user": lastfm_user, "limit": "15"}, bot, room)
if not data: return
tags = data.get("toptags", {}).get("tag", [])
if not tags:
await bot.api.send_text_message(room.room_id, f"🔍 No genre tags for {lastfm_user}.")
return
rows = [(safe_text(t, "name"), f"{safe_int(t, 'count')}×") for t in tags[:15]]
rows = [("🎶", a, b) for a,b in rows]
output = _output(f"🎶 {display_name} — Top Genres", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !era ----
async def cmd_era(room, message, bot, args):
if not args:
await bot.api.send_text_message(room.room_id, "Usage: !era <year>")
return
year = args[0].strip()
if not year.isdigit() or len(year)!=4:
await bot.api.send_text_message(room.room_id, "Please specify a valid 4-digit year.")
return
tag = year+"s" if year.endswith("0") else year
data = await call_lastfm_api("tag.getTopTracks", {"tag": tag, "limit": "10"}, bot, room)
if not data: data = await call_lastfm_api("tag.getTopTracks", {"tag": year, "limit": "10"}, bot, room)
if not data: return
tracks = data.get("tracks", {}).get("track", [])
if not tracks:
await bot.api.send_text_message(room.room_id, f"🔍 No tracks found for era **{year}**.")
return
rows = [(safe_text(t, "name"), _artist_name(t)) for t in tracks[:10]]
rows = [("🕰️", a, b) for a,b in rows]
output = _output(f"🕰️ Popular Tracks — {year}", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !weekly ----
async def cmd_weekly(room, message, bot, args):
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
if not lastfm_user: return
data = await call_lastfm_api("user.getWeeklyTrackChart", {"user": lastfm_user}, bot, room)
if not data: return
tracks = data.get("weeklytrackchart", {}).get("track", [])
if not tracks:
await bot.api.send_text_message(room.room_id, f"📊 No weekly chart for {lastfm_user}.")
return
total_plays = sum(safe_int(t, "playcount") for t in tracks)
rows = [("📊", "Unique Tracks", str(len(tracks))), ("🎵", "Total Plays", str(total_plays))]
for t in tracks[:10]:
artist = _artist_name(t)
name = safe_text(t, "name")
plays = safe_int(t, "playcount")
rows.append(("📅", name, f"{artist}{plays} plays"))
output = _output(f"📅 {display_name} — Weekly Report", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !monthly ----
async def cmd_monthly(room, message, bot, args):
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
if not lastfm_user: return
to_ts = int(time.time())
from_ts = int((datetime.utcnow() - timedelta(days=30)).timestamp())
data = await call_lastfm_api("user.getRecentTracks", {"user": lastfm_user, "from": str(from_ts), "to": str(to_ts), "limit": "200"}, bot, room)
if not data: return
tracks = data.get("recenttracks", {}).get("track", [])
if not tracks:
await bot.api.send_text_message(room.room_id, f"📊 No tracks in the last 30 days for {lastfm_user}.")
return
track_counts, artist_counts = {}, {}
for t in tracks:
name = safe_text(t, "name")
artist = _artist_name(t)
key = f"{name}|||{artist}"
track_counts[key] = track_counts.get(key,0)+1
artist_counts[artist] = artist_counts.get(artist,0)+1
rows = [
("🎵", "Total Scrobbles", str(len(tracks))),
("🔀", "Unique Tracks", str(len(track_counts))),
("🎤", "Unique Artists", str(len(artist_counts))),
]
rows.append(("🎶", "Top Tracks", ""))
for i, (key, cnt) in enumerate(sorted(track_counts.items(), key=lambda x: x[1], reverse=True)[:10], 1):
n, a = key.split("|||", 1)
rows.append(("", n, f"{a}{cnt} plays"))
rows.append(("🎤", "Top Artists", ""))
for i, (a, cnt) in enumerate(sorted(artist_counts.items(), key=lambda x: x[1], reverse=True)[:5], 1):
rows.append(("", a, f"{cnt} plays"))
output = _output(f"📆 {display_name} — Monthly Report (Last 30 Days)", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !yearly ----
async def cmd_yearly(room, message, bot, args):
matrix_user = str(message.sender)
year = None
user_arg = list(args)
if user_arg and user_arg[-1].isdigit() and len(user_arg[-1])==4:
year = int(user_arg.pop())
lastfm_user, display_name = await resolve_username(matrix_user, user_arg, bot, room)
if not lastfm_user: return
if year:
try:
from_ts = int(datetime(year,1,1).timestamp())
to_ts = int(datetime(year,12,31,23,59,59).timestamp())
except ValueError:
await bot.api.send_text_message(room.room_id, "Invalid year.")
return
else:
to_ts = int(time.time())
from_ts = int((datetime.utcnow() - timedelta(days=365)).timestamp())
year = datetime.utcnow().year
data = await call_lastfm_api("user.getRecentTracks", {"user": lastfm_user, "from": str(from_ts), "to": str(to_ts), "limit": "200"}, bot, room)
if not data: return
tracks = data.get("recenttracks", {}).get("track", [])
if not tracks:
await bot.api.send_text_message(room.room_id, f"📊 No tracks in {year} for {lastfm_user}.")
return
track_counts, artist_counts = {}, {}
for t in tracks:
name = safe_text(t, "name")
artist = _artist_name(t)
key = f"{name}|||{artist}"
track_counts[key] = track_counts.get(key,0)+1
artist_counts[artist] = artist_counts.get(artist,0)+1
rows = [
("🎵", "Total Scrobbles", str(len(tracks))),
("🔀", "Unique Tracks", str(len(track_counts))),
("🎤", "Unique Artists", str(len(artist_counts))),
]
rows.append(("🎶", "Top Tracks", ""))
for i, (key, cnt) in enumerate(sorted(track_counts.items(), key=lambda x: x[1], reverse=True)[:10], 1):
n, a = key.split("|||", 1)
rows.append(("", n, f"{a}{cnt} plays"))
rows.append(("🎤", "Top Artists", ""))
for i, (a, cnt) in enumerate(sorted(artist_counts.items(), key=lambda x: x[1], reverse=True)[:5], 1):
rows.append(("", a, f"{cnt} plays"))
output = _output(f"📆 {display_name} — Yearly Report ({year})", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !first (unchanged) ----
async def cmd_first(room, message, bot, args):
matrix_user = str(message.sender)
if not args:
await bot.api.send_text_message(room.room_id, "Usage: !first <artist> [username]")
return
artist_parts = list(args)
potential_user = artist_parts[-1]
user_arg = []
if len(artist_parts) >= 2 and " " not in potential_user:
user_arg = [potential_user]; artist_parts.pop()
artist_name = " ".join(artist_parts)
lastfm_user, display_name = await resolve_username(matrix_user, user_arg, bot, room)
if not lastfm_user: return
data = await call_lastfm_api("user.getRecentTracks", {"user": lastfm_user, "limit": "200", "from": "0"}, bot, room)
if not data: return
tracks = data.get("recenttracks", {}).get("track", [])
if not tracks:
await bot.api.send_text_message(room.room_id, f"No scrobbles found for {lastfm_user}.")
return
matches = []
for t in tracks:
track_artist = _artist_name(t)
if artist_name.lower() in track_artist.lower():
date_str = ""
if "date" in t and "#text" in t["date"]:
date_str = t["date"]["#text"]
matches.append((t, date_str))
if not matches:
await bot.api.send_text_message(room.room_id, f"🔍 No scrobbles of **{artist_name}** found for {display_name}.")
return
oldest_track, oldest_date = matches[-1]
name = safe_text(oldest_track, "name")
track_artist = _artist_name(oldest_track)
await bot.api.send_markdown_message(room.room_id,
f"🔍 **{display_name}** first scrobbled **{artist_name}** with:\n • **{name}** by {track_artist}\n • 📅 {oldest_date if oldest_date else 'Unknown date'}")
# ---- !concerts ----
async def cmd_concerts(room, message, bot, args):
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
if not lastfm_user: return
top = await call_lastfm_api("user.getTopArtists", {"user": lastfm_user, "period": "3month", "limit": "10"}, bot, room)
if not top: return
artists = [a.get("name", _artist_name(a)) for a in top.get("topartists", {}).get("artist", [])]
if not artists: return
await bot.api.send_text_message(room.room_id, "🔍 Searching for upcoming concerts...")
all_events = []
for aname in artists[:5]:
ev = await call_lastfm_api("artist.getEvents", {"artist": aname, "limit": "3", "autocorrect": "1"}, bot)
if ev:
for e in ev.get("events", {}).get("event", [])[:3]:
title = safe_text(e, "title")
venue = safe_text(e.get("venue", {}), "name", "Unknown Venue")
city = safe_text(e.get("venue", {}).get("location", {}), "city", "")
country = safe_text(e.get("venue", {}).get("location", {}), "country", "")
start = safe_text(e, "startDate", "TBD")
loc = f"{city}, {country}" if city else country
all_events.append((title, aname, venue, loc, start))
if not all_events:
await bot.api.send_text_message(room.room_id, f"🎫 No upcoming concerts found for {display_name}'s top artists.")
return
rows = []
for title, artist, venue, loc, date in all_events[:15]:
rows.append(("🎫", f"{artist}{title}", f"📍 {venue}, {loc} | 📅 {date}"))
output = _output(f"🎫 Upcoming Concerts for {display_name}", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !radio ----
async def cmd_radio(room, message, bot, args):
if not args:
await bot.api.send_text_message(room.room_id, "Usage: !radio <artist>")
return
aname = " ".join(args)
sim = await call_lastfm_api("artist.getSimilar", {"artist": aname, "limit": "10", "autocorrect": "1"}, bot, room)
if not sim: return
similar = sim.get("similarartists", {}).get("artist", [])
if not similar:
await bot.api.send_text_message(room.room_id, f"No similar artists for **{aname}**.")
return
playlist = []
for s in similar[:8]:
sname = s.get("name", _artist_name(s))
top_tracks = await call_lastfm_api("artist.getTopTracks", {"artist": sname, "limit": "1", "autocorrect": "1"}, bot)
if top_tracks:
tracks = top_tracks.get("toptracks", {}).get("track", [])
if tracks:
t = tracks[0] if isinstance(tracks, list) else tracks
playlist.append((sname, safe_text(t, "name")))
if not playlist:
await bot.api.send_text_message(room.room_id, "Could not generate playlist.")
return
rows = []
for art, track in playlist:
yt = await get_youtube_link(art, track)
rows.append(("📻", track, f"{art}" + (f" | ▶️ {yt}" if yt else "")))
output = _output(f"📻 Radio: {aname} — Similar Artists Playlist", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !mashup ----
async def cmd_mashup(room, message, bot, args):
if len(args) < 2:
await bot.api.send_text_message(room.room_id, "Usage: !mashup <artist1> <artist2>")
return
full = " ".join(args)
if "," in full:
parts = full.split(",", 1)
a1, a2 = parts[0].strip(), parts[1].strip()
else:
mid = len(args)//2
a1, a2 = " ".join(args[:mid]), " ".join(args[mid:])
d1 = await call_lastfm_api("artist.getSimilar", {"artist": a1, "limit": "20", "autocorrect": "1"}, bot, room)
d2 = await call_lastfm_api("artist.getSimilar", {"artist": a2, "limit": "20", "autocorrect": "1"}, bot, room)
if not d1 or not d2: return
sim1 = {a.get("name", _artist_name(a)).lower(): float(a.get("match",0)) for a in d1.get("similarartists", {}).get("artist", [])}
sim2 = {a.get("name", _artist_name(a)).lower(): float(a.get("match",0)) for a in d2.get("similarartists", {}).get("artist", [])}
common = set(sim1.keys()) & set(sim2.keys())
rows = []
if common:
shared = sorted(common, key=lambda a: sim1[a]+sim2[a], reverse=True)[:10]
rows.append(("🔀", "Shared similar artists", str(len(common))))
for a in shared:
avg = round((sim1[a]+sim2[a])/2*100)
rows.append(("", a, f"{avg}% avg match"))
else:
rows.append(("🔀", "Connections", "No direct connections found"))
tags1 = await call_lastfm_api("artist.getTopTags", {"artist": a1, "autocorrect": "1"}, bot)
tags2 = await call_lastfm_api("artist.getTopTags", {"artist": a2, "autocorrect": "1"}, bot)
t1 = set(safe_text(t,"name").lower() for t in (tags1.get("toptags",{}).get("tag",[]) if tags1 else []))
t2 = set(safe_text(t,"name").lower() for t in (tags2.get("toptags",{}).get("tag",[]) if tags2 else []))
common_tags = t1 & t2
if common_tags:
rows.append(("🏷️", "Shared genres", ", ".join(sorted(common_tags)[:8])))
output = _output(f"🔀 Mashup: {a1}{a2}", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- !collage ----
async def cmd_collage(room, message, bot, args):
matrix_user = str(message.sender)
size = 3
user_arg = list(args)
if user_arg and user_arg[-1].isdigit():
size = max(2, min(5, int(user_arg[-1])))
user_arg.pop()
lastfm_user, display_name = await resolve_username(matrix_user, user_arg, bot, room)
if not lastfm_user: return
data = await call_lastfm_api("user.getTopAlbums", {"user": lastfm_user, "period": "overall", "limit": str(size*size)}, bot, room)
if not data: return
albums = data.get("topalbums", {}).get("album", [])
if not albums:
await bot.api.send_text_message(room.room_id, f"No albums for {lastfm_user}.")
return
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60), headers=HEADERS) as session:
tasks = [download_album_art_to_file(session, alb) for alb in albums[:size*size]]
results = await asyncio.gather(*tasks)
downloaded = [r for r in results if r[2] is not None]
if not downloaded:
await bot.api.send_text_message(room.room_id, "Could not download any album art.")
return
placeholder = os.path.join(tempfile.gettempdir(), "lastfm_placeholder.png")
subprocess.run(["convert", "-size", "200x200", "xc:white", placeholder], check=True)
file_list = []
for _, _, path in results:
file_list.append(path if path else placeholder)
while len(file_list) < size*size:
file_list.append(placeholder)
collage_path = os.path.join(tempfile.gettempdir(), f"lastfm_collage_{lastfm_user}_{int(time.time())}.png")
cmd = ["montage", "-geometry", "200x200+2+2", "-tile", f"{size}x{size}"] + file_list + [collage_path]
try:
subprocess.run(cmd, check=True, timeout=30)
except subprocess.CalledProcessError:
if downloaded:
collage_path = downloaded[0][2]
else:
await bot.api.send_text_message(room.room_id, "Failed to create collage.")
return
await bot.api.send_image_message(room_id=room.room_id, image_filepath=collage_path)
rows = []
for alb in albums[:size*size]:
artist = album_artist_name(alb)
name = safe_text(alb, "name")
plays = safe_int(alb, "playcount")
rows.append(("🖼️", name, f"{artist}{plays} plays"))
output = _output(f"🖼️ {display_name} — Album Collage ({size}×{size})", rows)
await bot.api.send_markdown_message(room.room_id, output)
for _, _, path in downloaded:
if path and os.path.exists(path): os.remove(path)
if os.path.exists(placeholder): os.remove(placeholder)
if os.path.exists(collage_path): os.remove(collage_path)
# ---- !listening ----
async def cmd_listening(room, message, bot, args):
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
if not lastfm_user:
return
data = await call_lastfm_api(
"user.getRecentTracks", {"user": lastfm_user, "limit": "1"}, bot, room
)
if not data:
return
tracks = data.get("recenttracks", {}).get("track", [])
if not tracks:
await bot.api.send_text_message(room.room_id, f"No recent tracks for {lastfm_user}.")
return
t = tracks[0] if isinstance(tracks, list) else tracks
now_playing = t.get("@attr", {}).get("nowplaying", "false") == "true"
artist = _artist_name(t)
name = safe_text(t, "name")
album = safe_text(t, "album", "")
# find best image
img = ""
for im in t.get("image", []):
if im.get("size") == "extralarge":
img = im.get("#text", "")
break
if not img:
for im in t.get("image", []):
img = im.get("#text", "")
if img:
break
action = "is listening to" if now_playing else "last listened to"
summary = f"🎧 {display_name} {action}: {name} by {artist}"
lines = []
if img:
lines.append(f"![Album Art]({img})")
lines.append(f"**{name}** by **{artist}**")
if album:
lines.append(f"Album: *{album}*")
yt = await get_youtube_link(artist, name)
if yt:
lines.append(f"[▶️ YouTube]({yt})")
body = "<br>".join(lines)
await bot.api.send_markdown_message(room.room_id, collapsible_summary(summary, body))
# ---- !awards ----
async def cmd_awards(room, message, bot, args):
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
if not lastfm_user: return
info = await call_lastfm_api("user.getInfo", {"user": lastfm_user}, bot, room)
if not info: return
ui = info.get("user", {})
pc = safe_int(ui, "playcount")
ac = safe_int(ui, "artist_count", 0)
reg = ui.get("registered", {}).get("#text", "Unknown")
achievements = []
for threshold, title in [(100,"🎧 Newcomer"),(1000,"🎶 Listener"),(5000,"🎵 Collector"),(10000,"💿 Music Fanatic"),
(25000,"🎸 Audiophile"),(50000,"🎹 Music Scholar"),(100000,"🏆 Scrobble Master"),
(250000,"👑 Scrobble King/Queen"),(500000,"🌟 Scrobble Legend"),(1000000,"🌌 Scrobble Galaxy")]:
if pc >= threshold:
achievements.append(f"{title}{threshold:,}+ scrobbles")
if ac >= 10: achievements.append("🌿 Explorer — 10+ artists")
if ac >= 50: achievements.append("🌳 Curator — 50+ artists")
if ac >= 100: achievements.append("🌍 Globetrotter — 100+ artists")
if ac >= 500: achievements.append("🌌 Universe Explorer — 500+ artists")
if ac >= 1000: achievements.append("🚀 Cosmopolitan — 1,000+ artists")
try:
reg_date = datetime.strptime(reg, "%d %b %Y")
years = (datetime.utcnow() - reg_date).days // 365
if years >= 1: achievements.append(f"📅 Veteran — {years} year{'s' if years!=1 else ''} on Last.fm")
if years >= 5: achievements.append("🏅 Loyalist — 5+ years on Last.fm")
if years >= 10: achievements.append("🎖️ Decade Club — 10+ years on Last.fm")
except: pass
if ui.get("subscriber","0") == "1": achievements.append("⭐ Subscriber")
if not achievements: achievements.append("🆕 Keep scrobbling!")
rows = [("🏆", ach, "") for ach in achievements]
output = _output(f"🏆 {display_name} — Achievements", rows)
await bot.api.send_markdown_message(room.room_id, output)
# ---- help & dispatch ----
async def cmd_lastfm_help(room, message, bot, args):
help_text = """<details><summary><strong>🎵 Last.fm Plugin Commands</strong></summary>
<p>
<strong>Registration & Now Playing</strong><br>
• <code>!register &lt;username&gt;</code> - Register your Last.fm username<br>
• <code>!np [user]</code> - Show currently playing track<br>
<br>
<strong>Recent & Loved</strong><br>
• <code>!recent [user] [limit]</code> - Recent tracks (default 10, max 50)<br>
• <code>!loved [user]</code> - Recently loved tracks<br>
<br>
<strong>Top Lists</strong><br>
• <code>!toptracks [user] [period]</code> - Top tracks<br>
• <code>!topartists [user] [period]</code> - Top artists<br>
• <code>!topalbums [user] [period]</code> - Top albums<br>
<br>
<strong>Profile & Stats</strong><br>
• <code>!profile [user]</code> - Detailed profile<br>
• <code>!playcount [user]</code> - Total scrobbles<br>
• <code>!scrobbles [user]</code> - Detailed scrobbling statistics<br>
<br>
<strong>Social & Comparison</strong><br>
• <code>!compare &lt;user1&gt; &lt;user2&gt;</code> - Compare musical tastes<br>
• <code>!taste [user]</code> - Top artists with taste-o-meter<br>
• <code>!friends [user]</code> - Last.fm friends<br>
<br>
<strong>Discovery</strong><br>
• <code>!recommend [user]</code> - Artist recommendations<br>
• <code>!similar &lt;artist&gt;</code> - Find similar artists<br>
• <code>!tag &lt;tag&gt;</code> - Top artists for a tag/genre<br>
• <code>!radio &lt;artist&gt;</code> - Generate a playlist<br>
• <code>!mashup &lt;artist1&gt; &lt;artist2&gt;</code> - Find connections<br>
<br>
<strong>Charts & Tags</strong><br>
• <code>!charts</code> - Global top tracks<br>
• <code>!tagcloud [user]</code> - Top genre tags<br>
• <code>!genres [user]</code> - Top genres/tags<br>
<br>
<strong>Timebased Analysis</strong><br>
• <code>!decades [user]</code> - Favorite decades<br>
• <code>!era &lt;year&gt;</code> - Popular tracks from a year<br>
• <code>!weekly [user]</code> - Weekly listening report<br>
• <code>!monthly [user]</code> - Monthly listening report<br>
• <code>!yearly [user] [year]</code> - Yearly listening report<br>
<br>
<strong>Specialized</strong><br>
• <code>!first &lt;artist&gt; [user]</code> - First scrobble of an artist<br>
• <code>!concerts [user]</code> - Upcoming concerts<br>
• <code>!collage [user] [size]</code> - Album art collage (image)<br>
• <code>!listening [user]</code> - Now listening with album art<br>
• <code>!awards [user]</code> - Milestone achievements<br>
<br>
<strong>Roomwide</strong><br>
• <code>!now</code> - Show what registered users are playing<br>
</p></details>"""
await bot.api.send_markdown_message(room.room_id, help_text)
async def handle_command(room, message, bot, prefix, config):
match = botlib.MessageMatch(room, message, bot, prefix)
await init_db()
if not (match.is_not_from_this_bot() and match.prefix()): return
cmd = match.command()
args = match.args()
handlers = {
"register": cmd_register, "np": cmd_np, "recent": cmd_recent,
"toptracks": cmd_toptracks, "topartists": cmd_topartists, "topalbums": cmd_topalbums,
"loved": cmd_loved, "profile": cmd_profile, "playcount": cmd_playcount,
"scrobbles": cmd_scrobbles, "compare": cmd_compare, "taste": cmd_taste,
"friends": cmd_friends, "recommend": cmd_recommend, "similar": cmd_similar,
"tag": cmd_tag, "charts": cmd_charts, "tagcloud": cmd_tagcloud,
"now": cmd_now, "decades": cmd_decades, "genres": cmd_genres,
"era": cmd_era, "weekly": cmd_weekly, "monthly": cmd_monthly,
"yearly": cmd_yearly, "first": cmd_first, "concerts": cmd_concerts,
"radio": cmd_radio, "mashup": cmd_mashup, "collage": cmd_collage,
"listening": cmd_listening, "awards": cmd_awards, "lastfm": cmd_lastfm_help,
}
handler = handlers.get(cmd)
if handler:
try:
await handler(room, message, bot, args)
except Exception as e:
logging.error(f"Error in Last.fm command '{cmd}': {e}")
await bot.api.send_text_message(room.room_id, f"❌ Error processing !{cmd}: {str(e)}")
__version__ = "1.1.1"
__author__ = "Funguy Bot"
__description__ = "Last.fm music stats with aligned code block output"
__help__ = """<details><summary><strong>!lastfm</strong> Last.fm music stats</summary>
<p>Use <code>!lastfm</code> for full command list. Requires <strong>LASTFM_API_KEY</strong> env var.</p></details>"""