initial commit

This commit is contained in:
teraflops 2025-06-02 16:51:22 +02:00
commit a9a4d82559
Signed by: teraflops
GPG Key ID: 2B77D97AF6F8968C
5 changed files with 412 additions and 0 deletions

22
LICENSE Normal file
View File

@ -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.

75
README.md Normal file
View File

@ -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

View File

@ -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

View File

@ -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 <artist> <song>")
sys.exit(1)
artist = sys.argv[1]
song_title = sys.argv[2]
lyrics = get_lyrics(artist, song_title)
print(lyrics)

View File

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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");
}
})();