""" 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 \nOr specify a username: !command ") 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 ") 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 ") 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 ") 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 ") 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 ") 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 [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 ") 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 ") 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 = "
".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 = """
🎵 Last.fm Plugin Commands

Registration & Now Playing
!register <username> - Register your Last.fm username
!np [user] - Show currently playing track

Recent & Loved
!recent [user] [limit] - Recent tracks (default 10, max 50)
!loved [user] - Recently loved tracks

Top Lists
!toptracks [user] [period] - Top tracks
!topartists [user] [period] - Top artists
!topalbums [user] [period] - Top albums

Profile & Stats
!profile [user] - Detailed profile
!playcount [user] - Total scrobbles
!scrobbles [user] - Detailed scrobbling statistics

Social & Comparison
!compare <user1> <user2> - Compare musical tastes
!taste [user] - Top artists with taste-o-meter
!friends [user] - Last.fm friends

Discovery
!recommend [user] - Artist recommendations
!similar <artist> - Find similar artists
!tag <tag> - Top artists for a tag/genre
!radio <artist> - Generate a playlist
!mashup <artist1> <artist2> - Find connections

Charts & Tags
!charts - Global top tracks
!tagcloud [user] - Top genre tags
!genres [user] - Top genres/tags

Time‑based Analysis
!decades [user] - Favorite decades
!era <year> - Popular tracks from a year
!weekly [user] - Weekly listening report
!monthly [user] - Monthly listening report
!yearly [user] [year] - Yearly listening report

Specialized
!first <artist> [user] - First scrobble of an artist
!concerts [user] - Upcoming concerts
!collage [user] [size] - Album art collage (image)
!listening [user] - Now listening with album art
!awards [user] - Milestone achievements

Room‑wide
!now - Show what registered users are playing

""" 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__ = """
!lastfm – Last.fm music stats

Use !lastfm for full command list. Requires LASTFM_API_KEY env var.

"""