diff --git a/README.md b/README.md index 37323c9..4513c79 100644 --- a/README.md +++ b/README.md @@ -294,8 +294,7 @@ Perform IP geolocation lookups with detailed geographic information. ### 🎡 Last.fm Integration -**🎡 !register [username] and !np** -Register your Last.fm username and display currently playing tracks. +**🎡 !register [username] and comprehensive music analytics** **Features:** - Associate Matrix ID with Last.fm username @@ -304,7 +303,49 @@ Register your Last.fm username and display currently playing tracks. **Commands:** - `!register ` - Register your Last.fm username -- `!np` - Display currently playing track +- `!np [user]` - Display currently playing track +- `!recent [user] [limit]` - Show recent tracks (default 10, max 50) +- `!toptracks [user] [period]` - Show top tracks (overall/7day/1month/3month/6month/12month) +- `!topartists [user] [period]` - Show top artists +- `!topalbums [user] [period]` - Show top albums +- `!loved [user]` - Show recently loved tracks +- `!profile [user]` - Detailed user profile +- `!playcount [user]` - Total scrobbles +- `!scrobbles [user]` - Detailed scrobbling statistics +- `!compare ` - Compare musical tastes +- `!taste [user]` - Top artists with taste-o-meter +- `!friends [user]` - Show Last.fm friends +- `!recommend [user]` - Artist recommendations +- `!similar ` - Find similar artists +- `!tag ` - Top artists for a tag/genre +- `!charts` - Global top tracks chart +- `!tagcloud [user]` - Top genre tags +- `!now` - What are registered users playing? +- `!decades [user]` - Favorite decades analysis +- `!genres [user]` - Top genres/tags +- `!era ` - Popular tracks from a year +- `!weekly [user]` - Weekly listening report +- `!monthly [user]` - Monthly listening report +- `!yearly [user] [year]` - Yearly listening report +- `!first [user]` - Find first scrobble of an artist +- `!concerts [user]` - Upcoming concerts for top artists +- `!radio ` - Generate playlist based on artist +- `!mashup ` - Musical connections between artists +- `!collage [user] [size]` - Top album art URLs +- `!listening [user]` - Currently listening with album art +- `!awards [user]` - Milestone achievements + +**Examples:** +```bash +!register your_lastfm_username +!np +!recent 20 +!topartists 7day +!compare user1 user2 +!similar radiohead +!tag electronic +!era 1994 +``` ### ExploitDB Plugin diff --git a/plugins/help.py b/plugins/help.py index 7f98a0a..ad48155 100644 --- a/plugins/help.py +++ b/plugins/help.py @@ -359,16 +359,53 @@ Search Exploit-DB for security vulnerabilities and exploits. Returns detailed in
🎡 Last.fm Integration -

Register your Last.fm username and display currently playing tracks.

+

Comprehensive Last.fm integration with 30+ commands for music analytics and social features.

Commands:

  • !register - Register your Last.fm username
  • -
  • !np - Display your currently playing track from Last.fm
  • +
  • !np [user] - Show currently playing track
  • +
  • !recent [user] [limit] - Show recent tracks (default 10, max 50)
  • +
  • !toptracks [user] [period] - Show top tracks (overall/7day/1month/3month/6month/12month)
  • +
  • !topartists [user] [period] - Show top artists
  • +
  • !topalbums [user] [period] - Show top albums
  • +
  • !loved [user] - Show recently loved tracks
  • +
  • !profile [user] - Detailed user profile
  • +
  • !playcount [user] - Total scrobbles
  • +
  • !scrobbles [user] - Detailed scrobbling statistics
  • +
  • !compare - Compare musical tastes
  • +
  • !taste [user] - Top artists with taste-o-meter
  • +
  • !friends [user] - Show Last.fm friends
  • +
  • !recommend [user] - Artist recommendations
  • +
  • !similar - Find similar artists
  • +
  • !tag - Top artists for a tag/genre
  • +
  • !charts - Global top tracks chart
  • +
  • !tagcloud [user] - Top genre tags
  • +
  • !now - What are registered users playing?
  • +
  • !decades [user] - Favorite decades analysis
  • +
  • !genres [user] - Top genres/tags
  • +
  • !era - Popular tracks from a year
  • +
  • !weekly [user] - Weekly listening report
  • +
  • !monthly [user] - Monthly listening report
  • +
  • !yearly [user] [year] - Yearly listening report
  • +
  • !first [user] - Find first scrobble of an artist
  • +
  • !concerts [user] - Upcoming concerts for top artists
  • +
  • !radio - Generate playlist based on artist
  • +
  • !mashup - Musical connections between artists
  • +
  • !collage [user] [size] - Top album art URLs
  • +
  • !listening [user] - Currently listening with album art
  • +
  • !awards [user] - Milestone achievements

Features:

    -
  • Associate your Matrix ID with your Last.fm username
  • +
  • Register your Matrix ID with your Last.fm username
  • Display currently playing tracks with artist and album information
  • +
  • Compare musical tastes between users
  • +
  • Discover similar artists and genres
  • +
  • Get personalized artist recommendations
  • +
  • View detailed listening statistics and reports
  • +
  • Find upcoming concerts for your favorite artists
  • +
  • Generate playlists based on your musical preferences
  • +
  • View milestone achievements and listening habits
  • Uses SQLite database to store user associations
  • Requires LASTFM_API_KEY environment variable
@@ -376,11 +413,19 @@ Search Exploit-DB for security vulnerabilities and exploits. Returns detailed in
  • !register your_lastfm_username - Register your Last.fm username
  • !np - Show your currently playing track
  • +
  • !recent 20 - Show your 20 most recent tracks
  • +
  • !topartists 7day - Show your top artists from the last 7 days
  • +
  • !compare user1 user2 - Compare musical tastes between two users
  • +
  • !similar radiohead - Find artists similar to Radiohead
  • +
  • !tag electronic - Show top electronic artists
  • +
  • !era 1994 - Show popular tracks from 1994
  • +
  • !radio metallica - Generate a playlist based on Metallica
  • +
  • !mashup metallica megadeth - Find musical connections between Metallica and Megadeth

Requirements:

-
    -
  • LASTFM_API_KEY in .env file
  • Last.fm account at last.fm
  • +
  • LASTFM_API_KEY in .env file
  • +
  • YOUTUBE_API_KEY in .env file (for YouTube integration)
diff --git a/plugins/lastfm.py b/plugins/lastfm.py index 970fb71..20f2262 100644 --- a/plugins/lastfm.py +++ b/plugins/lastfm.py @@ -1,18 +1,76 @@ """ -This plugin provides Last.fm integration for the bot. -It allows users to register their Last.fm username and display their currently playing track. +This plugin provides comprehensive Last.fm integration for the bot. +It allows users to register their Last.fm username and access rich music analytics. + +Commands: + !register - Register your Last.fm username + !np - Show currently playing track (no collapsible) + !recent [user] [limit] - Show recent tracks (default 10, max 50) + !toptracks [user] [period] - Show top tracks (overall/7day/1month/3month/6month/12month) + !topartists [user] [period] - Show top artists + !topalbums [user] [period] - Show top albums + !loved [user] - Show recently loved tracks + !profile [user] - Detailed user profile + !playcount [user] - Total scrobbles + !scrobbles [user] - Detailed scrobbling statistics + !compare - Compare musical tastes + !taste [user] - Top artists with taste-o-meter + !friends [user] - Show Last.fm friends + !recommend [user] - Artist recommendations + !similar - Find similar artists + !tag - Top artists for a tag/genre + !charts - Global top tracks chart + !tagcloud [user] - Top genre tags + !now - What are registered users playing? + !decades [user] - Favorite decades analysis + !genres [user] - Top genres/tags + !era - Popular tracks from a year + !weekly [user] - Weekly listening report + !monthly [user] - Monthly listening report + !yearly [user] [year] - Yearly listening report + !first [user] - Find first scrobble of an artist + !concerts [user] - Upcoming concerts for top artists + !radio - Generate playlist based on artist + !mashup - Musical connections between artists + !collage [user] [size] - Album art collage (image) using ImageMagick + !listening [user] - Currently listening with album art + !awards [user] - Milestone achievements + !lastfm - Show this help """ import logging import os -import sqlite3 -import simplematrixbotlib as botlib -import aiosqlite -import json +import time +import subprocess +import tempfile +import asyncio import aiohttp +import aiosqlite +import simplematrixbotlib as botlib +from datetime import datetime, timedelta -# Database file path +# --------------------------------------------------------------------------- +# 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", +} + +# User-Agent to avoid 403/404 from CDNs +HEADERS = {"User-Agent": "FunguyBot/1.0 (Matrix last.fm plugin)"} + +# --------------------------------------------------------------------------- +# Database helpers +# --------------------------------------------------------------------------- async def init_db(): """Initialize the database with the required tables.""" @@ -25,77 +83,140 @@ async def init_db(): """) await db.commit() + async def get_lastfm_username(matrix_user): """Get Last.fm username for a 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 cursor: + async with db.execute( + "SELECT lastfm_user FROM user_lastfm WHERE matrix_user = ?", + (matrix_user,), + ) as cursor: row = await cursor.fetchone() return row[0] if row else None + async def set_lastfm_username(matrix_user, lastfm_user): """Associate a Last.fm username with a Matrix user.""" async with aiosqlite.connect(DB_PATH) as db: - # Check if user already exists - async with db.execute("SELECT lastfm_user FROM user_lastfm WHERE matrix_user = ?", (matrix_user,)) as cursor: + async with db.execute( + "SELECT lastfm_user FROM user_lastfm WHERE matrix_user = ?", + (matrix_user,), + ) as cursor: row = await cursor.fetchone() - if row: - # Update existing record - await db.execute("UPDATE user_lastfm SET lastfm_user = ? WHERE matrix_user = ?", (lastfm_user, matrix_user)) + await db.execute( + "UPDATE user_lastfm SET lastfm_user = ? WHERE matrix_user = ?", + (lastfm_user, matrix_user), + ) else: - # Insert new record - await db.execute("INSERT INTO user_lastfm (matrix_user, lastfm_user) VALUES (?, ?)", (matrix_user, lastfm_user)) - + await db.execute( + "INSERT INTO user_lastfm (matrix_user, lastfm_user) VALUES (?, ?)", + (matrix_user, lastfm_user), + ) await db.commit() -async def get_recent_track(lastfm_user): - """ - Get the recent tracks for a Last.fm user. - Args: - lastfm_user (str): Last.fm username +async def get_all_registered_users(): + """Get all registered Matrix user -> Last.fm user mappings.""" + async with aiosqlite.connect(DB_PATH) as db: + async with db.execute("SELECT matrix_user, lastfm_user FROM user_lastfm") as cursor: + rows = await cursor.fetchall() + return {row[0]: row[1] for row in rows} - Returns: - dict: Track information or None if error + +# --------------------------------------------------------------------------- +# Resolve username: registered user or explicit argument +# --------------------------------------------------------------------------- + +async def resolve_username(matrix_user, args, bot, room): """ + Resolve the Last.fm username from args or registration. + Returns (lastfm_user, display_name) or (None, None) if not resolved. + Sends error message to room if not resolved and bot+room provided. + """ + if args: + lastfm_user = args[0].strip() + display_name = lastfm_user + return lastfm_user, display_name + + lastfm_user = await get_lastfm_username(matrix_user) + if not lastfm_user: + if bot and room: + await bot.api.send_text_message( + room.room_id, + "Please register your Last.fm username first with !register \n" + "Or specify a username: !command ", + ) + return None, None + return lastfm_user, matrix_user + + +# --------------------------------------------------------------------------- +# API helper +# --------------------------------------------------------------------------- + +def get_api_key(): + """Get Last.fm API key from environment.""" api_key = os.getenv("LASTFM_API_KEY") if not api_key: logging.error("LASTFM_API_KEY not found in environment variables") + return api_key + + +async def call_lastfm_api(method, params, bot=None, room=None): + """ + Call the Last.fm API with the given method and params. + Returns JSON data or None on error. + Optionally sends error messages to a room. + """ + 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 - url = "http://ws.audioscrobbler.com/2.0/" - params = { - "method": "user.getrecenttracks", - "user": lastfm_user, + full_params = { + "method": method, "api_key": api_key, "format": "json", - "limit": "1" + **params, } try: - async with aiohttp.ClientSession() as session: - async with session.get(url, params=params) as response: + async with aiohttp.ClientSession(headers=HEADERS) as session: + async with session.get(API_BASE, params=full_params, timeout=15) as response: if response.status == 200: data = await response.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 else: - logging.error(f"Last.fm API returned status {response.status}") + logging.error(f"Last.fm API returned status {response.status} for {method}") + if bot and room: + await bot.api.send_text_message( + room.room_id, f"❌ Last.fm API error: HTTP {response.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 querying Last.fm API: {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): - """ - Search for a YouTube link for the given artist and track using YouTube Data API. - - Args: - artist (str): Artist name - track_name (str): Track name - - Returns: - str: YouTube video URL or None if not found - """ + """Search for a YouTube link for the given artist and track.""" youtube_api_key = os.getenv("YOUTUBE_API_KEY") if not youtube_api_key: return None @@ -107,7 +228,7 @@ async def get_youtube_link(artist, track_name): "q": search_query, "type": "video", "key": youtube_api_key, - "maxResults": "1" + "maxResults": "1", } try: @@ -120,123 +241,1781 @@ async def get_youtube_link(artist, track_name): video_id = items[0].get("id", {}).get("videoId") if video_id: return f"https://www.youtube.com/watch?v={video_id}" - return None except Exception as e: logging.error(f"Error searching YouTube: {e}") - return None + return None -async def format_recent_track(matrix_user, lastfm_user, data): + +# --------------------------------------------------------------------------- +# Safe extraction helpers +# --------------------------------------------------------------------------- + +def safe_text(obj, key, default="Unknown"): + """Safely extract #text from a nested dict.""" + 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): + """Safely extract an integer value.""" + try: + val = safe_text(obj, key, str(default)) + return int(val) + except (ValueError, TypeError): + return default + + +# --------------------------------------------------------------------------- +# Collapsible wrapper +# --------------------------------------------------------------------------- + +def wrap_collapsible(summary, body): + """Wrap content in a collapsible HTML details block.""" + return f"
{summary}{body}
" + + +# --------------------------------------------------------------------------- +# COLLAGE HELPER – download image and save to a temp file, return path or None +# --------------------------------------------------------------------------- + +# ------------------------------------------------------------ +# Helper: safely get artist name from album or track object +# ------------------------------------------------------------ +def album_artist_name(album): + """Extract artist name from an album object (handles both string and dict).""" + artist = album.get("artist", {}) + if isinstance(artist, str): + return artist + if isinstance(artist, dict): + # Album API returns 'name', track API returns '#text' + return artist.get("name", artist.get("#text", "Unknown")) + return "Unknown" + + +# ------------------------------------------------------------ +# Download an image to temp file – tries direct URL first, +# falls back to album.getInfo + download. +# ------------------------------------------------------------ +async def download_album_art_to_file(session, album_data): """ - Format recent track information into a readable message. - - Args: - matrix_user (str): The Matrix user ID - lastfm_user (str): The Last.fm username - data (dict): Data from Last.fm API - - Returns: - str: Formatted message + Download album art. + album_data is the raw album dict from user.getTopAlbums. + Returns (artist, album_name, filepath) or (artist, album_name, None). """ - if not data or "error" in data: - return f"πŸ” No recent track data found for {lastfm_user}." + album_name = safe_text(album_data, "name", "Unknown Album") + artist = album_artist_name(album_data) + + # 1. Try direct image from the album object (extralarge or any) + direct_url = None + for img in album_data.get("image", []): + if img.get("size") == "extralarge": + direct_url = img.get("#text") + break + if not direct_url: + for img in album_data.get("image", []): + url = img.get("#text") + if url: + direct_url = url + break + + if direct_url: + try: + async with session.get(direct_url, timeout=15) as resp: + if resp.status == 200: + content = await resp.read() + if len(content) >= 500: + ext = "jpg" if not direct_url.endswith(".png") else "png" + fd, tmp_path = tempfile.mkstemp(suffix=f".{ext}") + os.close(fd) + with open(tmp_path, "wb") as f: + f.write(content) + logging.info(f"Downloaded '{album_name}' from direct URL") + return (artist, album_name, tmp_path) + except Exception as e: + logging.warning(f"Direct URL failed for '{album_name}': {e}") + + # 2. 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: + # Get best image from album info + image_url = None + for img in album_info.get("image", []): + if img.get("size") == "extralarge": + image_url = img.get("#text") + break + if not image_url: + for img in album_info.get("image", []): + url = img.get("#text") + if url: + image_url = url + break + if image_url: + async with session.get(image_url, timeout=15) as img_resp: + if img_resp.status == 200: + content = await img_resp.read() + if len(content) >= 500: + ext = "jpg" if not image_url.endswith(".png") else "png" + fd, tmp_path = tempfile.mkstemp(suffix=f".{ext}") + os.close(fd) + with open(tmp_path, "wb") as f: + f.write(content) + logging.info(f"Downloaded '{album_name}' via album.getInfo") + return (artist, album_name, tmp_path) + except Exception as e: + logging.warning(f"album.getInfo fallback failed for '{album_name}': {e}") + + return (artist, album_name, None) + + + +# =================================================================== +# COMMAND HANDLERS +# =================================================================== + +# ---- !register --------------------------------------------------- + +async def cmd_register(room, message, bot, args): + """Handle !register """ + 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}" + ) + logging.info(f"Registered Last.fm user {lastfm_user} for {matrix_user}") + + +# ---- !np --------------------------------------------------------- + +async def cmd_np(room, message, bot, args): + """Handle !np - Show now playing track. No collapsible.""" + 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 - # Check if there's a currently playing track tracks = data.get("recenttracks", {}).get("track", []) if not tracks: - return f"πŸ” No recent tracks found for {lastfm_user}." + await bot.api.send_text_message(room.room_id, f"πŸ” No recent tracks found for {lastfm_user}.") + return - # Get the most recent track (first in the list) - track = tracks[0] if tracks else {} - - # Check if it's currently playing + track = tracks[0] if isinstance(tracks, list) else tracks now_playing = track.get("@attr", {}).get("nowplaying", "false") == "true" - # Extract track information - artist = track.get("artist", {}).get("#text", "Unknown Artist") - name = track.get("name", "Unknown Track") - album = track.get("album", {}).get("#text", "") + artist = safe_text(track, "artist") + name = safe_text(track, "name") + album = safe_text(track, "album", "") - # Create one-line message with bold track name if now_playing: - if album: - message = f"🎡 {matrix_user} is currently playing: **{name}** by {artist} from the album {album}" - else: - message = f"🎡 {matrix_user} is currently playing: **{name}** by {artist}" - elif tracks: - if album: - message = f"🎡 {matrix_user} last played: **{name}** by {artist} from the album {album}" - else: - message = f"🎡 {matrix_user} last played: **{name}** by {artist}" + prefix_icon = "🎡" + action = "is currently playing" else: - message = f"πŸ” No recent tracks found for {lastfm_user}." + 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}**" - # Try to get YouTube link youtube_link = await get_youtube_link(artist, name) if youtube_link: - message += f" | [YouTube Link]({youtube_link})" + message_text += f" | [YouTube]({youtube_link})" - return message + 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): + """Handle !recent [user] [limit]""" + matrix_user = str(message.sender) + + limit = 10 + user_arg = list(args) + # Parse --limit if present + 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 + # Allow limit as last argument if numeric + 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")) + summary = f"🎡 {display_name} β€” Recent Tracks ({min(limit, len(tracks))} of {total})" + lines = [] + for i, track in enumerate(tracks[:limit], 1): + artist = safe_text(track, "artist") + name = safe_text(track, "name") + album = safe_text(track, "album", "") + now = "πŸ”Š" if track.get("@attr", {}).get("nowplaying") == "true" else "" + date_str = "" + if "date" in track and "#text" in track["date"]: + date_str = f" β€” {track['date']['#text']}" + album_str = f" | *{album}*" if album else "" + lines.append(f" {i}. {now}**{name}** by {artist}{album_str}{date_str}") + + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !toptracks -------------------------------------------------- + +async def cmd_toptracks(room, message, bot, args): + """Handle !toptracks [user] [period]""" + 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) + summary = f"πŸ† {display_name} β€” Top Tracks ({period_label})" + lines = [] + for i, track in enumerate(tracks[:10], 1): + artist = safe_text(track, "artist") + name = safe_text(track, "name") + playcount = safe_int(track, "playcount") + lines.append(f" {i}. **{name}** by {artist} β€” *{playcount} plays*") + + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !topartists ------------------------------------------------- + +async def cmd_topartists(room, message, bot, args): + """Handle !topartists [user] [period]""" + 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) + summary = f"🎀 {display_name} β€” Top Artists ({period_label})" + lines = [] + for i, artist in enumerate(artists[:10], 1): + name = safe_text(artist, "name") + playcount = safe_int(artist, "playcount") + lines.append(f" {i}. **{name}** β€” *{playcount} plays*") + + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !topalbums -------------------------------------------------- + +async def cmd_topalbums(room, message, bot, args): + """Handle !topalbums [user] [period]""" + 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) + summary = f"πŸ’Ώ {display_name} β€” Top Albums ({period_label})" + lines = [] + for i, album in enumerate(albums[:10], 1): + artist = safe_text(album, "artist") + name = safe_text(album, "name") + playcount = safe_int(album, "playcount") + lines.append(f" {i}. **{name}** by {artist} β€” *{playcount} plays*") + + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !loved ------------------------------------------------------ + +async def cmd_loved(room, message, bot, args): + """Handle !loved [user]""" + 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")) + summary = f"❀️ {display_name} β€” Loved Tracks ({len(tracks)} of {total})" + lines = [] + for i, track in enumerate(tracks[:10], 1): + artist = safe_text(track, "artist") + name = safe_text(track, "name") + date_str = "" + if "date" in track and "#text" in track["date"]: + date_str = f" β€” {track['date']['#text']}" + lines.append(f" {i}. **{name}** by {artist}{date_str}") + + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !profile ---------------------------------------------------- + +async def cmd_profile(room, message, bot, args): + """Handle !profile [user]""" + 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 + + user_info = data.get("user", {}) + if not user_info: + await bot.api.send_text_message(room.room_id, f"πŸ” User {lastfm_user} not found.") + return + + real_name = user_info.get("realname", "") + country = user_info.get("country", "Unknown") + playcount = safe_int(user_info, "playcount") + playlists = safe_int(user_info, "playlists") + registered = user_info.get("registered", {}).get("#text", "Unknown") + url = user_info.get("url", "") + subscriber = "βœ…" if user_info.get("subscriber", "0") == "1" else "❌" + + summary = f"πŸ‘€ Profile: {display_name} ({lastfm_user})" + lines = [ + f" β€’ **Last.fm:** [{lastfm_user}]({url})" if url else f" β€’ **Last.fm:** {lastfm_user}", + f" β€’ **Real Name:** {real_name}" if real_name else "", + f" β€’ **Country:** {country}", + f" β€’ **Registered:** {registered}", + f" β€’ **Total Plays:** {playcount:,}", + f" β€’ **Playlists:** {playlists}", + f" β€’ **Subscriber:** {subscriber}", + ] + lines = [l for l in lines if l] + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !playcount -------------------------------------------------- + +async def cmd_playcount(room, message, bot, args): + """Handle !playcount [user] - short output, no collapsible needed.""" + 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 + + playcount = safe_int(data.get("user", {}), "playcount") + await bot.api.send_markdown_message( + room.room_id, f"πŸ”’ **{display_name}** has scrobbled **{playcount:,}** tracks total." + ) + + +# ---- !scrobbles -------------------------------------------------- + +async def cmd_scrobbles(room, message, bot, args): + """Handle !scrobbles [user] - detailed scrobbling stats""" + matrix_user = str(message.sender) + lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room) + if not lastfm_user: + return + + info_data = await call_lastfm_api("user.getInfo", {"user": lastfm_user}, bot, room) + if not info_data: + return + + user_info = info_data.get("user", {}) + playcount = safe_int(user_info, "playcount") + registered = user_info.get("registered", {}).get("#text", "Unknown") + artist_count = safe_int(user_info, "artist_count", 0) + + recent_data = await call_lastfm_api( + "user.getRecentTracks", {"user": lastfm_user, "limit": "200"}, bot, room + ) + today_count = 0 + if recent_data: + tracks = recent_data.get("recenttracks", {}).get("track", []) + today = datetime.utcnow().strftime("%d %b %Y") + for track in tracks: + if "date" in track and "#text" in track["date"]: + if today in track["date"]["#text"]: + today_count += 1 + + try: + reg_date = datetime.strptime(registered, "%d %b %Y") + days_since = max((datetime.utcnow() - reg_date).days, 1) + avg_per_day = round(playcount / days_since, 1) + except (ValueError, TypeError): + avg_per_day = "?" + + summary = f"πŸ“Š {display_name} β€” Scrobbling Stats" + lines = [ + f" β€’ **Total Scrobbles:** {playcount:,}", + f" β€’ **Unique Artists:** {artist_count:,}" if artist_count else "", + f" β€’ **Registered:** {registered}", + f" β€’ **Avg Scrobbles/Day:** {avg_per_day}", + f" β€’ **Today's Scrobbles:** {today_count}", + ] + lines = [l for l in lines if l] + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !compare ---------------------------------------------------- + +async def cmd_compare(room, message, bot, args): + """Handle !compare """ + if len(args) < 2: + await bot.api.send_text_message( + room.room_id, "Usage: !compare \nExample: !compare alice bob" + ) + return + + user1, user2 = args[0].strip(), args[1].strip() + + data1 = await call_lastfm_api( + "user.getTopArtists", {"user": user1, "period": "overall", "limit": "50"}, bot, room + ) + data2 = await call_lastfm_api( + "user.getTopArtists", {"user": user2, "period": "overall", "limit": "50"}, bot, room + ) + + if not data1 or not data2: + return + + artists1 = {safe_text(a, "name").lower(): safe_int(a, "playcount") + for a in data1.get("topartists", {}).get("artist", [])} + artists2 = {safe_text(a, "name").lower(): safe_int(a, "playcount") + for a in data2.get("topartists", {}).get("artist", [])} + + set1, set2 = set(artists1.keys()), set(artists2.keys()) + common = set1 & set2 + only1 = set1 - set2 + only2 = set2 - set1 + + similarity = round(len(common) / max(len(set1 | set2), 1) * 100, 1) if (set1 | set2) else 0 + + summary = f"πŸ”„ Musical Taste Comparison: {user1} vs {user2}" + lines = [ + f" β€’ **Taste Similarity:** {similarity}%", + f" β€’ **Common Artists:** {len(common)}", + f" β€’ **Unique to {user1}:** {len(only1)}", + f" β€’ **Unique to {user2}:** {len(only2)}", + ] + if common: + top_common = sorted(common, key=lambda a: artists1[a] + artists2.get(a, 0), reverse=True)[:5] + lines.append(f" β€’ **Top Shared:** {', '.join(top_common)}") + + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !taste ------------------------------------------------------ + +async def cmd_taste(room, message, bot, args): + """Handle !taste [user] - top artists with taste-o-meter""" + 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_plays = sum(safe_int(a, "playcount") for a in artists) + if total_plays == 0: + total_plays = 1 + + summary = f"🎯 {display_name} β€” Taste-o-Meter" + lines = [] + for i, artist in enumerate(artists[:15], 1): + name = safe_text(artist, "name") + pc = safe_int(artist, "playcount") + pct = round(pc / total_plays * 100, 1) if total_plays else 0 + bar = "β–ˆ" * min(int(pct * 2), 20) + lines.append(f" {i:2}. **{name}** {bar} {pct}%") + + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !friends ---------------------------------------------------- + +async def cmd_friends(room, message, bot, args): + """Handle !friends [user]""" + 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")) + summary = f"πŸ‘₯ {display_name} β€” Friends ({len(friends)} of {total})" + lines = [] + for f in friends[:15]: + fname = safe_text(f, "name") + realname = f.get("realname", "") + now = "" + if "recenttrack" in f: + rt = f["recenttrack"] + now = f" β€” 🎡 {safe_text(rt, 'artist')} - {safe_text(rt, 'name')}" + display = f"{fname} ({realname})" if realname else fname + lines.append(f" β€’ **{display}**{now}") + + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !recommend -------------------------------------------------- + +async def cmd_recommend(room, message, bot, args): + """Handle !recommend [user] - artist recommendations""" + matrix_user = str(message.sender) + lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room) + if not lastfm_user: + return + + top_data = await call_lastfm_api( + "user.getTopArtists", {"user": lastfm_user, "period": "3month", "limit": "5"}, bot, room + ) + if not top_data: + return + + top_artists = [safe_text(a, "name") for a in top_data.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) + recommendations = [] + + for artist_name in top_artists[:3]: + sim_data = await call_lastfm_api( + "artist.getSimilar", {"artist": artist_name, "limit": "5", "autocorrect": "1"}, bot + ) + if sim_data: + for a in sim_data.get("similarartists", {}).get("artist", []): + name = safe_text(a, "name") + match = float(a.get("match", "0")) + if name.lower() not in seen: + seen.add(name.lower()) + recommendations.append((name, match, artist_name)) + + recommendations.sort(key=lambda x: x[1], reverse=True) + recommendations = recommendations[:15] + + if not recommendations: + await bot.api.send_text_message(room.room_id, "No recommendations found.") + return + + summary = f"πŸ’‘ Recommendations for {display_name} (based on top artists)" + lines = [] + for i, (name, match, source) in enumerate(recommendations, 1): + pct = round(match * 100) + lines.append(f" {i}. **{name}** β€” {pct}% match (via {source})") + + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !similar ---------------------------------------------------- + +async def cmd_similar(room, message, bot, args): + """Handle !similar """ + if not args: + await bot.api.send_text_message(room.room_id, "Usage: !similar ") + return + + artist_name = " ".join(args) + data = await call_lastfm_api( + "artist.getSimilar", {"artist": artist_name, "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 **{artist_name}**.") + return + + summary = f"πŸ”— Similar to {artist_name}" + lines = [] + for i, a in enumerate(artists[:15], 1): + name = safe_text(a, "name") + match_pct = round(float(a.get("match", "0")) * 100) + lines.append(f" {i}. **{name}** β€” {match_pct}% match") + + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !tag -------------------------------------------------------- + +async def cmd_tag(room, message, bot, args): + """Handle !tag """ + if not args: + await bot.api.send_text_message(room.room_id, "Usage: !tag \nExample: !tag metal") + 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 + + summary = f"🏷️ Top Artists tagged '{tag}'" + lines = [] + for i, a in enumerate(artists[:15], 1): + name = safe_text(a, "name") + count = safe_int(a, "count") + lines.append(f" {i}. **{name}** β€” *{count} taggings*") + + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !charts ----------------------------------------------------- + +async def cmd_charts(room, message, bot, args): + """Handle !charts - global top tracks""" + 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 + + summary = "🌍 Global Top Tracks" + lines = [] + for i, track in enumerate(tracks[:10], 1): + artist = safe_text(track, "artist") + name = safe_text(track, "name") + listeners = safe_int(track, "listeners") + lines.append(f" {i}. **{name}** by {artist} β€” *{listeners:,} listeners*") + + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !tagcloud --------------------------------------------------- + +async def cmd_tagcloud(room, message, bot, args): + """Handle !tagcloud [user]""" + 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 + + summary = f"☁️ {display_name} β€” Tag Cloud" + tag_strs = [] + for tag in tags: + name = safe_text(tag, "name") + count = safe_int(tag, "count") + tag_strs.append(f"{name}({count})") + + lines = [" " + " β€’ ".join(tag_strs[:30])] + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !now -------------------------------------------------------- + +async def cmd_now(room, message, bot, args): + """Handle !now - what are all registered users playing?""" + 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 + + summary = "🎡 Now Playing Across Registered Users" + lines = [] + 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 + track = tracks[0] if isinstance(tracks, list) else tracks + if track.get("@attr", {}).get("nowplaying") == "true": + artist = safe_text(track, "artist") + name = safe_text(track, "name") + lines.append(f" β€’ **{lfm_user}**: {name} by {artist}") + found = True + + if not found: + lines.append(" β€’ Nobody is currently scrobbling.") + + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !decades ---------------------------------------------------- + +async def cmd_decades(room, message, bot, args): + """Handle !decades [user] - favorite decades""" + matrix_user = str(message.sender) + lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room) + if not lastfm_user: + return + + top_data = await call_lastfm_api( + "user.getTopArtists", {"user": lastfm_user, "period": "overall", "limit": "20"}, bot, room + ) + if not top_data: + return + + artists = top_data.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 artist_obj in artists[:10]: + artist_name = safe_text(artist_obj, "name") + playcount = safe_int(artist_obj, "playcount") + tag_data = await call_lastfm_api( + "artist.getTopTags", {"artist": artist_name, "autocorrect": "1"}, bot + ) + if tag_data: + for tag in tag_data.get("toptags", {}).get("tag", []): + tag_name = safe_text(tag, "name").lower() + if tag_name.endswith("s") and len(tag_name) == 3 and tag_name[:2].isdigit(): + decade = tag_name + decade_counts[decade] = decade_counts.get(decade, 0) + playcount + elif tag_name.endswith("s") and len(tag_name) == 5 and tag_name[:4].isdigit(): + decade = tag_name + decade_counts[decade] = decade_counts.get(decade, 0) + playcount + + if not decade_counts: + await bot.api.send_text_message( + room.room_id, f"Could not determine decade preferences for {lastfm_user}." + ) + return + + sorted_decades = sorted(decade_counts.items(), key=lambda x: x[1], reverse=True) + total = sum(decade_counts.values()) + summary = f"πŸ“… {display_name} β€” Favorite Decades" + lines = [] + for decade, count in sorted_decades[:8]: + pct = round(count / total * 100, 1) if total else 0 + bar = "β–ˆ" * min(int(pct * 2), 20) + lines.append(f" β€’ **{decade}** {bar} {pct}%") + + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !genres ----------------------------------------------------- + +async def cmd_genres(room, message, bot, args): + """Handle !genres [user] - top genres/tags""" + 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 + + summary = f"🎢 {display_name} β€” Top Genres" + lines = [] + for i, tag in enumerate(tags[:15], 1): + name = safe_text(tag, "name") + count = safe_int(tag, "count") + lines.append(f" {i}. **{name}** β€” *{count}Γ—*") + + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !era -------------------------------------------------------- + +async def cmd_era(room, message, bot, args): + """Handle !era """ + if not args: + await bot.api.send_text_message(room.room_id, "Usage: !era \nExample: !era 1994") + 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 = f"{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 + + summary = f"πŸ•°οΈ Popular Tracks β€” {year}" + lines = [] + for i, track in enumerate(tracks[:10], 1): + artist = safe_text(track, "artist") + name = safe_text(track, "name") + lines.append(f" {i}. **{name}** by {artist}") + + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !weekly ----------------------------------------------------- + +async def cmd_weekly(room, message, bot, args): + """Handle !weekly [user]""" + 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 + + summary = f"πŸ“… {display_name} β€” Weekly Report" + lines = [] + total_plays = 0 + for i, track in enumerate(tracks[:10], 1): + artist = safe_text(track, "artist") + name = safe_text(track, "name") + playcount = safe_int(track, "playcount") + total_plays += playcount + lines.append(f" {i}. **{name}** by {artist} β€” *{playcount} plays*") + + header = f" β€’ **Total unique tracks:** {len(tracks)} | **Total plays this week:** {total_plays}
" + body = header + "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !monthly ---------------------------------------------------- + +async def cmd_monthly(room, message, bot, args): + """Handle !monthly [user] - last 30 days""" + 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 track in tracks: + name = safe_text(track, "name") + artist = safe_text(track, "artist") + key = f"{name}|||{artist}" + track_counts[key] = track_counts.get(key, 0) + 1 + artist_counts[artist] = artist_counts.get(artist, 0) + 1 + + total = len(tracks) + unique_tracks = len(track_counts) + unique_artists = len(artist_counts) + + top_tracks = sorted(track_counts.items(), key=lambda x: x[1], reverse=True)[:10] + top_artists = sorted(artist_counts.items(), key=lambda x: x[1], reverse=True)[:5] + + summary = f"πŸ“† {display_name} β€” Monthly Report (Last 30 Days)" + lines = [ + f" β€’ **Total Scrobbles:** {total} | **Unique Tracks:** {unique_tracks} | **Unique Artists:** {unique_artists}", + "
Top Tracks:", + ] + for i, (key, count) in enumerate(top_tracks, 1): + name, artist = key.split("|||", 1) + lines.append(f" {i}. **{name}** by {artist} β€” *{count} plays*") + + lines.append("
Top Artists:") + for i, (artist, count) in enumerate(top_artists, 1): + lines.append(f" {i}. **{artist}** β€” *{count} plays*") + + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !yearly ----------------------------------------------------- + +async def cmd_yearly(room, message, bot, args): + """Handle !yearly [user] [year]""" + matrix_user = str(message.sender) + year = None + user_arg = list(args) + + if user_arg: + last = user_arg[-1] + if last.isdigit() and len(last) == 4: + year = int(last) + 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 track in tracks: + name = safe_text(track, "name") + artist = safe_text(track, "artist") + key = f"{name}|||{artist}" + track_counts[key] = track_counts.get(key, 0) + 1 + artist_counts[artist] = artist_counts.get(artist, 0) + 1 + + top_tracks = sorted(track_counts.items(), key=lambda x: x[1], reverse=True)[:10] + top_artists = sorted(artist_counts.items(), key=lambda x: x[1], reverse=True)[:5] + + summary = f"πŸ“† {display_name} β€” Yearly Report ({year})" + lines = [ + f" β€’ **Total Scrobbles:** {len(tracks)} | **Unique Tracks:** {len(track_counts)} | **Unique Artists:** {len(artist_counts)}", + "
Top Tracks:", + ] + for i, (key, count) in enumerate(top_tracks, 1): + name, artist = key.split("|||", 1) + lines.append(f" {i}. **{name}** by {artist} β€” *{count} plays*") + + lines.append("
Top Artists:") + for i, (artist, count) in enumerate(top_artists, 1): + lines.append(f" {i}. **{artist}** β€” *{count} plays*") + + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !first ------------------------------------------------------ + +async def cmd_first(room, message, bot, args): + """Handle !first [user]""" + 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: + if " " 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 track in tracks: + track_artist = safe_text(track, "artist") + if artist_name.lower() in track_artist.lower(): + date_str = "" + if "date" in track and "#text" in track["date"]: + date_str = track["date"]["#text"] + matches.append((track, date_str)) + + if not matches: + await bot.api.send_text_message( + room.room_id, + f"πŸ” No scrobbles of **{artist_name}** found for {display_name} (within recent history).", + ) + return + + oldest_track, oldest_date = matches[-1] + name = safe_text(oldest_track, "name") + track_artist = safe_text(oldest_track, "artist") + + await bot.api.send_markdown_message( + room.room_id, + f"πŸ” **{display_name}** first scrobbled **{artist_name}** with:\n" + f" β€’ **{name}** by {track_artist}\n" + f" β€’ πŸ“… {oldest_date if oldest_date else 'Unknown date'}", + ) + + +# ---- !concerts --------------------------------------------------- + +async def cmd_concerts(room, message, bot, args): + """Handle !concerts [user] - upcoming concerts for top artists""" + matrix_user = str(message.sender) + lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room) + if not lastfm_user: + return + + top_data = await call_lastfm_api( + "user.getTopArtists", {"user": lastfm_user, "period": "3month", "limit": "10"}, bot, room + ) + if not top_data: + return + + artists = [safe_text(a, "name") for a in top_data.get("topartists", {}).get("artist", [])] + if not artists: + return + + await bot.api.send_text_message(room.room_id, "πŸ” Searching for upcoming concerts...") + + all_events = [] + for artist_name in artists[:5]: + ev_data = await call_lastfm_api( + "artist.getEvents", {"artist": artist_name, "limit": "3", "autocorrect": "1"}, bot + ) + if ev_data: + for ev in ev_data.get("events", {}).get("event", [])[:3]: + title = safe_text(ev, "title") + venue_name = safe_text(ev.get("venue", {}), "name", "Unknown Venue") + city = safe_text(ev.get("venue", {}).get("location", {}), "city", "") + country = safe_text(ev.get("venue", {}).get("location", {}), "country", "") + start_date = safe_text(ev, "startDate", "TBD") + location = f"{city}, {country}" if city else country + all_events.append((title, artist_name, venue_name, location, start_date)) + + 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 + + summary = f"🎫 Upcoming Concerts for {display_name}'s Top Artists ({len(all_events)} found)" + lines = [] + for title, artist, venue, location, date in all_events[:15]: + lines.append(f" β€’ **{artist}** β€” {title}") + lines.append(f" πŸ“ {venue}, {location} | πŸ“… {date}") + + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !radio ------------------------------------------------------ + +async def cmd_radio(room, message, bot, args): + """Handle !radio - generate playlist from similar artists""" + if not args: + await bot.api.send_text_message(room.room_id, "Usage: !radio ") + return + + artist_name = " ".join(args) + sim_data = await call_lastfm_api( + "artist.getSimilar", {"artist": artist_name, "limit": "10", "autocorrect": "1"}, bot, room + ) + if not sim_data: + return + + similar = sim_data.get("similarartists", {}).get("artist", []) + if not similar: + await bot.api.send_text_message(room.room_id, f"No similar artists for **{artist_name}**.") + return + + playlist = [] + for sim in similar[:8]: + sim_name = safe_text(sim, "name") + top_data = await call_lastfm_api( + "artist.getTopTracks", {"artist": sim_name, "limit": "1", "autocorrect": "1"}, bot + ) + if top_data: + tracks = top_data.get("toptracks", {}).get("track", []) + if tracks: + track = tracks[0] if isinstance(tracks, list) else tracks + tname = safe_text(track, "name") + playlist.append((sim_name, tname)) + + if not playlist: + await bot.api.send_text_message(room.room_id, "Could not generate playlist.") + return + + summary = f"πŸ“» Radio: {artist_name} β€” Similar Artists Playlist ({len(playlist)} tracks)" + lines = [] + for i, (art, track) in enumerate(playlist, 1): + yt = await get_youtube_link(art, track) + yt_str = f" | [▢️ YouTube]({yt})" if yt else "" + lines.append(f" {i}. **{track}** by {art}{yt_str}") + + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !mashup ----------------------------------------------------- + +async def cmd_mashup(room, message, bot, args): + """Handle !mashup - find musical connections""" + 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) + artist1, artist2 = parts[0].strip(), parts[1].strip() + else: + mid = len(args) // 2 + artist1 = " ".join(args[:mid]) + artist2 = " ".join(args[mid:]) + + data1 = await call_lastfm_api( + "artist.getSimilar", {"artist": artist1, "limit": "20", "autocorrect": "1"}, bot, room + ) + data2 = await call_lastfm_api( + "artist.getSimilar", {"artist": artist2, "limit": "20", "autocorrect": "1"}, bot, room + ) + + if not data1 or not data2: + return + + sim1 = {safe_text(a, "name").lower(): float(a.get("match", 0)) + for a in data1.get("similarartists", {}).get("artist", [])} + sim2 = {safe_text(a, "name").lower(): float(a.get("match", 0)) + for a in data2.get("similarartists", {}).get("artist", [])} + + common = set(sim1.keys()) & set(sim2.keys()) + + summary = f"πŸ”€ Mashup: {artist1} ↔ {artist2}" + lines = [] + + if common: + shared = sorted(common, key=lambda a: sim1[a] + sim2[a], reverse=True)[:10] + lines.append(f" β€’ **Shared similar artists:** {len(common)}") + lines.append(" β€’ **Top connections:**") + for a in shared: + avg = round((sim1[a] + sim2[a]) / 2 * 100) + lines.append(f" - **{a}** ({avg}% avg match)") + else: + lines.append(" β€’ No direct musical connections found between these artists.") + + tags1_data = await call_lastfm_api( + "artist.getTopTags", {"artist": artist1, "autocorrect": "1"}, bot + ) + tags2_data = await call_lastfm_api( + "artist.getTopTags", {"artist": artist2, "autocorrect": "1"}, bot + ) + + tags1 = set() + tags2 = set() + if tags1_data: + tags1 = {safe_text(t, "name").lower() for t in tags1_data.get("toptags", {}).get("tag", [])} + if tags2_data: + tags2 = {safe_text(t, "name").lower() for t in tags2_data.get("toptags", {}).get("tag", [])} + + common_tags = tags1 & tags2 + if common_tags: + lines.append(f" β€’ **Shared genres:** {', '.join(sorted(common_tags)[:8])}") + + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# =================================================================== +# !collage – new ImageMagick-based implementation +# =================================================================== +# ------------------------------------------------------------ +# !collage command (using ImageMagick) +# ------------------------------------------------------------ +async def cmd_collage(room, message, bot, args): + """Handle !collage [user] [size] – create album art collage via ImageMagick.""" + 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 + + # 1. get top albums + 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 + + # 2. download all covers concurrently + timeout = aiohttp.ClientTimeout(total=60) + async with aiohttp.ClientSession(timeout=timeout, headers=HEADERS) as session: + tasks = [download_album_art_to_file(session, alb) for alb in albums[:size * size]] + results = await asyncio.gather(*tasks) + + # results = list of (artist, album_name, filepath) + 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 + + # 3. create a white placeholder tile for missing images + placeholder_path = os.path.join(tempfile.gettempdir(), "lastfm_placeholder.png") + subprocess.run(["convert", "-size", "200x200", "xc:white", placeholder_path], check=True) + + # Build ordered list of files (placeholder where missing) + file_list = [] + for _, _, path in results: + file_list.append(path if path else placeholder_path) + while len(file_list) < size * size: + file_list.append(placeholder_path) + + # 4. Use ImageMagick montage to stitch the grid + 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 as e: + logging.error(f"montage failed: {e}") + # fallback: send the first downloaded image + if downloaded: + collage_path = downloaded[0][2] + else: + await bot.api.send_text_message(room.room_id, "Failed to create collage.") + return + + # 5. send the image + await bot.api.send_image_message(room_id=room.room_id, image_filepath=collage_path) + + # 6. collapsible text details + summary = f"πŸ–ΌοΈ {display_name} β€” Album Collage ({size}Γ—{size})" + lines = [f"Top {size*size} albums for {display_name}"] + for i, album in enumerate(albums[:size * size], 1): + artist = album_artist_name(album) + name = safe_text(album, "name") + playcount = safe_int(album, "playcount") + lines.append(f" {i}. **{name}** by {artist} β€” *{playcount} plays*") + body = "
".join(lines) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + # Cleanup temp files + for _, _, path in downloaded: + if path and os.path.exists(path): + os.remove(path) + if os.path.exists(placeholder_path): + os.remove(placeholder_path) + if os.path.exists(collage_path): + os.remove(collage_path) + + +# ---- !listening -------------------------------------------------- + +async def cmd_listening(room, message, bot, args): + """Handle !listening [user] - what's playing with album art""" + 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 + + track = tracks[0] if isinstance(tracks, list) else tracks + now_playing = track.get("@attr", {}).get("nowplaying", "false") == "true" + artist = safe_text(track, "artist") + name = safe_text(track, "name") + album = safe_text(track, "album", "") + + image_url = "" + for img in track.get("image", []): + if img.get("size") == "extralarge": + image_url = img.get("#text", "") + break + if not image_url: + for img in track.get("image", []): + image_url = img.get("#text", "") + if image_url: + break + + action = "is listening to" if now_playing else "last listened to" + summary = f"🎧 {display_name} {action}: {name} by {artist}" + lines = [] + if image_url: + lines.append(f" ![Album Art]({image_url})") + 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, wrap_collapsible(summary, body)) + + +# ---- !awards ----------------------------------------------------- + +async def cmd_awards(room, message, bot, args): + """Handle !awards [user] - milestone achievements""" + matrix_user = str(message.sender) + lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room) + if not lastfm_user: + return + + info_data = await call_lastfm_api("user.getInfo", {"user": lastfm_user}, bot, room) + if not info_data: + return + + user_info = info_data.get("user", {}) + playcount = safe_int(user_info, "playcount") + artist_count = safe_int(user_info, "artist_count", 0) + registered = user_info.get("registered", {}).get("#text", "Unknown") + + achievements = [] + + milestones = [ + (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"), + ] + for threshold, title in milestones: + if playcount >= threshold: + achievements.append(f" β€’ {title} β€” {threshold:,}+ scrobbles") + + if artist_count >= 10: + achievements.append(f" β€’ 🌿 Explorer β€” 10+ artists") + if artist_count >= 50: + achievements.append(f" β€’ 🌳 Curator β€” 50+ artists") + if artist_count >= 100: + achievements.append(f" β€’ 🌍 Globetrotter β€” 100+ artists") + if artist_count >= 500: + achievements.append(f" β€’ 🌌 Universe Explorer β€” 500+ artists") + if artist_count >= 1000: + achievements.append(f" β€’ πŸš€ Cosmopolitan β€” 1,000+ artists") + + try: + reg_date = datetime.strptime(registered, "%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(f" β€’ πŸ… Loyalist β€” 5+ years on Last.fm") + if years >= 10: + achievements.append(f" β€’ πŸŽ–οΈ Decade Club β€” 10+ years on Last.fm") + except (ValueError, TypeError): + pass + + if user_info.get("subscriber", "0") == "1": + achievements.append(f" β€’ ⭐ Subscriber β€” Supporting Last.fm") + + if not achievements: + achievements.append(" β€’ πŸ†• Keep scrobbling to earn achievements!") + + summary = f"πŸ† {display_name} β€” Achievements" + header = f" β€’ Total Scrobbles: **{playcount:,}** | Artists: **{artist_count:,}**
" + body = header + "
".join(achievements) + await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body)) + + +# ---- !lastfm ----------------------------------------------------- + +async def cmd_lastfm_help(room, message, bot, args): + """Handle !lastfm - show help for all Last.fm plugin commands.""" + help_text = """ +
🎡 Last.fm Plugin Commands +

+Registration & Now Playing
+β€’ !register <username> - Register your Last.fm username
+β€’ !np [user] - Show currently playing track (no collapsible)
+
+Recent & Loved
+β€’ !recent [user] [limit] - Recent tracks (default 10, max 50)
+β€’ !loved [user] - Recently loved tracks
+
+Top Lists (period: overall/7day/1month/3month/6month/12month)
+β€’ !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 (short output)
+β€’ !scrobbles [user] - Detailed scrobbling statistics
+
+Social & Comparison
+β€’ !compare <user1> <user2> - Compare two users' 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 from similar artists
+β€’ !mashup <artist1> <artist2> - Find musical 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) + + +# =================================================================== +# MAIN DISPATCH +# =================================================================== async def handle_command(room, message, bot, prefix, config): """ - Function to handle the !register and !np commands. - - Args: - room (Room): The Matrix room where the command was invoked. - message (RoomMessage): The message object containing the command. - bot (Bot): The bot object. - prefix (str): The command prefix. - config (dict): Configuration parameters. - - Returns: - None + Main command dispatcher for the Last.fm plugin. + Preserves all existing functionality and adds comprehensive new commands. """ match = botlib.MessageMatch(room, message, bot, prefix) # Initialize database on first run await init_db() - if match.is_not_from_this_bot() and match.prefix() and match.command("register"): - args = match.args() + if not (match.is_not_from_this_bot() and match.prefix()): + return - if len(args) < 1: + command = match.command() + args = match.args() + + # Command routing table + command_map = { + "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 = command_map.get(command) + if handler: + try: + await handler(room, message, bot, args) + except Exception as e: + logging.error(f"Error in Last.fm command '{command}': {e}") await bot.api.send_text_message( - room.room_id, - "Usage: !register " + room.room_id, f"❌ Error processing !{command}: {str(e)}" ) - return - - lastfm_user = args[0].strip() - matrix_user = str(message.sender) - - # Register the Last.fm username - await set_lastfm_username(matrix_user, lastfm_user) - - # Confirm registration - await bot.api.send_text_message( - room.room_id, - f"βœ… Registered Last.fm user {lastfm_user} for {matrix_user}" - ) - - logging.info(f"Registered Last.fm user {lastfm_user} for {matrix_user}") - - elif match.is_not_from_this_bot() and match.prefix() and match.command("np"): - # Get currently playing track - matrix_user = str(message.sender) - lastfm_user = await get_lastfm_username(matrix_user) - - if not lastfm_user: - await bot.api.send_text_message( - room.room_id, - f"Please register your Last.fm username first with !register \n" - f"Example: !register your_lastfm_username" - ) - return - - # Get recent track data - track_data = await get_recent_track(lastfm_user) - - # Format and send results - result_message = await format_recent_track(matrix_user, lastfm_user, track_data) - await bot.api.send_markdown_message(room.room_id, result_message) - logging.info(f"Sent Last.fm now playing information for {lastfm_user}") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 833927e..d48d037 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,4 +15,6 @@ pyopenssl psutil toml python-whois -aiohttp \ No newline at end of file +aiohttp +aiosqlite +pillow \ No newline at end of file