initial commit
This commit is contained in:
commit
a9a4d82559
22
LICENSE
Normal file
22
LICENSE
Normal 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
75
README.md
Normal 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
|
||||||
|
|
||||||
|
|
14
etc/systemd/user/roon-extension.service
Normal file
14
etc/systemd/user/roon-extension.service
Normal 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
|
||||||
|
|
70
usr/share/waybar/scripts/get_lyrics.py
Executable file
70
usr/share/waybar/scripts/get_lyrics.py
Executable 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)
|
||||||
|
|
231
usr/share/waybar/scripts/roon_extension.js
Normal file
231
usr/share/waybar/scripts/roon_extension.js
Normal 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, "&")
|
||||||
|
.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");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
Loading…
x
Reference in New Issue
Block a user