commit a9a4d825598d567aacec2589baadd0ae71f6e243 Author: teraflops Date: Mon Jun 2 16:51:22 2025 +0200 initial commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b0f1ed0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2025 teraflops + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..8653f01 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# Roon Now Playing Extension + +This repository contains a Now Playing extension for [Roon](https://roonlabs.com/) that displays current track metadata, including cover art and lyrics. It integrates with the official [roon-kit](https://github.com/Stevenic/roon-kit) API and uses a Python script to fetch lyrics from the Musixmatch API + +## Contents + +- `roon_extension.js`: Main Roon extension that listens to playback events and displays Now Playing information. +- `get_lyrics.py`: Python script that fetches lyrics from Musixmatch based on artist and song title. + +## Requirements + +### Node.js (for `roon_extension.js`) +- [roon-kit](https://github.com/Stevenic/roon-kit) +- Node.js 18+ + +Install dependencies: +```bash +npm install roon-kit +``` +on Arch Linux: +``` +yay -S roon-kit +``` + +### Python (for `get_lyrics.py`) +- Python 3.x +- `requests` library + + +## Setup + +### API Key +This extension requires a [Musixmatch](https://developer.musixmatch.com/) API key to fetch lyrics. Save your API key to: + +```bash +~/.apikeys/musixmatch +``` + +Make sure the file contains only your API key as plain text. + +### Run the extension +Start the Roon extension using Node.js: +```bash +node roon_extension.js +``` + +Make sure Python is in your PATH and executable from the same environment. + +## Features + +- Displays Now Playing info using the Roon API. +- Automatically fetches lyrics from Musixmatch. +- Uses cover art from Roon and external metadata if available. + +### Waybar config +In modules add +``` +"custom/roon", +``` +then add the extension block +``` + "custom/roon": { + "exec": "cat /tmp/waybar_roon_info.json", + "format": "{text}", + "tooltip": true, + "return-type": "json", + "signal": 3 + }, +``` + +## License + +MIT License + + diff --git a/etc/systemd/user/roon-extension.service b/etc/systemd/user/roon-extension.service new file mode 100644 index 0000000..aacd422 --- /dev/null +++ b/etc/systemd/user/roon-extension.service @@ -0,0 +1,14 @@ +[Unit] +Description=Roon Extension Script +After=network.target + +[Service] +Environment=NODE_PATH=/usr/lib/node_modules +ExecStart=/usr/bin/node /usr/share/waybar/scripts/roon_extension.js +Restart=on-failure +Environment=DISPLAY=:0 +Environment=XDG_RUNTIME_DIR=/run/user/%U + +[Install] +WantedBy=default.target + diff --git a/usr/share/waybar/scripts/get_lyrics.py b/usr/share/waybar/scripts/get_lyrics.py new file mode 100755 index 0000000..99eb842 --- /dev/null +++ b/usr/share/waybar/scripts/get_lyrics.py @@ -0,0 +1,70 @@ +import sys +import requests +import json +import os + +with open(os.path.expanduser("~/.apikeys/musixmatch"), "r") as file: + musixmatch_token = file.read().strip() + +def get_lyrics(artist, song_title): + search_url = "https://api.musixmatch.com/ws/1.1/track.search" + params = { + "q_track_artist": f"{artist} {song_title}", + "apikey": musixmatch_token, + "s_track_rating": "desc", + "f_has_lyrics": "1" + } + + response = requests.get(search_url, params=params) + if response.status_code != 200: + return f"Error al buscar la canción: {response.status_code}" + + data = response.json() + tracks = data.get("message", {}).get("body", {}).get("track_list", []) + if not tracks: + return "No results found for this track." + + track_id = None + for t in tracks: + t_title = t["track"]["track_name"].lower() + t_artist = t["track"]["artist_name"].lower() + if song_title.lower() in t_title and artist.lower() in t_artist: + track_id = t["track"]["track_id"] + print("\n Filtered search result:") + print("Track found:", t["track"]["track_name"]) + print("Artist foung:", t["track"]["artist_name"]) + print("Track ID:", track_id) + print("---") + break + + if not track_id: + track_id = tracks[0]["track"]["track_id"] + print("\n generic search result:") + print("Title found:", tracks[0]["track"]["track_name"]) + print("Artist found:", tracks[0]["track"]["artist_name"]) + print("Track ID:", track_id) + print("---") + + lyrics_url = "https://api.musixmatch.com/ws/1.1/track.lyrics.get" + lyrics_params = { + "track_id": track_id, + "apikey": musixmatch_token + } + + lyrics_response = requests.get(lyrics_url, params=lyrics_params) + if lyrics_response.status_code != 200: + return f"Error obtaining lyrics: {lyrics_response.status_code}" + + lyrics_data = lyrics_response.json() + lyrics = lyrics_data.get("message", {}).get("body", {}).get("lyrics", {}).get("lyrics_body", "") + return lyrics or "no lyrics available." + +if len(sys.argv) != 3: + print("Usage: python get_lyrics.py ") + sys.exit(1) + +artist = sys.argv[1] +song_title = sys.argv[2] +lyrics = get_lyrics(artist, song_title) +print(lyrics) + diff --git a/usr/share/waybar/scripts/roon_extension.js b/usr/share/waybar/scripts/roon_extension.js new file mode 100644 index 0000000..8c3732d --- /dev/null +++ b/usr/share/waybar/scripts/roon_extension.js @@ -0,0 +1,231 @@ +const { RoonExtension } = require('roon-kit'); +const fs = require('fs'); +const { exec } = require('child_process'); + +const lyricsScriptPath = "/usr/share/waybar/scripts/get_lyrics.py"; +let pairedCore = null; +global.lastSeek = null; +global.lastZone = null; +global.lastLyrics = 'No lyrics avilale.'; + +function getLyrics(artist, title, callback) { + const cmd = `python3 ${lyricsScriptPath} "${artist}" "${title}"`; + log(`Executing: ${cmd}`); + exec(cmd, (error, stdout, stderr) => { + if (error) { + log(` Error in getLyrics: ${stderr}`); + callback("No lyrics available."); + } else { + const lyrics = stdout.trim(); + log(` Lyrics available (${lyrics.length} chars)`); + callback(lyrics || "No lyrics available."); + } + }); +} + +function truncateText(str, maxLength = 80) { + const chars = [...str]; + return chars.length > maxLength ? chars.slice(0, maxLength).join('') + '…' : str; +} + +function formatTime(seconds) { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, '0')}`; +} + +function progressBar(current, total, length = 20) { + if (!total || total === 0) return ''; + const percent = current / total; + const filled = Math.round(percent * length); + return '▰'.repeat(filled) + '▱'.repeat(length - filled); +} + +function escapeMarkup(str) { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function log(msg) { + const timestamp = new Date().toISOString(); + const fullMsg = `[${timestamp}] ${msg}`; + console.log(fullMsg); + fs.appendFileSync('/tmp/roon_debug.log', fullMsg + '\n'); +} + +const extension = new RoonExtension({ + description: { + extension_id: 'roon-kit-now-playing', + display_name: "Roon Kit Now Playing", + display_version: "0.4.2", + publisher: 'roon-kit', + email: 'stevenic@microsoft.com', + website: 'https://github.com/Stevenic/roon-kit' + }, + RoonApiBrowse: 'not_required', + RoonApiImage: 'required', + RoonApiTransport: 'required', + subscribe_outputs: false, + subscribe_zones: true, + log_level: 'none' +}); + +extension.on("subscribe_zones", async (core, response, body) => { + log("subscribe_zones event received"); + log(JSON.stringify(body, null, 2)); + + const changedZones = body.zones_changed ?? []; + const addedZones = body.zones_added ?? []; + const removedZones = body.zones_removed ?? []; + + if (removedZones.length > 0) { + fs.writeFileSync('/tmp/waybar_roon_info.json', JSON.stringify({ text: '', tooltip: '' })); + exec("pkill -RTMIN+3 waybar"); + return; + } + + let anyPlaying = false; + + for (const zone of [...addedZones, ...changedZones]) { + if (zone.state !== 'playing') { + log(` Zona "${zone.display_name}" is not playing (state: ${zone.state}), cleaning Waybar`); + fs.writeFileSync('/tmp/waybar_roon_info.json', JSON.stringify({ text: '', tooltip: '' })); + exec("pkill -RTMIN+3 waybar"); + continue; + } + + anyPlaying = true; + global.lastZone = zone; + + const rawTrack = zone.now_playing?.three_line?.line1 || ''; + const rawArtist = zone.now_playing?.three_line?.line2 || ''; + const album = zone.now_playing?.three_line?.line3 || ''; + const mediaType = zone.now_playing?.media_type || ''; + const sampleRate = zone.now_playing?.sample_rate || 0; + const bitDepth = zone.now_playing?.bits_per_sample || 0; + const channels = zone.now_playing?.channels || 0; + const year = zone.now_playing?.release_year || ''; + const genre = zone.now_playing?.genre || ''; + const duration = zone.now_playing?.length || 0; + const position = zone.now_playing?.seek_position || 0; + const remaining = Math.max(duration - position, 0); + + const bitrate = sampleRate && bitDepth && channels + ? `${Math.round(sampleRate * bitDepth * channels / 1000)} kbps` + : ''; + + const displayText = truncateText( + `${rawTrack} - ${rawArtist} • ${album} • ${formatTime(remaining)} restantes`, + 80 + ); + + const bar = progressBar(position, duration); + + const currentTrackInfo = `${rawArtist} - ${rawTrack}`; + fs.writeFileSync("/tmp/roon_track.txt", currentTrackInfo); + + getLyrics(rawArtist, rawTrack, async (lyrics) => { + global.lastLyrics = lyrics || 'No lyrics available.'; + + const headerLine = `${rawArtist} - ${rawTrack}`; + const tooltip = +`${headerLine} +Álbum: ${album} +${year || genre ? `Year: ${year} Genre: ${genre}` : ''} +Duration: ${formatTime(position)} / ${formatTime(duration)} +${bar} + +${lyrics}`; + + const data = { + text: escapeMarkup(displayText), + tooltip: escapeMarkup(tooltip) + }; + + log("writing waybar_roon_info.json with: " + JSON.stringify(data)); + fs.writeFileSync('/tmp/waybar_roon_info.json', JSON.stringify(data)); + exec("pkill -RTMIN+3 waybar"); + + const image_key = zone.now_playing?.image_key; + if (image_key && pairedCore) { + try { + const imageData = await pairedCore.services.RoonApiImage.get_image(image_key, { width: 300, height: 300 }); + const coverPath = '/tmp/roon_album_cover.jpg'; + fs.writeFileSync(coverPath, imageData.image); + exec(`notify-send -i ${coverPath} "Now Playing" "${headerLine}"`); + } catch (error) { + console.error("Error getting cover:", error); + exec(`notify-send "Now Playing" "${headerLine}"`); + } + } else { + exec(`notify-send "Now Playing" "${headerLine}"`); + } + }); + } + + // Nueva lógica: limpiar si no hay ninguna zona reproduciendo + if (!anyPlaying) { + log(" Ninguna zona está reproduciendo. Limpiando Waybar."); + fs.writeFileSync('/tmp/waybar_roon_info.json', JSON.stringify({ text: '', tooltip: '' })); + exec("pkill -RTMIN+3 waybar"); + } + + const seekUpdates = body.zones_seek_changed ?? []; + + for (const update of seekUpdates) { + const zone = global.lastZone; + if (!zone || zone.zone_id !== update.zone_id || zone.state !== "playing") continue; + + const rawTrack = zone.now_playing?.three_line?.line1 || ''; + const rawArtist = zone.now_playing?.three_line?.line2 || ''; + const album = zone.now_playing?.three_line?.line3 || ''; + const duration = zone.now_playing?.length || 0; + const position = update.seek_position || 0; + const remaining = Math.max(duration - position, 0); + + const bar = progressBar(position, duration); + const displayText = truncateText( + `${rawTrack} - ${rawArtist} • ${album} • ${formatTime(remaining)} restantes`, + 80 + ); + + const tooltip = +`${rawArtist} - ${rawTrack} +Álbum: ${album} +Duration: ${formatTime(position)} / ${formatTime(duration)} +${bar} + +${global.lastLyrics}`; + + const data = { + text: escapeMarkup(displayText), + tooltip: escapeMarkup(tooltip) + }; + + if (global.lastSeek !== position) { + fs.writeFileSync('/tmp/waybar_roon_info.json', JSON.stringify(data)); + exec("pkill -RTMIN+3 waybar"); + global.lastSeek = position; + } + } +}); + +extension.start_discovery(); +extension.set_status('Waiting for connection to Roon Core ...'); +log("searching Core..."); + +(async () => { + const core = await extension.get_core(); + if (core) { + pairedCore = core; + log("Paired with Roon Core"); + extension.set_status('Paired with Roon Core'); + } else { + log(" Not paired with any Core"); + } +})(); +