feat(halo): add song <URL> 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.
This commit is contained in:
Harald Hoyer 2026-05-20 09:42:11 +02:00
parent ac70c57c15
commit 5b44e037a1

View file

@ -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 <URL>` 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)