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