mirror of
https://github.com/LibreELEC/LibreELEC.tv.git
synced 2025-07-29 13:46:49 +00:00
Merge pull request #9497 from henri-funck/librespot-python
librespot: update python scripts
This commit is contained in:
commit
81ab9da457
@ -1,66 +0,0 @@
|
|||||||
#!/usr/bin/python
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
|
|
||||||
ADDRESS = ('127.0.0.1', 36963)
|
|
||||||
BUFFER_SIZE = 1024
|
|
||||||
|
|
||||||
|
|
||||||
def send_event(event):
|
|
||||||
data = json.dumps(event).encode()
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
||||||
sock.sendto(data, ADDRESS)
|
|
||||||
|
|
||||||
|
|
||||||
def receive_event():
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
||||||
sock.settimeout(None)
|
|
||||||
sock.bind(ADDRESS)
|
|
||||||
while True:
|
|
||||||
data, addr = sock.recvfrom(BUFFER_SIZE)
|
|
||||||
event = json.loads(data.decode())
|
|
||||||
if not event:
|
|
||||||
break
|
|
||||||
yield event
|
|
||||||
|
|
||||||
|
|
||||||
ARG_ALBUM = 'album'
|
|
||||||
ARG_ARTIST = 'artist'
|
|
||||||
ARG_ART = 'art'
|
|
||||||
ARG_TITLE = 'title'
|
|
||||||
|
|
||||||
KEY_ALBUM = 'ALBUM'
|
|
||||||
KEY_ARTISTS = 'ARTISTS'
|
|
||||||
KEY_COVERS = 'COVERS'
|
|
||||||
KEY_ITEM_TYPE = 'ITEM_TYPE'
|
|
||||||
KEY_NAME = 'NAME'
|
|
||||||
KEY_PLAYER_EVENT = 'PLAYER_EVENT'
|
|
||||||
KEY_SHOW_NAME = 'SHOW_NAME'
|
|
||||||
|
|
||||||
PLAYER_EVENT_STOPPED = 'stopped'
|
|
||||||
PLAYER_EVENT_TRACK_CHANGED = 'track_changed'
|
|
||||||
|
|
||||||
ITEM_TYPE_EPISODE = 'Episode'
|
|
||||||
ITEM_TYPE_TRACK = 'Track'
|
|
||||||
|
|
||||||
|
|
||||||
def get_env_value(key):
|
|
||||||
return os.environ.get(key, '').partition('\n')[0]
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
player_event = get_env_value(KEY_PLAYER_EVENT)
|
|
||||||
event = {KEY_PLAYER_EVENT: player_event}
|
|
||||||
if player_event == PLAYER_EVENT_STOPPED:
|
|
||||||
send_event(event)
|
|
||||||
elif player_event == PLAYER_EVENT_TRACK_CHANGED:
|
|
||||||
event[ARG_ART] = get_env_value(KEY_COVERS)
|
|
||||||
event[ARG_TITLE] = get_env_value(KEY_NAME)
|
|
||||||
item_type = get_env_value(KEY_ITEM_TYPE)
|
|
||||||
if item_type == ITEM_TYPE_EPISODE:
|
|
||||||
event[ARG_ALBUM] = get_env_value(KEY_SHOW_NAME)
|
|
||||||
elif item_type == ITEM_TYPE_TRACK:
|
|
||||||
event[ARG_ALBUM] = get_env_value(KEY_ALBUM)
|
|
||||||
event[ARG_ARTIST] = get_env_value(KEY_ARTISTS)
|
|
||||||
send_event(event)
|
|
@ -1,25 +1,8 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import xbmcaddon
|
|
||||||
import xbmcvfs
|
|
||||||
|
|
||||||
|
sys.path.append(os.path.join(os.path.dirname(__file__), "resources", "lib"))
|
||||||
|
|
||||||
def _set_home():
|
import monitor
|
||||||
home = xbmcvfs.translatePath(xbmcaddon.Addon().getAddonInfo('profile'))
|
|
||||||
os.makedirs(home, exist_ok=True)
|
|
||||||
os.chdir(home)
|
|
||||||
|
|
||||||
|
monitor.run()
|
||||||
def _set_paths():
|
|
||||||
path = xbmcaddon.Addon().getAddonInfo('path')
|
|
||||||
os.environ['PATH'] += os.pathsep + os.path.join(path, 'bin')
|
|
||||||
os.environ['LD_LIBRARY_PATH'] += os.pathsep + os.path.join(path, 'lib')
|
|
||||||
sys.path.append(os.path.join(path, 'bin'))
|
|
||||||
sys.path.append(os.path.join(path, 'resources', 'lib'))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
_set_home()
|
|
||||||
_set_paths()
|
|
||||||
import service
|
|
||||||
service.Monitor().run()
|
|
||||||
|
@ -16,11 +16,11 @@ msgid "Do not disturb Kodi"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgctxt "#30103"
|
msgctxt "#30103"
|
||||||
msgid "User options"
|
msgid "Backend"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgctxt "#30104"
|
msgctxt "#30104"
|
||||||
msgid "Backend"
|
msgid "Player"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgctxt "#30105"
|
msgctxt "#30105"
|
||||||
|
@ -0,0 +1,49 @@
|
|||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import onevent
|
||||||
|
import utils
|
||||||
|
|
||||||
|
_BUFFER = 1024
|
||||||
|
|
||||||
|
|
||||||
|
class EventHandler:
|
||||||
|
@utils.logged_method
|
||||||
|
def __init__(self, target):
|
||||||
|
self._target = target
|
||||||
|
self._socket = socket.socket(onevent.SOCK_AF, onevent.SOCK_TYPE)
|
||||||
|
self._socket.settimeout(None)
|
||||||
|
self._socket.bind((onevent.HOST, 0))
|
||||||
|
self._port = self._socket.getsockname()[1]
|
||||||
|
self._receiver = threading.Thread(target=self._handle_events)
|
||||||
|
self._receiver.start()
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
|
def __exit__(self, *_):
|
||||||
|
onevent.send_event(self._port)
|
||||||
|
self._receiver.join()
|
||||||
|
|
||||||
|
def _handle_events(self):
|
||||||
|
utils.log(f"Event handler listening on port {self._port}")
|
||||||
|
with self._socket:
|
||||||
|
while True:
|
||||||
|
data, _ = self._socket.recvfrom(_BUFFER)
|
||||||
|
event, dict = json.loads(data)
|
||||||
|
if event:
|
||||||
|
try:
|
||||||
|
utils.log(f"Event handler handling {event}{dict}")
|
||||||
|
method = f"on_event_{event}"
|
||||||
|
getattr(self._target, method)(**dict)
|
||||||
|
except Exception as e:
|
||||||
|
utils.log(f"Event handler failed to handle {event}: {e}")
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
utils.log("Event handler ended")
|
||||||
|
|
||||||
|
def get_onevent(self):
|
||||||
|
return f"python {onevent.__file__} {self._port}"
|
@ -1,8 +0,0 @@
|
|||||||
import player
|
|
||||||
import service
|
|
||||||
|
|
||||||
|
|
||||||
class Player(player.Player):
|
|
||||||
|
|
||||||
def onLibrespotTrackChanged(self, art, artist, title, **kwargs):
|
|
||||||
service.notification(heading=title, message=artist, icon=art)
|
|
@ -1,34 +0,0 @@
|
|||||||
import xbmc
|
|
||||||
import xbmcgui
|
|
||||||
|
|
||||||
import player
|
|
||||||
import service
|
|
||||||
|
|
||||||
|
|
||||||
class Player(player.Player):
|
|
||||||
|
|
||||||
def __init__(self, codec='pcm_sb16be', max_fanarts='10', **kwargs):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self._max_fanarts = int(max_fanarts)
|
|
||||||
self._list_item = xbmcgui.ListItem(path=self.librespot.file)
|
|
||||||
self._list_item.getVideoInfoTag().addAudioStream(xbmc.AudioStreamDetail(2, codec))
|
|
||||||
self._music_info_tag = self._list_item.getMusicInfoTag()
|
|
||||||
|
|
||||||
def onLibrespotTrackChanged(self, album='', art='', artist='', title=''):
|
|
||||||
fanart = service.get_fanart(art, self._max_fanarts) if art else art
|
|
||||||
self._list_item.setArt({'fanart': fanart, 'thumb': art})
|
|
||||||
self._music_info_tag.setAlbum(album)
|
|
||||||
self._music_info_tag.setArtist(artist)
|
|
||||||
self._music_info_tag.setTitle(title)
|
|
||||||
if self.isPlaying() and self.getPlayingFile() == self.librespot.file:
|
|
||||||
self.updateInfoTag(self._list_item)
|
|
||||||
else:
|
|
||||||
self.stop() # fixes unepxected behaviour of Player.play()
|
|
||||||
self.librespot.start_sink()
|
|
||||||
self.play(self.librespot.file, listitem=self._list_item)
|
|
||||||
|
|
||||||
def onLibrespotStopped(self):
|
|
||||||
self.librespot.stop_sink()
|
|
||||||
if self.isPlaying() and self.getPlayingFile() == self.librespot.file:
|
|
||||||
self.last_file = None
|
|
||||||
self.stop()
|
|
@ -1,93 +1,78 @@
|
|||||||
import shlex
|
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
import external_player
|
import utils
|
||||||
import internal_player
|
|
||||||
import service
|
|
||||||
|
|
||||||
|
|
||||||
class Librespot:
|
class Librespot:
|
||||||
|
@utils.logged_method
|
||||||
def __init__(self,
|
def __init__(self, target, backend, device):
|
||||||
bitrate='320',
|
self._target = target
|
||||||
device_type='tv',
|
name = utils.get_setting("name").format(socket.gethostname())
|
||||||
max_retries='5',
|
self._command = [
|
||||||
name='Librespot@{}',
|
"librespot",
|
||||||
options='',
|
"--backend", backend,
|
||||||
**kwargs):
|
"--bitrate", "320",
|
||||||
name = name.format(socket.gethostname())
|
"--device", device,
|
||||||
self.command = [
|
"--device-type", "tv",
|
||||||
'librespot',
|
"--disable-audio-cache",
|
||||||
'--bitrate', f'{bitrate}',
|
"--disable-credential-cache",
|
||||||
'--device-type', f'{device_type}',
|
"--name", name,
|
||||||
'--disable-audio-cache',
|
"--onevent", target.event_handler.get_onevent(),
|
||||||
'--disable-credential-cache',
|
"--quiet",
|
||||||
'--name', f'{name}',
|
]
|
||||||
'--onevent', 'onevent.py',
|
self._failures = 0
|
||||||
'--quiet',
|
self._max_failures = 5
|
||||||
] + shlex.split(options)
|
|
||||||
service.log(self.command)
|
|
||||||
self.file = ''
|
|
||||||
self._is_started = threading.Event()
|
|
||||||
self._is_stopped = threading.Event()
|
|
||||||
self._librespot = None
|
self._librespot = None
|
||||||
self._max_retries = int(max_retries)
|
self._get_librespot = self._schedule_librespot()
|
||||||
self._retries = 0
|
|
||||||
self._thread = threading.Thread()
|
|
||||||
|
|
||||||
def get_player(self, **kwargs):
|
|
||||||
return (internal_player if self.file else external_player).Player(**kwargs)
|
|
||||||
|
|
||||||
def restart(self):
|
|
||||||
if self._thread.is_alive():
|
|
||||||
self._librespot.terminate()
|
|
||||||
else:
|
|
||||||
self.start()
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
if not self._thread.is_alive() and self._retries < self._max_retries:
|
|
||||||
self._thread = threading.Thread(daemon=True, target=self._run)
|
|
||||||
self._thread.start()
|
|
||||||
self._is_started.wait(1)
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
if self._thread.is_alive():
|
|
||||||
self._is_stopped.set()
|
|
||||||
self._librespot.terminate()
|
|
||||||
self._thread.join()
|
|
||||||
|
|
||||||
def start_sink(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def stop_sink(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _run(self):
|
|
||||||
service.log('librespot thread started')
|
|
||||||
self._is_started.clear()
|
|
||||||
self._is_stopped.clear()
|
|
||||||
while not self._is_stopped.is_set():
|
|
||||||
with subprocess.Popen(self.command, stderr=subprocess.PIPE, text=True) as self._librespot:
|
|
||||||
self._is_started.set()
|
|
||||||
for line in self._librespot.stderr:
|
|
||||||
service.log(line.rstrip())
|
|
||||||
self.stop_sink()
|
|
||||||
if self._librespot.returncode <= 0:
|
|
||||||
self._retries = 0
|
|
||||||
else:
|
|
||||||
self._retries += 1
|
|
||||||
if self._retries < self._max_retries:
|
|
||||||
service.notification(
|
|
||||||
f'librespot failed {self._retries}/{self._max_retries}')
|
|
||||||
else:
|
|
||||||
service.notification('librespot failed too many times')
|
|
||||||
break
|
|
||||||
service.log('librespot thread stopped')
|
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, *args):
|
@utils.logged_method
|
||||||
self.stop()
|
def __exit__(self, *_):
|
||||||
|
self._get_librespot.close()
|
||||||
|
|
||||||
|
def _schedule_librespot(self):
|
||||||
|
while self._failures < self._max_failures:
|
||||||
|
with subprocess.Popen(
|
||||||
|
self._command, stderr=subprocess.PIPE, text=True
|
||||||
|
) as self._librespot:
|
||||||
|
threading.Thread(target=self._monitor_librespot).start()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self._librespot.terminate()
|
||||||
|
utils.call_if_has(self._target, "on_librespot_broken")
|
||||||
|
utils.log("Librespot crashed too many times", True)
|
||||||
|
self._librespot = None
|
||||||
|
while True:
|
||||||
|
yield
|
||||||
|
|
||||||
|
def _monitor_librespot(self):
|
||||||
|
self._target.on_librespot_started()
|
||||||
|
with self._librespot as librespot:
|
||||||
|
for line in librespot.stderr:
|
||||||
|
utils.log(line.rstrip())
|
||||||
|
self._target.on_librespot_stopped()
|
||||||
|
if librespot.returncode < 0:
|
||||||
|
self._failures = 0
|
||||||
|
else:
|
||||||
|
self._failures += 1
|
||||||
|
next(self._get_librespot)
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
|
def restart(self):
|
||||||
|
next(self._get_librespot)
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
|
def start(self):
|
||||||
|
if self._librespot is None or self._librespot.poll() is not None:
|
||||||
|
next(self._get_librespot)
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
|
def stop(self):
|
||||||
|
if self._librespot is not None:
|
||||||
|
self._librespot.terminate()
|
||||||
|
@ -1,11 +0,0 @@
|
|||||||
import librespot
|
|
||||||
|
|
||||||
|
|
||||||
class Librespot(librespot.Librespot):
|
|
||||||
|
|
||||||
def __init__(self, alsa_device='hw:2,0', **kwargs):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self.command += [
|
|
||||||
'--backend', 'alsa',
|
|
||||||
'--device', f'{alsa_device}',
|
|
||||||
]
|
|
@ -1,73 +0,0 @@
|
|||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
import librespot
|
|
||||||
import service
|
|
||||||
|
|
||||||
|
|
||||||
class Librespot(librespot.Librespot):
|
|
||||||
|
|
||||||
def __init__(self,
|
|
||||||
codec='pcm_sb16be',
|
|
||||||
pa_rtp_address='127.0.0.1',
|
|
||||||
pa_rtp_device='librespot',
|
|
||||||
pa_rtp_port='24642',
|
|
||||||
**kwargs):
|
|
||||||
service.log('pulseaudio backend started')
|
|
||||||
sap_cmd = f'nc -l -u -s {pa_rtp_address} -p 9875'.split()
|
|
||||||
self._sap_server = subprocess.Popen(sap_cmd,
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.STDOUT)
|
|
||||||
service.log(f'sap server started')
|
|
||||||
if not pa_rtp_port:
|
|
||||||
with socket.socket() as s:
|
|
||||||
s.bind((pa_rtp_address, 0))
|
|
||||||
pa_rtp_port = s.getsockname()[1]
|
|
||||||
modules = [
|
|
||||||
[
|
|
||||||
f'module-null-sink',
|
|
||||||
f'sink_name={pa_rtp_device}',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
f'module-rtp-send',
|
|
||||||
f'destination_ip={pa_rtp_address}',
|
|
||||||
f'inhibit_auto_suspend=always',
|
|
||||||
f'port={pa_rtp_port}',
|
|
||||||
f'source={pa_rtp_device}.monitor',
|
|
||||||
],
|
|
||||||
]
|
|
||||||
self._modules = [self._pactl('load-module', *m) for m in modules]
|
|
||||||
self._sink_name = f'{pa_rtp_device}'
|
|
||||||
self.stop_sink()
|
|
||||||
service.log(f'pulseaudio modules loaded: {self._modules}')
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self.command += [
|
|
||||||
'--backend', 'pulseaudio',
|
|
||||||
'--device', f'{pa_rtp_device}',
|
|
||||||
]
|
|
||||||
self.file = f'rtp://{pa_rtp_address}:{pa_rtp_port}'
|
|
||||||
|
|
||||||
def start_sink(self):
|
|
||||||
self._pactl('suspend-sink', self._sink_name, '0')
|
|
||||||
|
|
||||||
def stop_sink(self):
|
|
||||||
self._pactl('suspend-sink', self._sink_name, '1')
|
|
||||||
|
|
||||||
def _pactl(self, command, *args):
|
|
||||||
out = subprocess.run(['pactl', command, *args],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
text=True
|
|
||||||
).stdout.rstrip()
|
|
||||||
service.log(f'pactl {command} {args}: {out}')
|
|
||||||
return out
|
|
||||||
|
|
||||||
def __exit__(self, *args):
|
|
||||||
super().__exit__(*args)
|
|
||||||
for module in reversed(self._modules):
|
|
||||||
if module:
|
|
||||||
self._pactl('unload-module', module)
|
|
||||||
service.log('pulseaudio backend stopped')
|
|
||||||
if self._sap_server.poll() is None:
|
|
||||||
self._sap_server.terminate()
|
|
||||||
self._sap_server.wait()
|
|
||||||
service.log('sap server stopped')
|
|
@ -0,0 +1,33 @@
|
|||||||
|
import xbmc
|
||||||
|
|
||||||
|
import service
|
||||||
|
import service_pulseaudio
|
||||||
|
import utils
|
||||||
|
|
||||||
|
|
||||||
|
def _get_service():
|
||||||
|
while True:
|
||||||
|
backend = utils.get_setting("backend")
|
||||||
|
match backend:
|
||||||
|
case "alsa":
|
||||||
|
alsa_device = utils.get_setting("alsa_device")
|
||||||
|
service_ = service.Service(backend, alsa_device)
|
||||||
|
case _:
|
||||||
|
service_ = service_pulseaudio.Service()
|
||||||
|
yield from service_.run()
|
||||||
|
|
||||||
|
|
||||||
|
class _Monitor(xbmc.Monitor):
|
||||||
|
@utils.logged_method
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._service = _get_service()
|
||||||
|
next(self._service)
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
|
def onSettingsChanged(self):
|
||||||
|
next(self._service)
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
_Monitor().waitForAbort()
|
@ -0,0 +1,56 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
HOST = "127.0.0.1"
|
||||||
|
SOCK_AF = socket.AF_INET
|
||||||
|
SOCK_TYPE = socket.SOCK_DGRAM
|
||||||
|
|
||||||
|
|
||||||
|
def _get(key):
|
||||||
|
return os.environ.get(key, "")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_first(key):
|
||||||
|
return os.environ.get(key, "").partition("\n")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_time(key):
|
||||||
|
return int(os.environ.get(key, "0")) / 1000
|
||||||
|
|
||||||
|
|
||||||
|
def _on_event():
|
||||||
|
event = _get("PLAYER_EVENT")
|
||||||
|
dict = {}
|
||||||
|
if event in ["paused", "playing", "position_correction", "seeked"]:
|
||||||
|
dict["position"] = _get_time("POSITION_MS")
|
||||||
|
dict["then"] = time.time()
|
||||||
|
elif event == "track_changed":
|
||||||
|
dict["art"] = _get_first("COVERS")
|
||||||
|
dict["duration"] = round(_get_time("DURATION_MS"))
|
||||||
|
dict["title"] = _get("NAME")
|
||||||
|
item_type = _get("ITEM_TYPE")
|
||||||
|
match item_type:
|
||||||
|
case "Track":
|
||||||
|
dict["album"] = _get("ALBUM")
|
||||||
|
dict["artist"] = _get_first("ARTISTS")
|
||||||
|
case "Episode":
|
||||||
|
dict["album"] = _get("SHOW_NAME")
|
||||||
|
elif event == "stopped":
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
port = int(sys.argv[1])
|
||||||
|
send_event(port, event, dict)
|
||||||
|
|
||||||
|
|
||||||
|
def send_event(port, event="", dict={}):
|
||||||
|
data = json.dumps([event, dict]).encode()
|
||||||
|
with socket.socket(SOCK_AF, SOCK_TYPE) as sock:
|
||||||
|
sock.sendto(data, (HOST, port))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
_on_event()
|
@ -1,71 +1,82 @@
|
|||||||
import threading
|
|
||||||
import xbmc
|
import xbmc
|
||||||
|
|
||||||
import onevent
|
import utils
|
||||||
import service
|
|
||||||
|
|
||||||
|
|
||||||
class Player(xbmc.Player):
|
class Player(xbmc.Player):
|
||||||
|
@utils.logged_method
|
||||||
|
def __init__(self, target, file, librespot):
|
||||||
|
self._target = target
|
||||||
|
self.file = file
|
||||||
|
self._librespot = librespot
|
||||||
|
self._dnd_kodi = utils.get_setting("dnd_kodi") == "true"
|
||||||
|
self._was_playing_file = False
|
||||||
|
if not self._dnd_kodi or not self.isPlaying():
|
||||||
|
self._librespot.start()
|
||||||
|
|
||||||
def __init__(self, dnd_kodi='false', librespot=None, **kwargs):
|
def _on_playback_ended(self):
|
||||||
super().__init__()
|
was_playing_file = self._was_playing_file
|
||||||
self._dnd_kodi = (dnd_kodi == 'true')
|
self._was_playing_file = False
|
||||||
self._thread = threading.Thread(daemon=True, target=self._run)
|
if was_playing_file:
|
||||||
self._thread.start()
|
self._librespot.restart()
|
||||||
self.last_file = None
|
|
||||||
self.librespot = librespot
|
|
||||||
if not (self._dnd_kodi and self.isPlaying()):
|
|
||||||
self.librespot.start()
|
|
||||||
|
|
||||||
def onAVStarted(self):
|
|
||||||
file = self.getPlayingFile()
|
|
||||||
if file != self.librespot.file:
|
|
||||||
if self._dnd_kodi:
|
|
||||||
self.librespot.stop()
|
|
||||||
elif self.last_file == self.librespot.file:
|
|
||||||
self.librespot.restart()
|
|
||||||
self.last_file = file
|
|
||||||
|
|
||||||
def onLibrespotStopped(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def onLibrespotTrackChanged(self, album='', art='', artist='', title=''):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def onPlayBackEnded(self):
|
|
||||||
if self.last_file == self.librespot.file:
|
|
||||||
self.librespot.restart()
|
|
||||||
else:
|
else:
|
||||||
self.librespot.start()
|
self._librespot.start()
|
||||||
self.last_file = None
|
|
||||||
|
|
||||||
|
def is_playing_file(self):
|
||||||
|
return self.isPlaying() and self.getPlayingFile() == self.file
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
|
def onAVStarted(self):
|
||||||
|
if self.is_playing_file():
|
||||||
|
self._was_playing_file = True
|
||||||
|
self.on_playback_started()
|
||||||
|
else:
|
||||||
|
self._was_playing_file = False
|
||||||
|
if self._dnd_kodi:
|
||||||
|
self._librespot.stop()
|
||||||
|
else:
|
||||||
|
self._librespot.start()
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
|
def onPlayBackEnded(self):
|
||||||
|
self._on_playback_ended()
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
def onPlayBackError(self):
|
def onPlayBackError(self):
|
||||||
self.onPlayBackEnded()
|
self._on_playback_ended()
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
def onPlayBackStopped(self):
|
def onPlayBackStopped(self):
|
||||||
self.onPlayBackEnded()
|
self._on_playback_ended()
|
||||||
|
|
||||||
# fixes unexpected behaviour of Player.stop()
|
def on_playback_started(self):
|
||||||
def stop(self):
|
pass
|
||||||
xbmc.executebuiltin('PlayerControl(Stop)')
|
|
||||||
|
|
||||||
def _run(self):
|
def do_paused(self, **_):
|
||||||
service.log('onevent dispatcher started')
|
pass
|
||||||
for event in onevent.receive_event():
|
|
||||||
try:
|
|
||||||
player_event = event.pop(onevent.KEY_PLAYER_EVENT)
|
|
||||||
if player_event == onevent.PLAYER_EVENT_STOPPED:
|
|
||||||
self.onLibrespotStopped()
|
|
||||||
elif player_event == onevent.PLAYER_EVENT_TRACK_CHANGED:
|
|
||||||
self.onLibrespotTrackChanged(**event)
|
|
||||||
except Exception as e:
|
|
||||||
service.log(e, True)
|
|
||||||
service.log('onevent dispatcher stopped')
|
|
||||||
|
|
||||||
def __enter__(self):
|
def do_playing(self, **_):
|
||||||
return self
|
pass
|
||||||
|
|
||||||
def __exit__(self, *args):
|
def do_position_correction(self, **_):
|
||||||
onevent.send_event({})
|
pass
|
||||||
self._thread.join()
|
|
||||||
self.onLibrespotStopped()
|
def do_seeked(self, **_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
|
def do_stopped(self, **_):
|
||||||
|
if self._was_playing_file:
|
||||||
|
self._was_playing_file = False
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
|
def do_track_changed(self, album="", art="", artist="", title="", **_):
|
||||||
|
if not self.isPlaying():
|
||||||
|
utils.notification(title, artist if artist else album, art)
|
||||||
|
|
||||||
|
def on_librespot_started(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_librespot_stopped(self):
|
||||||
|
pass
|
||||||
|
@ -0,0 +1,36 @@
|
|||||||
|
import xbmcgui
|
||||||
|
|
||||||
|
import player
|
||||||
|
import spotify
|
||||||
|
import utils
|
||||||
|
|
||||||
|
|
||||||
|
class Player(player.Player):
|
||||||
|
@utils.logged_method
|
||||||
|
def __init__(self, target, file, librespot):
|
||||||
|
super().__init__(target, file, librespot)
|
||||||
|
self._list_item = xbmcgui.ListItem(path=self.file)
|
||||||
|
self._list_item.setProperties(
|
||||||
|
{
|
||||||
|
"inputstream": "inputstream.ffmpeg",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._info_tag_music = self._list_item.getMusicInfoTag()
|
||||||
|
|
||||||
|
def do_paused(self, **kwargs):
|
||||||
|
self.do_playing(**kwargs)
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
|
def do_playing(self, **_):
|
||||||
|
if not self.is_playing_file():
|
||||||
|
self.play(self.file, self._list_item)
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
|
def do_track_changed(self, album="", art="", artist="", title="", **_):
|
||||||
|
fanart = spotify.get_fanart(art)
|
||||||
|
self._list_item.setArt({"fanart": fanart, "thumb": art})
|
||||||
|
self._info_tag_music.setAlbum(album)
|
||||||
|
self._info_tag_music.setArtist(artist)
|
||||||
|
self._info_tag_music.setTitle(title)
|
||||||
|
if self.is_playing_file():
|
||||||
|
self.updateInfoTag(self._list_item)
|
@ -0,0 +1,60 @@
|
|||||||
|
import time
|
||||||
|
import xbmcgui
|
||||||
|
|
||||||
|
import player
|
||||||
|
import spotify
|
||||||
|
import utils
|
||||||
|
|
||||||
|
|
||||||
|
class Player(player.Player):
|
||||||
|
@utils.logged_method
|
||||||
|
def __init__(self, target, file, librespot):
|
||||||
|
super().__init__(target, file, librespot)
|
||||||
|
self._list_item = xbmcgui.ListItem(path=self.file)
|
||||||
|
self._list_item.setProperties(
|
||||||
|
{
|
||||||
|
"inputstream": "inputstream.ffmpeg",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._info_tag_music = self._list_item.getMusicInfoTag()
|
||||||
|
self._is_paused = False
|
||||||
|
|
||||||
|
def _do_playing(self, paused, position=0.0, then=0.0, **_):
|
||||||
|
self._is_paused = paused
|
||||||
|
if self.is_playing_file():
|
||||||
|
self.do_seeked(position, then)
|
||||||
|
else:
|
||||||
|
self._position = position
|
||||||
|
self._then = then
|
||||||
|
self.play(self.file, self._list_item)
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
|
def do_paused(self, **kwargs):
|
||||||
|
self._do_playing(True, **kwargs)
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
|
def do_playing(self, **kwargs):
|
||||||
|
self._do_playing(False, **kwargs)
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
|
def do_seeked(self, position=0.0, then=0.0, **_):
|
||||||
|
if self._is_paused:
|
||||||
|
self.seekTime(position)
|
||||||
|
self.pause()
|
||||||
|
else:
|
||||||
|
self.seekTime(position - then + time.time())
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
|
def do_track_changed(self, album="", art="", artist="", duration=0.0, title="", **_):
|
||||||
|
fanart = spotify.get_fanart(art)
|
||||||
|
self._list_item.setArt({"fanart": fanart, "thumb": art})
|
||||||
|
self._info_tag_music.setAlbum(album)
|
||||||
|
self._info_tag_music.setArtist(artist)
|
||||||
|
self._info_tag_music.setDuration(duration)
|
||||||
|
self._info_tag_music.setTitle(title)
|
||||||
|
if self.is_playing_file():
|
||||||
|
self.updateInfoTag(self._list_item)
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
|
def on_playback_started(self):
|
||||||
|
self.do_seeked(self._position, self._then)
|
@ -0,0 +1,49 @@
|
|||||||
|
import subprocess
|
||||||
|
|
||||||
|
import utils
|
||||||
|
|
||||||
|
|
||||||
|
def _run(command):
|
||||||
|
stdout = subprocess.run(
|
||||||
|
command.split(), stdout=subprocess.PIPE, text=True
|
||||||
|
).stdout.rstrip()
|
||||||
|
utils.log(f"{command}: {stdout}")
|
||||||
|
return stdout
|
||||||
|
|
||||||
|
|
||||||
|
class PulseAudio:
|
||||||
|
@utils.logged_method
|
||||||
|
def __init__(self, address="127.0.0.1", device="librespot", port="23432"):
|
||||||
|
self._device = device
|
||||||
|
self._file = f"rtp://{address}:{port}"
|
||||||
|
self._sap_server = subprocess.Popen(
|
||||||
|
f"nc -lup 9875 -s {address}".split(),
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
self._m1 = _run(f"pactl load-module module-null-sink sink_name={device}")
|
||||||
|
self.suspend_sink(1)
|
||||||
|
self._m2 = _run(
|
||||||
|
f"pactl load-module module-rtp-send destination_ip={address} inhibit_auto_suspend=always port={port} source={device}.monitor"
|
||||||
|
)
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
|
def __exit__(self, *args):
|
||||||
|
self.suspend_sink("1")
|
||||||
|
for m in [self._m2, self._m1]:
|
||||||
|
if m:
|
||||||
|
_run(f"pactl unload-module {m}")
|
||||||
|
self._sap_server.terminate()
|
||||||
|
|
||||||
|
def get_device(self):
|
||||||
|
return self._device
|
||||||
|
|
||||||
|
def get_file(self):
|
||||||
|
return self._file
|
||||||
|
|
||||||
|
def suspend_sink(self, bit):
|
||||||
|
_run(f"pactl suspend-sink {bit}")
|
@ -1,88 +1,51 @@
|
|||||||
import PIL.Image
|
import event_handler
|
||||||
import urllib.request
|
import librespot
|
||||||
import tempfile
|
import utils
|
||||||
import os
|
|
||||||
import xbmc
|
|
||||||
import xbmcaddon
|
|
||||||
import xbmcgui
|
|
||||||
|
|
||||||
_ADDON = xbmcaddon.Addon()
|
|
||||||
_ICON = _ADDON.getAddonInfo('icon')
|
|
||||||
_NAME = _ADDON.getAddonInfo('name')
|
|
||||||
_DIALOG = xbmcgui.Dialog()
|
|
||||||
|
|
||||||
|
|
||||||
def log(message, show=False):
|
class Service:
|
||||||
xbmc.log(f'{_NAME}: {message}', xbmc.LOGINFO if show else xbmc.LOGDEBUG)
|
@utils.logged_method
|
||||||
|
def __init__(self, backend, device, file=""):
|
||||||
|
self.backend = backend
|
||||||
def notification(message='', sound=False, heading=_NAME, icon=_ICON, time=5000):
|
self.device = device
|
||||||
_DIALOG.notification(heading, message, icon, time, sound)
|
self.file = file
|
||||||
|
|
||||||
|
|
||||||
_FANART_DIR = os.path.join(tempfile.gettempdir(), 'librespot.fanart')
|
|
||||||
|
|
||||||
|
|
||||||
def get_fanart(url, max_fanarts):
|
|
||||||
name = os.path.basename(url)
|
|
||||||
target = os.path.join(_FANART_DIR, f'{name}_16x9')
|
|
||||||
if not os.path.exists(target):
|
|
||||||
if not os.path.exists(_FANART_DIR):
|
|
||||||
os.makedirs(_FANART_DIR)
|
|
||||||
files = os.listdir(_FANART_DIR)
|
|
||||||
files = [os.path.join(_FANART_DIR, file) for file in files if os.path.isfile(
|
|
||||||
os.path.join(_FANART_DIR, file))]
|
|
||||||
files.sort(key=os.path.getmtime)
|
|
||||||
for file in files[:-max_fanarts]:
|
|
||||||
os.remove(file)
|
|
||||||
source = os.path.join(_FANART_DIR, f'{name}_9x9')
|
|
||||||
urllib.request.urlretrieve(url, source)
|
|
||||||
image = PIL.Image.open(source)
|
|
||||||
width, height = image.size
|
|
||||||
new_width = int(height * 16 / 9)
|
|
||||||
delta_w = new_width - width
|
|
||||||
new_image = PIL.Image.new('RGB', (new_width, height), (0, 0, 0))
|
|
||||||
new_image.paste(image, (delta_w // 2, 0))
|
|
||||||
new_image.save(target, 'JPEG', optimize=True)
|
|
||||||
os.remove(source)
|
|
||||||
return target
|
|
||||||
|
|
||||||
|
|
||||||
_SETTINGS = {
|
|
||||||
'alsa_device': 'hw:2,0',
|
|
||||||
'backend': 'pulseaudio_rtp',
|
|
||||||
'dnd_kodi': 'false',
|
|
||||||
'name': f'{_NAME}@{{}}',
|
|
||||||
'options': '',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_setting(setting, default):
|
|
||||||
value = _ADDON.getSetting(setting)
|
|
||||||
return value if value else default
|
|
||||||
|
|
||||||
|
|
||||||
def _get_librespot():
|
|
||||||
while True:
|
|
||||||
settings = {k: _get_setting(k, v) for k, v in _SETTINGS.items()}
|
|
||||||
backend = settings.pop('backend')
|
|
||||||
librespot_class = __import__(f'librespot_{backend}').Librespot
|
|
||||||
with librespot_class(**settings) as librespot:
|
|
||||||
with librespot.get_player(librespot=librespot, **settings) as player:
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
class Monitor(xbmc.Monitor):
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._get_librespot = _get_librespot()
|
|
||||||
self.onSettingsChanged()
|
|
||||||
|
|
||||||
def onSettingsChanged(self):
|
|
||||||
log('settings changed')
|
|
||||||
next(self._get_librespot)
|
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
def run(self):
|
def run(self):
|
||||||
self.waitForAbort()
|
if self.file:
|
||||||
log('abort requested')
|
player = utils.get_setting("player")
|
||||||
self._get_librespot.close()
|
module_player = f"player_{player}"
|
||||||
|
else:
|
||||||
|
module_player = "player"
|
||||||
|
|
||||||
|
with event_handler.EventHandler(self) as self.event_handler:
|
||||||
|
with librespot.Librespot(self, self.backend, self.device) as self.librespot:
|
||||||
|
self.player = __import__(module_player).Player(self, self.file, self.librespot)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
del self.player
|
||||||
|
|
||||||
|
def on_event_paused(self, **_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_event_playing(self, **_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_event_position_correction(self, **_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_event_seeked(self, **_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_event_stopped(self, **kwargs):
|
||||||
|
self.player.do_stopped(**kwargs)
|
||||||
|
|
||||||
|
def on_event_track_changed(self, **kwargs):
|
||||||
|
self.player.do_track_changed(**kwargs)
|
||||||
|
|
||||||
|
def on_librespot_started(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_librespot_stopped(self):
|
||||||
|
pass
|
||||||
|
@ -0,0 +1,45 @@
|
|||||||
|
import pulseaudio
|
||||||
|
import service
|
||||||
|
import utils
|
||||||
|
|
||||||
|
|
||||||
|
class Service(service.Service):
|
||||||
|
@utils.logged_method
|
||||||
|
def __init__(self):
|
||||||
|
self.pulseaudio = pulseaudio.PulseAudio()
|
||||||
|
backend = "pulseaudio"
|
||||||
|
device = self.pulseaudio.get_device()
|
||||||
|
file = self.pulseaudio.get_file()
|
||||||
|
super().__init__(backend, device, file)
|
||||||
|
|
||||||
|
@utils.logged_method
|
||||||
|
def run(self):
|
||||||
|
with self.pulseaudio:
|
||||||
|
yield from super().run()
|
||||||
|
|
||||||
|
def on_event_paused(self, **kwargs):
|
||||||
|
self.pulseaudio.suspend_sink("0")
|
||||||
|
self.player.do_paused(**kwargs)
|
||||||
|
|
||||||
|
def on_event_playing(self, **kwargs):
|
||||||
|
self.pulseaudio.suspend_sink("0")
|
||||||
|
self.player.do_playing(**kwargs)
|
||||||
|
|
||||||
|
def on_event_position_correction(self, **kwargs):
|
||||||
|
self.player.do_seeked(**kwargs)
|
||||||
|
|
||||||
|
def on_event_seeked(self, **kwargs):
|
||||||
|
self.player.do_seeked(**kwargs)
|
||||||
|
|
||||||
|
def on_event_stopped(self, **_):
|
||||||
|
self.player.do_stopped()
|
||||||
|
self.pulseaudio.suspend_sink("1")
|
||||||
|
|
||||||
|
def on_event_track_changed(self, **kwargs):
|
||||||
|
self.player.do_track_changed(**kwargs)
|
||||||
|
|
||||||
|
def on_librespot_started(self):
|
||||||
|
self.pulseaudio.suspend_sink("1")
|
||||||
|
|
||||||
|
def on_librespot_stopped(self):
|
||||||
|
self.pulseaudio.suspend_sink("1")
|
@ -0,0 +1,34 @@
|
|||||||
|
import PIL.Image
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
_DIRECTORY_NAME = "librespot.coverart"
|
||||||
|
_DIRECTORY_PATH = os.path.join(tempfile.gettempdir(), _DIRECTORY_NAME)
|
||||||
|
_MAX_COVERARTS = 10
|
||||||
|
|
||||||
|
|
||||||
|
def get_fanart(url):
|
||||||
|
name = os.path.basename(url)
|
||||||
|
target = os.path.join(_DIRECTORY_PATH, f"{name}")
|
||||||
|
if not os.path.exists(target):
|
||||||
|
if not os.path.exists(_DIRECTORY_PATH):
|
||||||
|
os.makedirs(_DIRECTORY_PATH)
|
||||||
|
paths = [
|
||||||
|
os.path.join(_DIRECTORY_PATH, file) for file in os.listdir(_DIRECTORY_PATH)
|
||||||
|
]
|
||||||
|
paths = [path for path in paths if os.path.isfile(path)]
|
||||||
|
paths.sort(key=os.path.getmtime)
|
||||||
|
for path in paths[:-_MAX_COVERARTS]:
|
||||||
|
os.remove(path)
|
||||||
|
source = os.path.join(_DIRECTORY_PATH, f"{name}.tmp")
|
||||||
|
urllib.request.urlretrieve(url, source)
|
||||||
|
image = PIL.Image.open(source)
|
||||||
|
width, height = image.size
|
||||||
|
new_width = int(height * 16 / 9)
|
||||||
|
delta_w = new_width - width
|
||||||
|
new_image = PIL.Image.new("RGB", (new_width, height), (0, 0, 0))
|
||||||
|
new_image.paste(image, (delta_w // 2, 0))
|
||||||
|
new_image.save(target, "JPEG", optimize=True)
|
||||||
|
os.remove(source)
|
||||||
|
return target
|
@ -0,0 +1,47 @@
|
|||||||
|
import os
|
||||||
|
import xbmc
|
||||||
|
import xbmcaddon
|
||||||
|
import xbmcgui
|
||||||
|
import xbmcvfs
|
||||||
|
|
||||||
|
_ADDON_HOME = xbmcvfs.translatePath(xbmcaddon.Addon().getAddonInfo("profile"))
|
||||||
|
_ADDON_ICON = xbmcaddon.Addon().getAddonInfo("icon")
|
||||||
|
_ADDON_NAME = xbmcaddon.Addon().getAddonInfo("name")
|
||||||
|
_ADDON_PATH = xbmcaddon.Addon().getAddonInfo("path")
|
||||||
|
_DIALOG = xbmcgui.Dialog()
|
||||||
|
_SETTINGS = {
|
||||||
|
"alsa_device": "hw:2,0",
|
||||||
|
"backend": "pulseaudio",
|
||||||
|
"dnd_kodi": "false",
|
||||||
|
"name": "Librespot{}",
|
||||||
|
"player": "default",
|
||||||
|
}
|
||||||
|
|
||||||
|
os.environ["PATH"] += os.pathsep + os.path.join(_ADDON_PATH, "bin")
|
||||||
|
os.makedirs(_ADDON_HOME, exist_ok=True)
|
||||||
|
os.chdir(_ADDON_HOME)
|
||||||
|
|
||||||
|
|
||||||
|
def get_setting(key):
|
||||||
|
setting = xbmcaddon.Addon().getSetting(key)
|
||||||
|
return setting if setting else _SETTINGS[key]
|
||||||
|
|
||||||
|
|
||||||
|
def log(message, notify=False):
|
||||||
|
xbmc.log(f"{_ADDON_NAME}: {message}", xbmc.LOGINFO)
|
||||||
|
if notify:
|
||||||
|
notification(message)
|
||||||
|
|
||||||
|
|
||||||
|
def logged_method(method):
|
||||||
|
def logger(*args, **kwargs):
|
||||||
|
log(f"{method.__module__}.{method.__qualname__}")
|
||||||
|
return method(*args, **kwargs)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def notification(
|
||||||
|
message="", heading=_ADDON_NAME, icon=_ADDON_ICON, sound=False, time=5000
|
||||||
|
):
|
||||||
|
_DIALOG.notification(heading, message, icon, time, sound)
|
@ -1,10 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||||
<settings>
|
<settings>
|
||||||
<category label="30100">
|
<category label="30100">
|
||||||
<setting label="30101" id="name" type="text" default="Librespot@{}" />
|
<setting label="30101" id="name" type="text" default="Librespot@{}" />
|
||||||
<setting label="30102" id="dnd_kodi" type="bool" default="false" />
|
<setting label="30102" id="dnd_kodi" type="bool" default="false" />
|
||||||
<setting label="30103" id="options" type="text" default="" />
|
<setting label="30103" id="backend" type="select" values="pulseaudio|alsa" />
|
||||||
<setting label="30104" id="backend" type="select" values="pulseaudio_rtp|alsa" />
|
<setting label="30104" id="player" type="select" values="default|basic" subsetting="true" visible="eq(-1,0)" />
|
||||||
<setting label="30105" id="alsa_device" type="text" default="hw:2,0" subsetting="true" visible="eq(-1,1)" />
|
<setting label="30105" id="alsa_device" type="text" default="hw:2,0" subsetting="true" visible="eq(-2,1)" />
|
||||||
</category>
|
</category>
|
||||||
</settings>
|
</settings>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user