From 5b44e037a17a8f7f62b16e968ef4c65a0b9ee024 Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Wed, 20 May 2026 09:42:11 +0200 Subject: [PATCH] feat(halo): add `song ` command to convert via song.link Resolves the URL through the Odesli public API (api.song.link) and replies with the canonical song.link page plus per-platform deep links (Spotify, Apple Music, YouTube/YT Music, Tidal, Deezer, Amazon Music, SoundCloud). Country is pinned to DE. --- .../mx/nextcloud-opencode-bot/bot.py | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/systems/x86_64-linux/mx/nextcloud-opencode-bot/bot.py b/systems/x86_64-linux/mx/nextcloud-opencode-bot/bot.py index a2190db..3d96146 100644 --- a/systems/x86_64-linux/mx/nextcloud-opencode-bot/bot.py +++ b/systems/x86_64-linux/mx/nextcloud-opencode-bot/bot.py @@ -30,6 +30,19 @@ TIMEOUT = int(os.environ.get("TIMEOUT", "120")) SYSTEM_PROMPT = os.environ.get("SYSTEM_PROMPT", "") BOT_NAME = os.environ.get("BOT_NAME", "Halo") +SONGLINK_API = "https://api.song.link/v1-alpha.1/links" +SONGLINK_COUNTRY = "DE" +SONGLINK_PLATFORMS = [ + ("spotify", "Spotify"), + ("appleMusic", "Apple Music"), + ("youtube", "YouTube"), + ("youtubeMusic", "YouTube Music"), + ("tidal", "Tidal"), + ("deezer", "Deezer"), + ("amazonMusic", "Amazon Music"), + ("soundcloud", "SoundCloud"), +] + def get_bot_secret() -> str: cred_path = os.environ.get("CREDENTIALS_DIRECTORY", "") @@ -159,6 +172,52 @@ async def call_model(messages: list[dict]) -> str: return f"❌ Fehler: {str(e)}" +async def resolve_song_link(url: str) -> str: + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + SONGLINK_API, + params={"url": url, "userCountry": SONGLINK_COUNTRY}, + ) + except httpx.TimeoutException: + return "⏱️ Timeout bei der Anfrage an song.link." + except Exception as e: + log.exception("song.link request failed") + return f"❌ Fehler bei song.link: {e}" + + if resp.status_code != 200: + log.error(f"song.link error: {resp.status_code} {resp.text[:300]}") + return f"❌ song.link Fehler: HTTP {resp.status_code}" + + data = resp.json() + page_url = data.get("pageUrl") + if not page_url: + return "❌ song.link hat keine Seite zurückgegeben." + + entity_id = data.get("entityUniqueId") + entity = data.get("entitiesByUniqueId", {}).get(entity_id, {}) if entity_id else {} + title = entity.get("title") + artist = entity.get("artistName") + + lines: list[str] = [] + if title: + header = f"**{title}**" + (f" – {artist}" if artist else "") + lines.append(header) + lines.append(page_url) + + links = data.get("linksByPlatform", {}) + platform_lines = [] + for key, label in SONGLINK_PLATFORMS: + platform_url = links.get(key, {}).get("url") + if platform_url: + platform_lines.append(f"- [{label}]({platform_url})") + if platform_lines: + lines.append("") + lines.extend(platform_lines) + + return "\n".join(lines) + + async def send_reply(conversation_token: str, message: str, reply_to: int = None): if not NEXTCLOUD_URL: log.error("NEXTCLOUD_URL not configured") @@ -281,12 +340,21 @@ Schreib mir einfach eine Nachricht und ich antworte dir. **Befehle:** • `hilfe` oder `?` – Diese Hilfe anzeigen +• `song ` – Streaming-Link über song.link konvertieren Modell: `{MODEL_NAME}` @ `{MODEL_BASE_URL}` Der Bot merkt sich die letzten Nachrichten pro Raum (bis zum Neustart).""" await send_reply(conversation_token, help_text, reply_to=message_id) return JSONResponse({"status": "ok", "action": "help"}) + song_match = re.match(r'^\s*song\s+(\S+)', message_text, re.IGNORECASE) + if song_match: + song_url = song_match.group(1).strip() + log.info(f"Resolving song.link for {song_url}") + reply = await resolve_song_link(song_url) + await send_reply(conversation_token, reply, reply_to=message_id) + return JSONResponse({"status": "ok", "action": "song"}) + messages = build_messages(conversation_token, message_text, actor_id) response = await call_model(messages)