1251 lines
58 KiB
Python
1251 lines
58 KiB
Python
"""
|
||
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"")
|
||
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 <username></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 <user1> <user2></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 <artist></code> - Find similar artists<br>
|
||
• <code>!tag <tag></code> - Top artists for a tag/genre<br>
|
||
• <code>!radio <artist></code> - Generate a playlist<br>
|
||
• <code>!mashup <artist1> <artist2></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>Time‑based Analysis</strong><br>
|
||
• <code>!decades [user]</code> - Favorite decades<br>
|
||
• <code>!era <year></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 <artist> [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>Room‑wide</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>"""
|