diff --git a/packages/addons/service/librespot/source/bin/onevent.py b/packages/addons/service/librespot/source/bin/onevent.py deleted file mode 100644 index ec0151506e3..00000000000 --- a/packages/addons/service/librespot/source/bin/onevent.py +++ /dev/null @@ -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) diff --git a/packages/addons/service/librespot/source/default.py b/packages/addons/service/librespot/source/default.py index 01c1dcde58e..acdb5c41e55 100644 --- a/packages/addons/service/librespot/source/default.py +++ b/packages/addons/service/librespot/source/default.py @@ -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() diff --git a/packages/addons/service/librespot/source/resources/language/English/strings.po b/packages/addons/service/librespot/source/resources/language/English/strings.po index 8a12a179c36..00361273bac 100644 --- a/packages/addons/service/librespot/source/resources/language/English/strings.po +++ b/packages/addons/service/librespot/source/resources/language/English/strings.po @@ -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" diff --git a/packages/addons/service/librespot/source/resources/lib/event_handler.py b/packages/addons/service/librespot/source/resources/lib/event_handler.py new file mode 100644 index 00000000000..ef2695337a1 --- /dev/null +++ b/packages/addons/service/librespot/source/resources/lib/event_handler.py @@ -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}" diff --git a/packages/addons/service/librespot/source/resources/lib/external_player.py b/packages/addons/service/librespot/source/resources/lib/external_player.py deleted file mode 100644 index 3fd67142957..00000000000 --- a/packages/addons/service/librespot/source/resources/lib/external_player.py +++ /dev/null @@ -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) diff --git a/packages/addons/service/librespot/source/resources/lib/internal_player.py b/packages/addons/service/librespot/source/resources/lib/internal_player.py deleted file mode 100644 index 0636e92fd87..00000000000 --- a/packages/addons/service/librespot/source/resources/lib/internal_player.py +++ /dev/null @@ -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() diff --git a/packages/addons/service/librespot/source/resources/lib/librespot.py b/packages/addons/service/librespot/source/resources/lib/librespot.py index 983b83a7c34..db8b35c7dd5 100644 --- a/packages/addons/service/librespot/source/resources/lib/librespot.py +++ b/packages/addons/service/librespot/source/resources/lib/librespot.py @@ -1,93 +1,78 @@ -import shlex import socket import subprocess import threading -import external_player -import internal_player -import service +import utils class Librespot: + @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._get_librespot = self._schedule_librespot() + + @utils.logged_method + def __enter__(self): + return self - 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 __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 - self._max_retries = int(max_retries) - self._retries = 0 - self._thread = threading.Thread() + while True: + yield - def get_player(self, **kwargs): - return (internal_player if self.file else external_player).Player(**kwargs) + 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): - if self._thread.is_alive(): - self._librespot.terminate() - else: - self.start() + next(self._get_librespot) + @utils.logged_method 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) + if self._librespot is None or self._librespot.poll() is not None: + next(self._get_librespot) + @utils.logged_method def stop(self): - if self._thread.is_alive(): - self._is_stopped.set() + if self._librespot is not None: 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') - - def __enter__(self): - return self - - def __exit__(self, *args): - self.stop() diff --git a/packages/addons/service/librespot/source/resources/lib/librespot_alsa.py b/packages/addons/service/librespot/source/resources/lib/librespot_alsa.py deleted file mode 100644 index 6b1446bdbe6..00000000000 --- a/packages/addons/service/librespot/source/resources/lib/librespot_alsa.py +++ /dev/null @@ -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}', - ] diff --git a/packages/addons/service/librespot/source/resources/lib/librespot_pulseaudio_rtp.py b/packages/addons/service/librespot/source/resources/lib/librespot_pulseaudio_rtp.py deleted file mode 100644 index 6b1a79a46a4..00000000000 --- a/packages/addons/service/librespot/source/resources/lib/librespot_pulseaudio_rtp.py +++ /dev/null @@ -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') diff --git a/packages/addons/service/librespot/source/resources/lib/monitor.py b/packages/addons/service/librespot/source/resources/lib/monitor.py new file mode 100644 index 00000000000..cb6ad74b05f --- /dev/null +++ b/packages/addons/service/librespot/source/resources/lib/monitor.py @@ -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() diff --git a/packages/addons/service/librespot/source/resources/lib/onevent.py b/packages/addons/service/librespot/source/resources/lib/onevent.py new file mode 100644 index 00000000000..49b442ec785 --- /dev/null +++ b/packages/addons/service/librespot/source/resources/lib/onevent.py @@ -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() diff --git a/packages/addons/service/librespot/source/resources/lib/player.py b/packages/addons/service/librespot/source/resources/lib/player.py index 6ed93dab1ea..405ace106c9 100644 --- a/packages/addons/service/librespot/source/resources/lib/player.py +++ b/packages/addons/service/librespot/source/resources/lib/player.py @@ -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 _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() + + def is_playing_file(self): + return self.isPlaying() and self.getPlayingFile() == self.file + @utils.logged_method def onAVStarted(self): - file = self.getPlayingFile() - if file != self.librespot.file: + 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() - elif self.last_file == self.librespot.file: - self.librespot.restart() - self.last_file = file + self._librespot.stop() + else: + self._librespot.start() - def onLibrespotStopped(self): + @utils.logged_method + def onPlayBackEnded(self): + self._on_playback_ended() + + @utils.logged_method + def onPlayBackError(self): + self._on_playback_ended() + + @utils.logged_method + def onPlayBackStopped(self): + self._on_playback_ended() + + def on_playback_started(self): pass - def onLibrespotTrackChanged(self, album='', art='', artist='', title=''): + def do_paused(self, **_): pass - def onPlayBackEnded(self): - if self.last_file == self.librespot.file: - self.librespot.restart() - else: - self.librespot.start() - self.last_file = None + def do_playing(self, **_): + pass - def onPlayBackError(self): - self.onPlayBackEnded() + def do_position_correction(self, **_): + pass - def onPlayBackStopped(self): - self.onPlayBackEnded() - - # fixes unexpected behaviour of Player.stop() - def stop(self): - xbmc.executebuiltin('PlayerControl(Stop)') - - 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 __enter__(self): - return self - - def __exit__(self, *args): - onevent.send_event({}) - 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 diff --git a/packages/addons/service/librespot/source/resources/lib/player_basic.py b/packages/addons/service/librespot/source/resources/lib/player_basic.py new file mode 100644 index 00000000000..955b17b22c3 --- /dev/null +++ b/packages/addons/service/librespot/source/resources/lib/player_basic.py @@ -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) diff --git a/packages/addons/service/librespot/source/resources/lib/player_default.py b/packages/addons/service/librespot/source/resources/lib/player_default.py new file mode 100644 index 00000000000..db7047d9f60 --- /dev/null +++ b/packages/addons/service/librespot/source/resources/lib/player_default.py @@ -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) diff --git a/packages/addons/service/librespot/source/resources/lib/pulseaudio.py b/packages/addons/service/librespot/source/resources/lib/pulseaudio.py new file mode 100644 index 00000000000..1f8ea27575c --- /dev/null +++ b/packages/addons/service/librespot/source/resources/lib/pulseaudio.py @@ -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}") diff --git a/packages/addons/service/librespot/source/resources/lib/service.py b/packages/addons/service/librespot/source/resources/lib/service.py index 74b21c83e2e..6b52b163b5c 100644 --- a/packages/addons/service/librespot/source/resources/lib/service.py +++ b/packages/addons/service/librespot/source/resources/lib/service.py @@ -1,88 +1,51 @@ -import PIL.Image -import urllib.request -import tempfile -import os -import xbmc -import xbmcaddon -import xbmcgui +import event_handler +import librespot +import utils -_ADDON = xbmcaddon.Addon() -_ICON = _ADDON.getAddonInfo('icon') -_NAME = _ADDON.getAddonInfo('name') -_DIALOG = xbmcgui.Dialog() +class Service: + @utils.logged_method + def __init__(self, backend, device, file=""): + self.backend = backend + self.device = device + self.file = file -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': '', -} + @utils.logged_method + def run(self): + 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 _get_setting(setting, default): - value = _ADDON.getSetting(setting) - return value if value else default + def on_event_paused(self, **_): + pass + def on_event_playing(self, **_): + pass -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 + def on_event_position_correction(self, **_): + pass + def on_event_seeked(self, **_): + pass -class Monitor(xbmc.Monitor): + def on_event_stopped(self, **kwargs): + self.player.do_stopped(**kwargs) - def __init__(self): - self._get_librespot = _get_librespot() - self.onSettingsChanged() + def on_event_track_changed(self, **kwargs): + self.player.do_track_changed(**kwargs) - def onSettingsChanged(self): - log('settings changed') - next(self._get_librespot) + def on_librespot_started(self): + pass - def run(self): - self.waitForAbort() - log('abort requested') - self._get_librespot.close() + def on_librespot_stopped(self): + pass diff --git a/packages/addons/service/librespot/source/resources/lib/service_pulseaudio.py b/packages/addons/service/librespot/source/resources/lib/service_pulseaudio.py new file mode 100644 index 00000000000..c06a73764f7 --- /dev/null +++ b/packages/addons/service/librespot/source/resources/lib/service_pulseaudio.py @@ -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") diff --git a/packages/addons/service/librespot/source/resources/lib/spotify.py b/packages/addons/service/librespot/source/resources/lib/spotify.py new file mode 100644 index 00000000000..ed5babc59d8 --- /dev/null +++ b/packages/addons/service/librespot/source/resources/lib/spotify.py @@ -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 diff --git a/packages/addons/service/librespot/source/resources/lib/utils.py b/packages/addons/service/librespot/source/resources/lib/utils.py new file mode 100644 index 00000000000..2e684ab51cc --- /dev/null +++ b/packages/addons/service/librespot/source/resources/lib/utils.py @@ -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) diff --git a/packages/addons/service/librespot/source/resources/settings.xml b/packages/addons/service/librespot/source/resources/settings.xml index c446d477249..51f26c3dfb9 100644 --- a/packages/addons/service/librespot/source/resources/settings.xml +++ b/packages/addons/service/librespot/source/resources/settings.xml @@ -1,10 +1,10 @@ - - - - - + + + + +