mirror of
https://github.com/LibreELEC/LibreELEC.tv.git
synced 2025-07-24 11:16:51 +00:00
librespot: drop system.d
This commit is contained in:
parent
f8c86fe50e
commit
153f3d3658
@ -1,3 +1,8 @@
|
||||
122
|
||||
- Improve track information: fanart, podcast
|
||||
- Improve settings: autoplay, name, RTP port
|
||||
- Drop system.d
|
||||
|
||||
121
|
||||
- Update to 66f8a98 (2020-02-26)
|
||||
- Gapless playback
|
||||
|
@ -5,8 +5,8 @@
|
||||
PKG_NAME="librespot"
|
||||
PKG_VERSION="66f8a98ad2f5bf35be4daecd788dad6f0d87fb7c"
|
||||
PKG_SHA256="b027c983341aa53d940412d5624cfe91392958ea9836ba597289680a4430b253"
|
||||
PKG_VERSION_DATE="2020-02-26"
|
||||
PKG_REV="121"
|
||||
PKG_VERSION_DATE="2020-02-27"
|
||||
PKG_REV="122"
|
||||
PKG_ARCH="any"
|
||||
PKG_LICENSE="MIT"
|
||||
PKG_SITE="https://github.com/librespot-org/librespot/"
|
||||
@ -24,20 +24,16 @@ PKG_ADDON_REQUIRES="script.module.requests:0.0.0"
|
||||
PKG_MAINTAINER="Anton Voyl (awiouy)"
|
||||
|
||||
make_target() {
|
||||
. "$(get_build_dir rust)/cargo/env"
|
||||
. $(get_build_dir rust)/cargo/env
|
||||
cargo build \
|
||||
--release \
|
||||
--no-default-features \
|
||||
--features "alsa-backend pulseaudio-backend with-dns-sd with-vorbis"
|
||||
"$STRIP" $PKG_BUILD/.$TARGET_NAME/*/release/librespot
|
||||
--features "alsa-backend pulseaudio-backend with-vorbis"
|
||||
$STRIP $PKG_BUILD/.$TARGET_NAME/*/release/librespot
|
||||
}
|
||||
|
||||
addon() {
|
||||
mkdir -p "$ADDON_BUILD/$PKG_ADDON_ID/bin"
|
||||
mkdir -p $ADDON_BUILD/$PKG_ADDON_ID/bin
|
||||
cp $PKG_BUILD/.$TARGET_NAME/*/release/librespot \
|
||||
"$ADDON_BUILD/$PKG_ADDON_ID/bin"
|
||||
|
||||
mkdir -p "$ADDON_BUILD/$PKG_ADDON_ID/lib"
|
||||
cp "$(get_build_dir avahi)/avahi-compat-libdns_sd/.libs/libdns_sd.so.1" \
|
||||
"$ADDON_BUILD/$PKG_ADDON_ID/lib"
|
||||
$ADDON_BUILD/$PKG_ADDON_ID/bin
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
commit 0623a601115f5c62d2b30645a85d9496546d5cba
|
||||
commit 93ad8ce6f94e51bb14547ddf9e7a234c41adc266
|
||||
Author: awiouy <awiouy@gmail.com>
|
||||
Date: Tue Dec 3 23:21:35 2019 +0100
|
||||
|
||||
@ -42,40 +42,27 @@ index 0f71110..931167d 100644
|
||||
}
|
||||
}
|
||||
diff --git a/playback/src/player.rs b/playback/src/player.rs
|
||||
index ef7484c..5b56782 100644
|
||||
index ef7484c..22ecb2c 100644
|
||||
--- a/playback/src/player.rs
|
||||
+++ b/playback/src/player.rs
|
||||
@@ -4,7 +4,8 @@ use futures::{future, Async, Future, Poll, Stream};
|
||||
use std;
|
||||
use std::borrow::Cow;
|
||||
use std::cmp::max;
|
||||
-use std::io::{Read, Result, Seek, SeekFrom};
|
||||
+use std::fs::OpenOptions;
|
||||
+use std::io::{Read, Result, Seek, SeekFrom, Write};
|
||||
use std::mem;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -1415,6 +1416,12 @@ impl PlayerInternal {
|
||||
@@ -1415,6 +1415,10 @@ impl PlayerInternal {
|
||||
}
|
||||
}
|
||||
|
||||
+ fn notify_kodi(&mut self, event: String) {
|
||||
+ // println!("Librespot fifo = {}", event);
|
||||
+ let mut file = OpenOptions::new().write(true).open("/tmp/librespot").unwrap();
|
||||
+ writeln!(&mut file, "{}", event).unwrap();
|
||||
+ eprintln!("@{}", event);
|
||||
+ }
|
||||
+
|
||||
fn send_event(&mut self, event: PlayerEvent) {
|
||||
let mut index = 0;
|
||||
while index < self.event_senders.len() {
|
||||
@@ -1425,6 +1432,17 @@ impl PlayerInternal {
|
||||
@@ -1425,6 +1429,16 @@ impl PlayerInternal {
|
||||
}
|
||||
}
|
||||
}
|
||||
+ if self.config.notify_kodi {
|
||||
+ use PlayerEvent::*;
|
||||
+ match event {
|
||||
+ Paused { .. } => self.notify_kodi("Paused".to_string()),
|
||||
+ Playing {track_id, .. } => self.notify_kodi(["Playing",
|
||||
+ &track_id.audio_type.to_string(),
|
||||
+ &track_id.to_base62()].join(" ")),
|
||||
@ -87,7 +74,7 @@ index ef7484c..5b56782 100644
|
||||
|
||||
fn load_track(
|
||||
diff --git a/src/main.rs b/src/main.rs
|
||||
index 70a2dff..3e63308 100644
|
||||
index f749a52..5a9f75d 100644
|
||||
--- a/src/main.rs
|
||||
+++ b/src/main.rs
|
||||
@@ -171,6 +171,11 @@ fn setup(args: &[String]) -> Setup {
|
||||
|
@ -0,0 +1,59 @@
|
||||
From 389276e5d361da5f2a2116d11724c815ce4b6ec8 Mon Sep 17 00:00:00 2001
|
||||
From: Konstantin Seiler <list@kseiler.de>
|
||||
Date: Wed, 11 Mar 2020 13:49:52 +1100
|
||||
Subject: [PATCH] Enable pulseaudio device names
|
||||
|
||||
---
|
||||
playback/src/audio_backend/pulseaudio.rs | 12 +++++++-----
|
||||
1 file changed, 7 insertions(+), 5 deletions(-)
|
||||
|
||||
diff --git a/playback/src/audio_backend/pulseaudio.rs b/playback/src/audio_backend/pulseaudio.rs
|
||||
index e844b0d6..8a833d65 100644
|
||||
--- a/playback/src/audio_backend/pulseaudio.rs
|
||||
+++ b/playback/src/audio_backend/pulseaudio.rs
|
||||
@@ -12,6 +12,7 @@ pub struct PulseAudioSink {
|
||||
ss: pa_sample_spec,
|
||||
name: CString,
|
||||
desc: CString,
|
||||
+ device: Option<CString>,
|
||||
}
|
||||
|
||||
fn call_pulseaudio<T, F, FailCheck>(
|
||||
@@ -56,10 +57,6 @@ impl Open for PulseAudioSink {
|
||||
fn open(device: Option<String>) -> PulseAudioSink {
|
||||
debug!("Using PulseAudio sink");
|
||||
|
||||
- if device.is_some() {
|
||||
- panic!("pulseaudio sink does not support specifying a device name");
|
||||
- }
|
||||
-
|
||||
let ss = pa_sample_spec {
|
||||
format: PA_SAMPLE_S16LE,
|
||||
channels: 2, // stereo
|
||||
@@ -74,6 +71,7 @@ impl Open for PulseAudioSink {
|
||||
ss: ss,
|
||||
name: name,
|
||||
desc: description,
|
||||
+ device: device.and_then(|s| CString::new(s).ok()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,13 +79,17 @@ impl Open for PulseAudioSink {
|
||||
impl Sink for PulseAudioSink {
|
||||
fn start(&mut self) -> io::Result<()> {
|
||||
if self.s == null_mut() {
|
||||
+ let device = match &self.device {
|
||||
+ None => null(),
|
||||
+ Some(device) => device.as_ptr(),
|
||||
+ };
|
||||
self.s = call_pulseaudio(
|
||||
|err| unsafe {
|
||||
pa_simple_new(
|
||||
null(), // Use the default server.
|
||||
self.name.as_ptr(), // Our application's name.
|
||||
PA_STREAM_PLAYBACK,
|
||||
- null(), // Use the default device.
|
||||
+ device,
|
||||
self.desc.as_ptr(), // desc of our stream.
|
||||
&self.ss, // Our sample format.
|
||||
null(), // Use default channel map
|
@ -1,27 +0,0 @@
|
||||
commit 88ba7c3ad879e784ce55714c2c834911b2573ce1
|
||||
Author: awiouy <awiouy@gmail.com>
|
||||
Date: Tue Dec 3 23:22:55 2019 +0100
|
||||
|
||||
Use own pulseaudio sink
|
||||
|
||||
diff --git a/playback/src/audio_backend/pulseaudio.rs b/playback/src/audio_backend/pulseaudio.rs
|
||||
index e844b0d..0320f65 100644
|
||||
--- a/playback/src/audio_backend/pulseaudio.rs
|
||||
+++ b/playback/src/audio_backend/pulseaudio.rs
|
||||
@@ -80,6 +80,7 @@ impl Open for PulseAudioSink {
|
||||
|
||||
impl Sink for PulseAudioSink {
|
||||
fn start(&mut self) -> io::Result<()> {
|
||||
+ let sink = CString::new("librespot_sink").unwrap();
|
||||
if self.s == null_mut() {
|
||||
self.s = call_pulseaudio(
|
||||
|err| unsafe {
|
||||
@@ -87,7 +88,7 @@ impl Sink for PulseAudioSink {
|
||||
null(), // Use the default server.
|
||||
self.name.as_ptr(), // Our application's name.
|
||||
PA_STREAM_PLAYBACK,
|
||||
- null(), // Use the default device.
|
||||
+ sink.as_ptr(), // Our sink.
|
||||
self.desc.as_ptr(), // desc of our stream.
|
||||
&self.ss, // Our sample format.
|
||||
null(), // Use default channel map
|
@ -1,37 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# SPDX-License-Identifier: GPL-2.0
|
||||
# Copyright (C) 2017-present Team LibreELEC (https://libreelec.tv)
|
||||
|
||||
. /etc/profile
|
||||
oe_setup_addon service.librespot
|
||||
|
||||
PORT="6666"
|
||||
SINK_NAME="librespot_sink"
|
||||
|
||||
if ! pactl list modules short | grep "sink_name=$SINK_NAME"; then
|
||||
pactl load-module module-null-sink sink_name="$SINK_NAME" > /dev/null
|
||||
fi
|
||||
if ! pactl list modules short | grep "source=$SINK_NAME"; then
|
||||
pactl load-module module-rtp-send source="$SINK_NAME.monitor" \
|
||||
destination_ip=127.0.0.1 port="$PORT" source_ip=127.0.0.1 > /dev/null
|
||||
fi
|
||||
pactl suspend-sink "$SINK_NAME" 1
|
||||
|
||||
LIBRESPOT="librespot \
|
||||
--backend pulseaudio \
|
||||
--bitrate 320 \
|
||||
--cache \"$ADDON_HOME/cache\" \
|
||||
--device-type TV \
|
||||
--disable-audio-cache \
|
||||
--name \"Kodi ($HOSTNAME)\" \
|
||||
--notify-kodi"
|
||||
|
||||
if [ "$ls_a" = "true" -a -n "$ls_p" -a -n "$ls_u" ]; then
|
||||
LIBRESPOT="$LIBRESPOT \
|
||||
--disable-discovery \
|
||||
--password \"$ls_p\" \
|
||||
--username \"$ls_u\""
|
||||
fi
|
||||
|
||||
eval $LIBRESPOT
|
@ -1,240 +1,8 @@
|
||||
# SPDX-License-Identifier: GPL-2.0
|
||||
# Copyright (C) 2017-present Team LibreELEC (https://libreelec.tv)
|
||||
|
||||
import base64
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import requests
|
||||
import xbmc
|
||||
import xbmcaddon
|
||||
import xbmcgui
|
||||
import sys
|
||||
|
||||
ADDON = xbmcaddon.Addon()
|
||||
ADDON_ID = ADDON.getAddonInfo('id')
|
||||
ADDON_NAME = ADDON.getAddonInfo('name')
|
||||
FIFO = '/tmp/librespot'
|
||||
LOG_LEVEL = xbmc.LOGNOTICE
|
||||
LOG_MESSAGE = ADDON.getAddonInfo('name') + ': {}'
|
||||
SINK_NAME = "librespot_sink"
|
||||
STREAM_CODEC = 'pcm_s16be'
|
||||
STREAM_PORT = '6666'
|
||||
STREAM_URL = 'rtp://127.0.0.1:{}'.format(STREAM_PORT)
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), 'resources', 'lib'))
|
||||
|
||||
from ls_monitor import Monitor as Monitor
|
||||
|
||||
def log(message):
|
||||
xbmc.log(LOG_MESSAGE.format(message), LOG_LEVEL)
|
||||
|
||||
|
||||
class Monitor(xbmc.Monitor):
|
||||
|
||||
def __init__(self):
|
||||
log('monitor started')
|
||||
self.player = Player()
|
||||
|
||||
def onSettingsChanged(self):
|
||||
log('settings changed')
|
||||
self.player.update()
|
||||
|
||||
def waitForAbort(self):
|
||||
super(Monitor, self).waitForAbort()
|
||||
log('abort requested')
|
||||
self.player.abort()
|
||||
|
||||
|
||||
class Player(threading.Thread, xbmc.Player):
|
||||
|
||||
def __init__(self):
|
||||
log('player started')
|
||||
super(Player, self).__init__()
|
||||
self.aborted = False
|
||||
self.isLibrespotStarted = True
|
||||
self.listitem = xbmcgui.ListItem()
|
||||
self.listitem.addStreamInfo('audio', {'codec': STREAM_CODEC})
|
||||
self.listitem.setPath(STREAM_URL)
|
||||
self.spotify = Spotify()
|
||||
if self.isPlaying():
|
||||
self.onAVStarted()
|
||||
else:
|
||||
self.playingFile = ''
|
||||
self.onPlayBackStopped()
|
||||
self.start()
|
||||
|
||||
def abort(self):
|
||||
log('aborting player')
|
||||
self.aborted = True
|
||||
with open(FIFO, 'w') as fifo:
|
||||
fifo.close()
|
||||
self.join()
|
||||
|
||||
def onAVChange(self):
|
||||
self.onAVStarted()
|
||||
|
||||
def onAVStarted(self):
|
||||
log('playback started')
|
||||
self.playingFile = self.getPlayingFile()
|
||||
if self.isLibrespotStarted and (self.playingFile != STREAM_URL):
|
||||
self.isLibrespotStarted = False
|
||||
self.systemctl('stop')
|
||||
|
||||
def onPlayBackEnded(self):
|
||||
self.onPlayBackStopped()
|
||||
|
||||
def onPlayBackError(self):
|
||||
self.onPlayBackStopped()
|
||||
|
||||
def onPlayBackStopped(self):
|
||||
log('playback stopped')
|
||||
if self.playingFile == STREAM_URL:
|
||||
self.systemctl('restart')
|
||||
elif not self.isLibrespotStarted:
|
||||
self.systemctl('start')
|
||||
self.isLibrespotStarted = True
|
||||
|
||||
def pauseLibrespot(self):
|
||||
if self.isPlaying() and (self.getPlayingFile() == STREAM_URL):
|
||||
log('pausing librespot playback')
|
||||
self.pause()
|
||||
|
||||
def playLibrespot(self, type, id):
|
||||
info = self.spotify.getInfo(type, id)
|
||||
self.listitem.setArt(info.getArt())
|
||||
self.listitem.setInfo('music', info.getInfo())
|
||||
if not self.isPlaying():
|
||||
subprocess.call(['pactl', 'suspend-sink', SINK_NAME, '0'])
|
||||
log('starting librespot playback')
|
||||
self.play(STREAM_URL, self.listitem)
|
||||
elif self.getPlayingFile() == STREAM_URL:
|
||||
log('updating librespot playback')
|
||||
self.updateInfoTag(self.listitem)
|
||||
|
||||
def run(self):
|
||||
log('control pipe started')
|
||||
try:
|
||||
os.unlink(FIFO)
|
||||
except OSError:
|
||||
pass
|
||||
while not self.aborted:
|
||||
if not os.path.exists(FIFO):
|
||||
os.mkfifo(FIFO)
|
||||
with open(FIFO, 'r') as fifo:
|
||||
command = fifo.readline()
|
||||
log('control pipe {}'.format(command))
|
||||
if command == '':
|
||||
continue
|
||||
command = command.split()
|
||||
if command[0] in ['Paused']:
|
||||
self.pauseLibrespot()
|
||||
elif command[0] in ['Playing']:
|
||||
self.playLibrespot(command[1], command[2])
|
||||
elif command[0] in ['Stopped']:
|
||||
self.stopLibrespot()
|
||||
try:
|
||||
os.unlink(FIFO)
|
||||
except OSError:
|
||||
pass
|
||||
log('control pipe stopped')
|
||||
|
||||
def stopLibrespot(self):
|
||||
if self.isPlaying() and (self.getPlayingFile() == STREAM_URL):
|
||||
log('stopping librespot playback')
|
||||
self.stop()
|
||||
|
||||
def systemctl(self, command):
|
||||
log('{} librespot'.format(command))
|
||||
subprocess.call(['systemctl', command, ADDON_ID])
|
||||
|
||||
def update(self):
|
||||
log('updating player')
|
||||
if self.isLibrespotStarted:
|
||||
self.systemctl('restart')
|
||||
|
||||
|
||||
class Spotify():
|
||||
|
||||
def __init__(self):
|
||||
self.headers = None
|
||||
self.expiration = time.time()
|
||||
|
||||
def getHeaders(self):
|
||||
if time.time() > self.expiration:
|
||||
log('token expired')
|
||||
token = requests.post(
|
||||
url='https://accounts.spotify.com/api/token',
|
||||
data={'grant_type': 'client_credentials'},
|
||||
headers={
|
||||
'Authorization': 'Basic MTY5ZGY1NTMyZGVlNDdhNTk5MTNmODUyOGU4M2FlNzE6MWYzZDhiNTA3YmJlNGY2OGJlYjNhNDQ3MmU4YWQ0MTE='}
|
||||
).json()
|
||||
print(token)
|
||||
log('new token expires in {} seconds'.format(token['expires_in']))
|
||||
self.expiration = time.time() + float(token['expires_in']) - 15
|
||||
self.headers = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer {}'.format(token['access_token'])
|
||||
}
|
||||
|
||||
def getEndpoint(self, url):
|
||||
try:
|
||||
self.getHeaders()
|
||||
return requests.get(
|
||||
url=url,
|
||||
headers=self.headers
|
||||
).json()
|
||||
except Exception as e:
|
||||
log('failed to get endpoint from Spotify {}'.format(e))
|
||||
return {}
|
||||
|
||||
def getInfo(self, type, id):
|
||||
if type == 'Track':
|
||||
return TrackInfo(self.getEndpoint('https://api.spotify.com/v1/tracks/{}'.format(id)))
|
||||
else:
|
||||
return UnknownInfo(type, id)
|
||||
|
||||
|
||||
class UnknownInfo:
|
||||
|
||||
def __init__(self, type, id):
|
||||
self.id = id
|
||||
self.type = type
|
||||
|
||||
def get(self, default, *indices):
|
||||
tree = self.info
|
||||
try:
|
||||
for index in indices:
|
||||
tree = tree[index]
|
||||
except LookupError:
|
||||
tree = default
|
||||
return tree
|
||||
|
||||
def getArt(self):
|
||||
return {'thumb': ''}
|
||||
|
||||
def getInfo(self):
|
||||
return {'album': '', 'artist': self.type, 'title': self.id}
|
||||
|
||||
|
||||
class TrackInfo(UnknownInfo):
|
||||
|
||||
def __init__(self, info):
|
||||
self.info = info
|
||||
|
||||
def getArt(self):
|
||||
return {
|
||||
'thumb': self.get('', 'album', 'images', 0, 'url')
|
||||
}
|
||||
|
||||
def getInfo(self):
|
||||
return {
|
||||
'album': self.get('', 'album', 'name'),
|
||||
'artist': self.get('', 'artists', 0, 'name'),
|
||||
'title': self.get('', 'name')
|
||||
}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
log('service started')
|
||||
Monitor().waitForAbort()
|
||||
log('service stopped')
|
||||
Monitor().run()
|
||||
|
@ -1,5 +0,0 @@
|
||||
# SPDX-License-Identifier: GPL-2.0
|
||||
# Copyright (C) 2017-present Team LibreELEC (https://libreelec.tv)
|
||||
|
||||
PORT="6666"
|
||||
SINK_NAME="librespot_sink"
|
@ -8,13 +8,25 @@ msgid "Configuration"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30101"
|
||||
msgid "User mode"
|
||||
msgid "Autoplay"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30102"
|
||||
msgid "Username"
|
||||
msgid "Discovery"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30103"
|
||||
msgid "Username"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30104"
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30105"
|
||||
msgid "Name"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30106"
|
||||
msgid "RTP Port"
|
||||
msgstr ""
|
||||
|
@ -0,0 +1,45 @@
|
||||
import os
|
||||
import socket
|
||||
import xbmc
|
||||
import xbmcaddon
|
||||
import xbmcgui
|
||||
|
||||
|
||||
DEFAULTS = dict(
|
||||
autoplay='true',
|
||||
bitrate='320',
|
||||
device='librespot',
|
||||
discovery='true',
|
||||
name='Librespot@{}',
|
||||
password='',
|
||||
rtp_dest='127.0.0.1',
|
||||
rtp_port='24642',
|
||||
username='',
|
||||
)
|
||||
|
||||
ADDON = xbmcaddon.Addon()
|
||||
ADDON_HOME = xbmc.translatePath(ADDON.getAddonInfo('profile'))
|
||||
ADDON_ICON = ADDON.getAddonInfo('icon')
|
||||
ADDON_NAME = ADDON.getAddonInfo('name')
|
||||
ADDON_PATH = ADDON.getAddonInfo('path')
|
||||
ADDON_ENVT = dict(PATH=os.path.join(ADDON_PATH, 'bin'))
|
||||
DIALOG = xbmcgui.Dialog()
|
||||
|
||||
|
||||
def get_settings():
|
||||
if not os.path.exists(ADDON_HOME):
|
||||
os.makedirs(ADDON_HOME)
|
||||
settings = dict()
|
||||
for id in DEFAULTS.keys():
|
||||
value = ADDON.getSetting(id)
|
||||
settings[id] = DEFAULTS[id] if value == '' else value
|
||||
settings['name'] = settings['name'].format(socket.gethostname())
|
||||
return settings
|
||||
|
||||
|
||||
def log(message):
|
||||
xbmc.log('{}: {}'.format(ADDON_NAME, message), xbmc.LOGNOTICE)
|
||||
|
||||
|
||||
def notification(message):
|
||||
DIALOG.notification(ADDON_NAME, message, ADDON_ICON)
|
@ -0,0 +1,161 @@
|
||||
import shlex
|
||||
import subprocess
|
||||
import threading
|
||||
import xbmc
|
||||
import xbmcgui
|
||||
|
||||
from ls_addon import ADDON_ENVT as ADDON_ENVT
|
||||
from ls_addon import ADDON_HOME as ADDON_HOME
|
||||
from ls_addon import get_settings as get_settings
|
||||
from ls_addon import log as log
|
||||
from ls_pulseaudio import Pulseaudio as Pulseaudio
|
||||
from ls_spotify import SPOTIFY as SPOTIFY
|
||||
|
||||
|
||||
LIBRESPOT = 'librespot' \
|
||||
' --backend pulseaudio' \
|
||||
' --bitrate {bitrate}' \
|
||||
' --cache cache' \
|
||||
' --device {device}' \
|
||||
' --device-type TV' \
|
||||
' --disable-audio-cache' \
|
||||
' --name {name}' \
|
||||
' --notify-kodi'
|
||||
LIBRESPOT_AUTOPLAY = ' --autoplay'
|
||||
LIBRESPOT_AUTHENTICATE = ' --disable-discovery' \
|
||||
' --password {password}' \
|
||||
' --username {username}'
|
||||
|
||||
CODEC = 'pcm_s16be'
|
||||
MAX_PANICS = 3
|
||||
|
||||
|
||||
class Librespot(xbmc.Player):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
settings = get_settings()
|
||||
quoted = {k: shlex.quote(v) for (k, v) in settings.items()}
|
||||
command = LIBRESPOT
|
||||
if settings['autoplay'] == 'true':
|
||||
command += LIBRESPOT_AUTOPLAY
|
||||
if (settings['discovery'] == 'false' and
|
||||
settings['password'] != '' and
|
||||
settings['username'] != ''):
|
||||
command += LIBRESPOT_AUTHENTICATE
|
||||
self.command = shlex.split(command.format(**quoted))
|
||||
log(shlex.split(command.format(**dict(quoted, password='*obfuscated*'))))
|
||||
self.is_aborted = False
|
||||
self.is_dead = False
|
||||
self.pulseaudio = Pulseaudio(settings)
|
||||
self.listitem = xbmcgui.ListItem()
|
||||
self.listitem.addStreamInfo('audio', {'codec': CODEC})
|
||||
self.listitem.setPath(path=self.pulseaudio.url)
|
||||
|
||||
def __enter__(self):
|
||||
self.pulseaudio.load_modules()
|
||||
self.panics = 0
|
||||
self.librespot = None
|
||||
self.is_playing_librespot = False
|
||||
if not self.isPlaying():
|
||||
self.start_librespot()
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.stop_librespot()
|
||||
self.pulseaudio.unload_modules()
|
||||
|
||||
def on_event_panic(self):
|
||||
self.pulseaudio.suspend_sink(1)
|
||||
self.panics += 1
|
||||
log('event panic {}/{}'.format(self.panics, MAX_PANICS))
|
||||
self.is_dead = self.panics >= MAX_PANICS
|
||||
self.stop_librespot(True)
|
||||
|
||||
def on_event_playing(self, type, id):
|
||||
log('event playing')
|
||||
SPOTIFY.update_listitem(self.listitem, type, id, self.country)
|
||||
if not self.isPlaying():
|
||||
log('starting librespot playback')
|
||||
self.pulseaudio.suspend_sink(0)
|
||||
self.play(self.pulseaudio.url, self.listitem)
|
||||
elif self.is_playing_librespot:
|
||||
log('updating librespot playback')
|
||||
self.updateInfoTag(self.listitem)
|
||||
|
||||
def on_event_stopped(self):
|
||||
self.pulseaudio.suspend_sink(1)
|
||||
log('event stopped')
|
||||
self.panics = 0
|
||||
self.stop()
|
||||
|
||||
def onPlayBackEnded(self):
|
||||
self.onPlayBackStopped()
|
||||
|
||||
def onPlayBackError(self):
|
||||
self.onPlayBackStopped()
|
||||
|
||||
def onPlayBackStarted(self):
|
||||
log('Kodi playback started')
|
||||
self.is_playing_librespot = self.getPlayingFile() == self.pulseaudio.url
|
||||
if not self.is_playing_librespot:
|
||||
self.stop_librespot()
|
||||
|
||||
def onPlayBackStopped(self):
|
||||
if self.is_playing_librespot:
|
||||
log('librespot playback stopped')
|
||||
self.is_playing_librespot = False
|
||||
self.stop_librespot(True)
|
||||
else:
|
||||
log('Kodi playback stopped')
|
||||
self.start_librespot()
|
||||
|
||||
def run_librespot(self):
|
||||
log('librespot thread started')
|
||||
self.restart = True
|
||||
while self.restart and not self.is_dead:
|
||||
self.librespot = subprocess.Popen(
|
||||
self.command,
|
||||
cwd=ADDON_HOME,
|
||||
env=ADDON_ENVT,
|
||||
stderr=subprocess.STDOUT,
|
||||
stdout=subprocess.PIPE,
|
||||
text=True)
|
||||
log('librespot started')
|
||||
with self.librespot.stdout:
|
||||
for line in self.librespot.stdout:
|
||||
words = line.split()
|
||||
if words[0] == '@Playing':
|
||||
self.on_event_playing(words[1], words[2])
|
||||
elif words[0] == '@Stopped':
|
||||
self.on_event_stopped()
|
||||
elif words[0] == 'stack':
|
||||
self.on_event_panic()
|
||||
else:
|
||||
log(line.rstrip())
|
||||
if 'Country:' in line:
|
||||
self.country = words[-1].strip('"')
|
||||
log('country={}'.format(self.country))
|
||||
self.pulseaudio.suspend_sink(1)
|
||||
self.stop()
|
||||
self.librespot.wait()
|
||||
log('librespot stopped')
|
||||
self.librespot = None
|
||||
log('librespot thread stopped')
|
||||
|
||||
def start_librespot(self):
|
||||
if self.librespot is None:
|
||||
self.thread = threading.Thread(target=self.run_librespot)
|
||||
self.thread.start()
|
||||
|
||||
def stop(self):
|
||||
if self.is_playing_librespot and not self.is_aborted:
|
||||
log('stopping librespot playback')
|
||||
self.is_playing_librespot = False
|
||||
super().stop()
|
||||
|
||||
def stop_librespot(self, restart=False):
|
||||
self.restart = restart
|
||||
if self.librespot is not None:
|
||||
self.librespot.terminate()
|
||||
if not restart:
|
||||
self.thread.join()
|
@ -0,0 +1,35 @@
|
||||
import xbmc
|
||||
|
||||
from ls_addon import log as log
|
||||
from ls_addon import notification as notification
|
||||
from ls_librespot import Librespot as Librespot
|
||||
|
||||
|
||||
class Monitor(xbmc.Monitor):
|
||||
|
||||
def onSettingsChanged(self):
|
||||
self.is_changed = True
|
||||
|
||||
def run(self):
|
||||
log('monitor started')
|
||||
is_aborted = False
|
||||
is_dead = False
|
||||
while not (is_aborted or is_dead):
|
||||
self.is_changed = False
|
||||
librespot = Librespot()
|
||||
with librespot:
|
||||
while True:
|
||||
is_aborted = self.waitForAbort(1)
|
||||
if is_aborted:
|
||||
log('monitor aborted')
|
||||
librespot.is_aborted = True
|
||||
break
|
||||
is_dead = librespot.is_dead
|
||||
if is_dead:
|
||||
log('librespot died')
|
||||
notification('Too many errors')
|
||||
break
|
||||
if self.is_changed:
|
||||
log('settings changed')
|
||||
break
|
||||
log('monitor stopped')
|
@ -0,0 +1,46 @@
|
||||
import subprocess
|
||||
|
||||
from ls_addon import log as log
|
||||
|
||||
|
||||
def run(command):
|
||||
return subprocess.check_output(command.split(), text=True)
|
||||
|
||||
|
||||
class Pulseaudio:
|
||||
|
||||
def __init__(self, settings):
|
||||
self.null_sink = dict(
|
||||
module='module-null-sink',
|
||||
args='sink_name={device}'.format(**settings)
|
||||
)
|
||||
self.rtp_send = dict(
|
||||
module='module-rtp-send',
|
||||
args='destination_ip={rtp_dest} port={rtp_port}'
|
||||
' source={device}.monitor'.format(**settings)
|
||||
)
|
||||
self.suspend = 'pactl suspend-sink {device} {{}}'.format(**settings)
|
||||
self.url = 'rtp://{rtp_dest}:{rtp_port}'.format(**settings)
|
||||
|
||||
def list_modules(self):
|
||||
return [module.split('\t')
|
||||
for module in run('pactl list modules short').splitlines()[::-1]]
|
||||
|
||||
def load_modules(self):
|
||||
args = [module[2] for module in self.list_modules()]
|
||||
for module in [self.null_sink, self.rtp_send]:
|
||||
if module['args'] not in args:
|
||||
run('pactl load-module {} {}'.format(
|
||||
module['module'], module['args']))
|
||||
log('loaded {} {}'.format(module['module'], module['args']))
|
||||
self.suspend_sink(1)
|
||||
|
||||
def suspend_sink(self, bit):
|
||||
run(self.suspend.format(bit))
|
||||
log('suspended sink {}'.format(bit))
|
||||
|
||||
def unload_modules(self):
|
||||
for module in self.list_modules():
|
||||
if module[2] in [self.null_sink['args'], self.rtp_send['args']]:
|
||||
run('pactl unload-module {}'.format(module[0]))
|
||||
log('unloaded {} {}'.format(module[1], module[2]))
|
@ -0,0 +1,80 @@
|
||||
import requests
|
||||
import time
|
||||
|
||||
|
||||
from ls_addon import ADDON_ICON as ADDON_ICON
|
||||
from ls_addon import log as log
|
||||
|
||||
|
||||
SPOTIFY_ENDPOINT_EPISODES = 'https://api.spotify.com/v1/episodes/'
|
||||
SPOTIFY_ENDPOINT_TRACKS = 'https://api.spotify.com/v1/tracks/'
|
||||
SPOTIFY_HEADERS = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
SPOTIFY_REQUEST_TOKEN = {
|
||||
'url': 'https://accounts.spotify.com/api/token',
|
||||
'data': {'grant_type': 'client_credentials'},
|
||||
'headers': {'Authorization': 'Basic MTY5ZGY1NTMyZGVlNDdhNTk5MTNmODUyOGU4M2FlNzE6MWYzZDhiNTA3YmJlNGY2OGJlYjNhNDQ3MmU4YWQ0MTE='}
|
||||
}
|
||||
|
||||
|
||||
def get(info, indices, default):
|
||||
try:
|
||||
for index in indices:
|
||||
info = info[index]
|
||||
return info
|
||||
except LookupError:
|
||||
return default
|
||||
|
||||
|
||||
class Spotify:
|
||||
|
||||
def __init__(self):
|
||||
self.headers = SPOTIFY_HEADERS
|
||||
self.expiration = time.time()
|
||||
|
||||
def get_headers(self):
|
||||
if time.time() > self.expiration:
|
||||
log('token expired')
|
||||
token = requests.post(**SPOTIFY_REQUEST_TOKEN).json()
|
||||
log(token)
|
||||
self.expiration = time.time() + float(token['expires_in']) - 5
|
||||
self.headers['Authorization'] = 'Bearer {}'.format(
|
||||
token['access_token'])
|
||||
|
||||
def get_endpoint(self, endpoint, id, market):
|
||||
try:
|
||||
self.get_headers()
|
||||
return requests.get(url=endpoint + id,
|
||||
headers=self.headers,
|
||||
params=dict(market=market)).json()
|
||||
except Exception as e:
|
||||
log('failed to get {} from Spotify {}'.format(endpoint, e))
|
||||
return {}
|
||||
|
||||
def update_listitem(self, listitem, type, id, market='SE'):
|
||||
if type == 'Podcast':
|
||||
info = self.get_endpoint(SPOTIFY_ENDPOINT_EPISODES, id, market)
|
||||
album = get(info, ['show', 'name'], 'unknown show',)
|
||||
artist = get(info, ['show', 'publisher'], 'unknown publisher')
|
||||
thumb = get(info, ['images', 0, 'url'], ADDON_ICON)
|
||||
title = get(info, ['name'], 'unknown episode')
|
||||
elif type == 'Track':
|
||||
info = self.get_endpoint(SPOTIFY_ENDPOINT_TRACKS, id, market)
|
||||
album = get(info, ['album', 'name'], 'unknown album')
|
||||
artist = get(info, ['artists', 0, 'name'], 'unknown artist')
|
||||
thumb = get(info, ['album', 'images', 0, 'url'], ADDON_ICON)
|
||||
title = get(info, ['name'], 'unknown title')
|
||||
else:
|
||||
album = ''
|
||||
artist = 'Unknown Media Type'
|
||||
thumb = ADDON_ICON
|
||||
title = ''
|
||||
listitem.setArt(dict(fanart=thumb, thumb=thumb))
|
||||
listitem.setInfo('music', dict(
|
||||
album=album, artist=artist, title=title))
|
||||
log('{}#{}#{}#{}'.format(title, artist, album, thumb))
|
||||
|
||||
|
||||
SPOTIFY = Spotify()
|
@ -1,8 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<settings>
|
||||
<category label="30100">
|
||||
<setting label="30101" id="ls_a" type="bool" default="false" />
|
||||
<setting label="30102" id="ls_u" type="text" default="" subsetting="true" visible="eq(-1,true)" />
|
||||
<setting label="30103" id="ls_p" type="text" default="" subsetting="true" visible="eq(-2,true)" option="hidden" />
|
||||
<setting label="30101" id="autoplay" type="bool" default="true" />
|
||||
<setting label="30102" id="discovery" type="bool" default="true" />
|
||||
<setting label="30103" id="username" type="text" default="" subsetting="true" visible="eq(-1,false)" />
|
||||
<setting label="30104" id="password" type="text" default="" subsetting="true" visible="eq(-2,false)" option="hidden" />
|
||||
<setting label="30105" id="name" type="text" default="Librespot@{}" />
|
||||
<setting label="30106" id="rtp_port" type="number" default="24642" />
|
||||
</category>
|
||||
</settings>
|
||||
|
@ -1,5 +0,0 @@
|
||||
<settings version="2">
|
||||
<setting id="ls_a" default="true">false</setting>
|
||||
<setting id="ls_p" default="true"></setting>
|
||||
<setting id="ls_u" default="true"></setting>
|
||||
</settings>
|
@ -1,13 +0,0 @@
|
||||
[Unit]
|
||||
Description=librespot
|
||||
Wants=kodi.service
|
||||
After=kodi.service
|
||||
|
||||
[Service]
|
||||
EnvironmentFile=/storage/.kodi/addons/service.librespot/ls_env.py
|
||||
ExecStart=/bin/sh /storage/.kodi/addons/service.librespot/bin/librespot.start
|
||||
ExecStopPost=-/usr/bin/pactl suspend-sink librespot_sink 1
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=kodi.service
|
Loading…
x
Reference in New Issue
Block a user