Merge pull request #9497 from henri-funck/librespot-python

librespot: update python scripts
This commit is contained in:
mglae 2024-12-05 18:12:45 +01:00 committed by GitHub
commit 81ab9da457
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 599 additions and 440 deletions

View File

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

View File

@ -1,25 +1,8 @@
import os
import sys
import xbmcaddon
import xbmcvfs
sys.path.append(os.path.join(os.path.dirname(__file__), "resources", "lib"))
def _set_home():
home = xbmcvfs.translatePath(xbmcaddon.Addon().getAddonInfo('profile'))
os.makedirs(home, exist_ok=True)
os.chdir(home)
import monitor
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()
monitor.run()

View File

@ -16,11 +16,11 @@ msgid "Do not disturb Kodi"
msgstr ""
msgctxt "#30103"
msgid "User options"
msgid "Backend"
msgstr ""
msgctxt "#30104"
msgid "Backend"
msgid "Player"
msgstr ""
msgctxt "#30105"

View File

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

View File

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

View File

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

View File

@ -1,93 +1,78 @@
import shlex
import socket
import subprocess
import threading
import external_player
import internal_player
import service
import utils
class Librespot:
def __init__(self,
bitrate='320',
device_type='tv',
max_retries='5',
name='Librespot@{}',
options='',
**kwargs):
name = name.format(socket.gethostname())
self.command = [
'librespot',
'--bitrate', f'{bitrate}',
'--device-type', f'{device_type}',
'--disable-audio-cache',
'--disable-credential-cache',
'--name', f'{name}',
'--onevent', 'onevent.py',
'--quiet',
] + shlex.split(options)
service.log(self.command)
self.file = ''
self._is_started = threading.Event()
self._is_stopped = threading.Event()
@utils.logged_method
def __init__(self, target, backend, device):
self._target = target
name = utils.get_setting("name").format(socket.gethostname())
self._command = [
"librespot",
"--backend", backend,
"--bitrate", "320",
"--device", device,
"--device-type", "tv",
"--disable-audio-cache",
"--disable-credential-cache",
"--name", name,
"--onevent", target.event_handler.get_onevent(),
"--quiet",
]
self._failures = 0
self._max_failures = 5
self._librespot = None
self._max_retries = int(max_retries)
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')
self._get_librespot = self._schedule_librespot()
@utils.logged_method
def __enter__(self):
return self
def __exit__(self, *args):
self.stop()
@utils.logged_method
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()

View File

@ -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}',
]

View File

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

View File

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

View File

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

View File

@ -1,71 +1,82 @@
import threading
import xbmc
import onevent
import service
import utils
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):
super().__init__()
self._dnd_kodi = (dnd_kodi == 'true')
self._thread = threading.Thread(daemon=True, target=self._run)
self._thread.start()
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()
def _on_playback_ended(self):
was_playing_file = self._was_playing_file
self._was_playing_file = False
if was_playing_file:
self._librespot.restart()
else:
self.librespot.start()
self.last_file = None
self._librespot.start()
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):
self.onPlayBackEnded()
self._on_playback_ended()
@utils.logged_method
def onPlayBackStopped(self):
self.onPlayBackEnded()
self._on_playback_ended()
# fixes unexpected behaviour of Player.stop()
def stop(self):
xbmc.executebuiltin('PlayerControl(Stop)')
def on_playback_started(self):
pass
def _run(self):
service.log('onevent dispatcher started')
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 do_paused(self, **_):
pass
def __enter__(self):
return self
def do_playing(self, **_):
pass
def __exit__(self, *args):
onevent.send_event({})
self._thread.join()
self.onLibrespotStopped()
def do_position_correction(self, **_):
pass
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

View File

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

View File

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

View File

@ -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}")

View File

@ -1,88 +1,51 @@
import PIL.Image
import urllib.request
import tempfile
import os
import xbmc
import xbmcaddon
import xbmcgui
_ADDON = xbmcaddon.Addon()
_ICON = _ADDON.getAddonInfo('icon')
_NAME = _ADDON.getAddonInfo('name')
_DIALOG = xbmcgui.Dialog()
import event_handler
import librespot
import utils
def log(message, show=False):
xbmc.log(f'{_NAME}: {message}', xbmc.LOGINFO if show else xbmc.LOGDEBUG)
def notification(message='', sound=False, heading=_NAME, icon=_ICON, time=5000):
_DIALOG.notification(heading, message, icon, time, sound)
_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)
class Service:
@utils.logged_method
def __init__(self, backend, device, file=""):
self.backend = backend
self.device = device
self.file = file
@utils.logged_method
def run(self):
self.waitForAbort()
log('abort requested')
self._get_librespot.close()
if self.file:
player = utils.get_setting("player")
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

View File

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

View File

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

View File

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

View File

@ -3,8 +3,8 @@
<category label="30100">
<setting label="30101" id="name" type="text" default="Librespot@{}" />
<setting label="30102" id="dnd_kodi" type="bool" default="false" />
<setting label="30103" id="options" type="text" default="" />
<setting label="30104" id="backend" type="select" values="pulseaudio_rtp|alsa" />
<setting label="30105" id="alsa_device" type="text" default="hw:2,0" subsetting="true" visible="eq(-1,1)" />
<setting label="30103" id="backend" type="select" values="pulseaudio|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(-2,1)" />
</category>
</settings>