From 84cc997dc0aa92eae9766b2d5bfa686ce84df18b Mon Sep 17 00:00:00 2001 From: Henri Funck Date: Mon, 27 Mar 2023 05:32:11 +0200 Subject: [PATCH] librespot: update to 03b547d --- .../addons/service/librespot/changelog.txt | 3 +- packages/addons/service/librespot/package.mk | 15 +- .../patches/librespot-01_notify_kodi.patch | 108 ------------ .../service/librespot/source/bin/onevent.py | 66 +++++++ .../service/librespot/source/default.py | 23 ++- .../service.librespot-alsa.service | 30 ---- .../resources/language/English/strings.po | 32 ++-- .../source/resources/lib/external_player.py | 8 + .../source/resources/lib/internal_player.py | 34 ++++ .../source/resources/lib/librespot.py | 93 ++++++++++ .../source/resources/lib/librespot_alsa.py | 11 ++ .../resources/lib/librespot_pulseaudio_rtp.py | 73 ++++++++ .../source/resources/lib/ls_addon.py | 49 ------ .../source/resources/lib/ls_librespot.py | 164 ------------------ .../source/resources/lib/ls_monitor.py | 35 ---- .../source/resources/lib/ls_pulseaudio.py | 46 ----- .../source/resources/lib/ls_spotify.py | 80 --------- .../librespot/source/resources/lib/player.py | 71 ++++++++ .../librespot/source/resources/lib/service.py | 88 ++++++++++ .../librespot/source/resources/settings.xml | 12 +- 20 files changed, 493 insertions(+), 548 deletions(-) delete mode 100644 packages/addons/service/librespot/patches/librespot-01_notify_kodi.patch create mode 100644 packages/addons/service/librespot/source/bin/onevent.py delete mode 100644 packages/addons/service/librespot/source/miscellaneous/service.librespot-alsa.service create mode 100644 packages/addons/service/librespot/source/resources/lib/external_player.py create mode 100644 packages/addons/service/librespot/source/resources/lib/internal_player.py create mode 100644 packages/addons/service/librespot/source/resources/lib/librespot.py create mode 100644 packages/addons/service/librespot/source/resources/lib/librespot_alsa.py create mode 100644 packages/addons/service/librespot/source/resources/lib/librespot_pulseaudio_rtp.py delete mode 100644 packages/addons/service/librespot/source/resources/lib/ls_addon.py delete mode 100644 packages/addons/service/librespot/source/resources/lib/ls_librespot.py delete mode 100644 packages/addons/service/librespot/source/resources/lib/ls_monitor.py delete mode 100644 packages/addons/service/librespot/source/resources/lib/ls_pulseaudio.py delete mode 100644 packages/addons/service/librespot/source/resources/lib/ls_spotify.py create mode 100644 packages/addons/service/librespot/source/resources/lib/player.py create mode 100644 packages/addons/service/librespot/source/resources/lib/service.py diff --git a/packages/addons/service/librespot/changelog.txt b/packages/addons/service/librespot/changelog.txt index 927b2407d4..9a854f4905 100644 --- a/packages/addons/service/librespot/changelog.txt +++ b/packages/addons/service/librespot/changelog.txt @@ -1 +1,2 @@ -initial release +1 +- update librespot to githash 03b547d (2023-04-16) diff --git a/packages/addons/service/librespot/package.mk b/packages/addons/service/librespot/package.mk index 79d193a074..197c2785e7 100644 --- a/packages/addons/service/librespot/package.mk +++ b/packages/addons/service/librespot/package.mk @@ -3,13 +3,14 @@ # Copyright (C) 2017-present Team LibreELEC (https://libreelec.tv) PKG_NAME="librespot" -PKG_VERSION="0.3.1" -PKG_SHA256="d360eaf61ad4216ee2c4a4d583d61c8ec7367b5efbe512011d049f73e4f24952" -PKG_REV="0" +PKG_VERSION="03b547d3b3b434811225ebc153c651e5f1917b95" +PKG_VERSION_DATE="2023-04-16" +PKG_SHA256="fd6ffed75f5d9a0941c4ca2a86192c17217b60d1d301e47ff0d8aeec3b3e0e97" +PKG_REV="1" PKG_ARCH="any" PKG_LICENSE="MIT" PKG_SITE="https://github.com/librespot-org/librespot/" -PKG_URL="https://github.com/librespot-org/librespot/archive/v${PKG_VERSION}.tar.gz" +PKG_URL="https://github.com/librespot-org/librespot/archive/${PKG_VERSION}.tar.gz" PKG_DEPENDS_TARGET="toolchain alsa-lib avahi pulseaudio cargo:host" PKG_SECTION="service" PKG_SHORTDESC="Librespot: play Spotify through Kodi using a Spotify app as a remote" @@ -19,10 +20,14 @@ PKG_TOOLCHAIN="manual" PKG_IS_ADDON="yes" PKG_ADDON_NAME="Librespot" PKG_ADDON_TYPE="xbmc.service" -PKG_ADDON_REQUIRES="script.module.requests:0.0.0" PKG_MAINTAINER="Anton Voyl (awiouy)" make_target() { + unset CFLAGS + unset CXXFLAGS + unset CPPFLAGS + unset LDFLAGS + export RUSTC_LINKER=${CC} cargo build \ --target ${TARGET_NAME} \ --release \ diff --git a/packages/addons/service/librespot/patches/librespot-01_notify_kodi.patch b/packages/addons/service/librespot/patches/librespot-01_notify_kodi.patch deleted file mode 100644 index 3ab7f7f135..0000000000 --- a/packages/addons/service/librespot/patches/librespot-01_notify_kodi.patch +++ /dev/null @@ -1,108 +0,0 @@ -commit 10489ef0b9de4241eb8e007596f3d62616120545 -Author: awiouy -Date: Fri May 29 07:40:19 2020 +0200 - - Notify Kodi - -diff --git a/core/src/spotify_id.rs b/core/src/spotify_id.rs -index 1a5fcd2..c670977 100644 ---- a/core/src/spotify_id.rs -+++ b/core/src/spotify_id.rs -@@ -9,6 +9,12 @@ - Podcast, - NonPlayable, - } -+ -+impl fmt::Display for SpotifyAudioType { -+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { -+ write!(f, "{:?}", self) -+ } -+} - - impl From<&str> for SpotifyAudioType { - fn from(v: &str) -> Self { -diff --git a/playback/src/config.rs b/playback/src/config.rs -index 9d65042..6d098db 100644 ---- a/playback/src/config.rs -+++ b/playback/src/config.rs -@@ -142,6 +142,7 @@ - // pass function pointers so they can be lazily instantiated *after* spawning a thread - // (thereby circumventing Send bounds that they might not satisfy) - pub ditherer: Option, -+ pub notify_kodi: bool, - } - - impl Default for PlayerConfig { -@@ -159,6 +160,7 @@ - normalisation_knee: 1.0, - passthrough: false, - ditherer: Some(mk_ditherer::), -+ notify_kodi: false, - } - } - } -diff --git a/playback/src/player.rs b/playback/src/player.rs -index 2dd8f3b..67b3b28 100644 ---- a/playback/src/player.rs -+++ b/playback/src/player.rs -@@ -1868,6 +1868,10 @@ impl PlayerInternal { - } - } - -+ fn notify_kodi(&mut self, event: String) { -+ eprintln!("@{}", event); -+ } -+ - fn send_event(&mut self, event: PlayerEvent) { - let mut index = 0; - while index < self.event_senders.len() { -@@ -1878,6 +1882,16 @@ impl PlayerInternal { - } - } - } -+ if self.config.notify_kodi { -+ use PlayerEvent::*; -+ match event { -+ Playing {track_id, .. } => self.notify_kodi(["Playing", -+ &track_id.audio_type.to_string(), -+ &track_id.to_base62()].join(" ")), -+ Stopped { .. } => self.notify_kodi("Stopped".to_string()), -+ _ => () -+ } -+ } - } - - fn load_track( -diff --git a/src/main.rs b/src/main.rs -index 2efd62b..ecee2ff 100644 ---- a/src/main.rs -+++ b/src/main.rs -@@ -424,6 +424,11 @@ - "", - PASSTHROUGH, - "Pass a raw stream to the output. Only works with the pipe and subprocess backends.", -+ ) -+ .optflag( -+ "", -+ "notify-kodi", -+ "Notify Kodi", - ); - - let matches = match opts.parse(&args[1..]) { -@@ -644,6 +649,8 @@ fn setup(args: &[String]) -> Setup { - ) - }; - -+ let notify_kodi = matches.opt_present("notify-kodi"); -+ - let session_config = { - let device_id = device_id(&name); - -@@ -763,6 +763,7 @@ - normalisation_release, - normalisation_knee, - ditherer, -+ notify_kodi: notify_kodi, - } - }; - diff --git a/packages/addons/service/librespot/source/bin/onevent.py b/packages/addons/service/librespot/source/bin/onevent.py new file mode 100644 index 0000000000..ec0151506e --- /dev/null +++ b/packages/addons/service/librespot/source/bin/onevent.py @@ -0,0 +1,66 @@ +#!/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 acf8371f3f..01c1dcde58 100644 --- a/packages/addons/service/librespot/source/default.py +++ b/packages/addons/service/librespot/source/default.py @@ -1,8 +1,25 @@ import os import sys +import xbmcaddon +import xbmcvfs -sys.path.append(os.path.join(os.path.dirname(__file__), 'resources', 'lib')) -from ls_monitor import Monitor as Monitor +def _set_home(): + 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() diff --git a/packages/addons/service/librespot/source/miscellaneous/service.librespot-alsa.service b/packages/addons/service/librespot/source/miscellaneous/service.librespot-alsa.service deleted file mode 100644 index 35fc6a0d2a..0000000000 --- a/packages/addons/service/librespot/source/miscellaneous/service.librespot-alsa.service +++ /dev/null @@ -1,30 +0,0 @@ -# Librespot for ALSA -# Copy this file to '/storage/.config/system.d/service.librespot-alsa.service' and adapt it to your needs -# Enable the service with 'systemctl enable /storage/.config/system.d/service.librespot-alsa.service' -# Start the service with 'systemctl start service.librespot-alsa.service' -# If you update the file, reload units with 'systemctl daemon-reload' and restart the service - -[Unit] -Description=librespot alsa backend -After=network-online.target -Wants=network-online.target - -[Service] -Environment=LD_LIBRARY_PATH=/storage/.kodi/addons/service.librespot/lib -#Enable Raspberry Pi onboard audio -#ExecStartPre=-dtparam audio=on -#Set Raspberry Pi playback route -#ExecStartPre=-amixer -c 0 cset name="PCM Playback Route" 1 -ExecStart=/storage/.kodi/addons/service.librespot/bin/librespot \ - --backend alsa \ - --bitrate 320 \ - --cache "/storage/.config/lsa_cache" \ -# Use 'aplay -L' to list available devices -# --device "default:CARD=ALSA" \ - --device-type computer \ - --disable-audio-cache \ - --name "Librespot ALSA" \ -Restart=always - -[Install] -WantedBy=network-online.target 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 785318d9c0..8a12a179c3 100644 --- a/packages/addons/service/librespot/source/resources/language/English/strings.po +++ b/packages/addons/service/librespot/source/resources/language/English/strings.po @@ -8,29 +8,21 @@ msgid "Configuration" msgstr "" msgctxt "#30101" -msgid "Autoplay" -msgstr "" - -msgctxt "#30102" -msgid "Discovery" -msgstr "" - -msgctxt "#30103" -msgid "Username" -msgstr "" - -msgctxt "#30104" -msgid "Password" -msgstr "" - -msgctxt "#30105" msgid "Name" msgstr "" -msgctxt "#30106" -msgid "RTP Port" +msgctxt "#30102" +msgid "Do not disturb Kodi" msgstr "" -msgctxt "#30107" -msgid "Connect Port" +msgctxt "#30103" +msgid "User options" +msgstr "" + +msgctxt "#30104" +msgid "Backend" +msgstr "" + +msgctxt "#30105" +msgid "ALSA device" msgstr "" diff --git a/packages/addons/service/librespot/source/resources/lib/external_player.py b/packages/addons/service/librespot/source/resources/lib/external_player.py new file mode 100644 index 0000000000..3fd6714295 --- /dev/null +++ b/packages/addons/service/librespot/source/resources/lib/external_player.py @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000000..0636e92fd8 --- /dev/null +++ b/packages/addons/service/librespot/source/resources/lib/internal_player.py @@ -0,0 +1,34 @@ +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 new file mode 100644 index 0000000000..983b83a7c3 --- /dev/null +++ b/packages/addons/service/librespot/source/resources/lib/librespot.py @@ -0,0 +1,93 @@ +import shlex +import socket +import subprocess +import threading + +import external_player +import internal_player +import service + + +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() + 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') + + 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 new file mode 100644 index 0000000000..6b1446bdbe --- /dev/null +++ b/packages/addons/service/librespot/source/resources/lib/librespot_alsa.py @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000000..6b1a79a46a --- /dev/null +++ b/packages/addons/service/librespot/source/resources/lib/librespot_pulseaudio_rtp.py @@ -0,0 +1,73 @@ +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/ls_addon.py b/packages/addons/service/librespot/source/resources/lib/ls_addon.py deleted file mode 100644 index 3501a71aca..0000000000 --- a/packages/addons/service/librespot/source/resources/lib/ls_addon.py +++ /dev/null @@ -1,49 +0,0 @@ -import os -import socket -import xbmc -import xbmcvfs -import xbmcaddon -import xbmcgui - - -DEFAULTS = dict( - autoplay='true', - bitrate='320', - device='librespot', - discovery='true', - name='Librespot@{}', - password='', - rtp_dest='127.0.0.1', - rtp_port='24642', - connect_port='0', - username='', -) - -ADDON = xbmcaddon.Addon() -ADDON_HOME = xbmcvfs.translatePath(ADDON.getAddonInfo('profile')) -ADDON_ICON = ADDON.getAddonInfo('icon') -ADDON_NAME = ADDON.getAddonInfo('name') -ADDON_PATH = ADDON.getAddonInfo('path') -ADDON_ENVT = dict( - LD_LIBRARY_PATH=os.path.join(ADDON_PATH, 'lib'), - PATH=os.path.join(ADDON_PATH, 'bin')) -DIALOG = xbmcgui.Dialog() - - -def get_settings(): - if not os.path.exists(ADDON_HOME): - os.makedirs(ADDON_HOME) - settings = dict() - for id in DEFAULTS.keys(): - value = ADDON.getSetting(id) - settings[id] = DEFAULTS[id] if value == '' else value - settings['name'] = settings['name'].format(socket.gethostname()) - return settings - - -def log(message): - xbmc.log('{}: {}'.format(ADDON_NAME, message), xbmc.LOGINFO) - - -def notification(message): - DIALOG.notification(ADDON_NAME, message, ADDON_ICON) diff --git a/packages/addons/service/librespot/source/resources/lib/ls_librespot.py b/packages/addons/service/librespot/source/resources/lib/ls_librespot.py deleted file mode 100644 index f774c2e753..0000000000 --- a/packages/addons/service/librespot/source/resources/lib/ls_librespot.py +++ /dev/null @@ -1,164 +0,0 @@ -import shlex -import subprocess -import threading -import xbmc -import xbmcgui - -from ls_addon import ADDON_ENVT as ADDON_ENVT -from ls_addon import ADDON_HOME as ADDON_HOME -from ls_addon import get_settings as get_settings -from ls_addon import log as log -from ls_pulseaudio import Pulseaudio as Pulseaudio -from ls_spotify import SPOTIFY as SPOTIFY - - -LIBRESPOT = 'librespot' \ - ' --backend pulseaudio' \ - ' --bitrate {bitrate}' \ - ' --cache cache' \ - ' --device {device}' \ - ' --device-type TV' \ - ' --disable-audio-cache' \ - ' --name {name}' \ - ' --notify-kodi' -LIBRESPOT_AUTOPLAY = ' --autoplay' -LIBRESPOT_AUTHENTICATE = ' --disable-discovery' \ - ' --password {password}' \ - ' --username {username}' - -CODEC = 'pcm_s16be' -MAX_PANICS = 3 - - -class Librespot(xbmc.Player): - - def __init__(self): - super().__init__() - settings = get_settings() - quoted = {k: shlex.quote(v) for (k, v) in settings.items()} - command = LIBRESPOT - if settings['connect_port'] != "0": - command += ' --zeroconf-port %s ' % settings['connect_port'] - if settings['autoplay'] == 'true': - command += LIBRESPOT_AUTOPLAY - if (settings['discovery'] == 'false' and - settings['password'] != '' and - settings['username'] != ''): - command += LIBRESPOT_AUTHENTICATE - self.command = shlex.split(command.format(**quoted)) - log(shlex.split(command.format(**dict(quoted, password='*obfuscated*')))) - self.is_aborted = False - self.is_dead = False - self.pulseaudio = Pulseaudio(settings) - self.listitem = xbmcgui.ListItem() - self.listitem.addStreamInfo('audio', {'codec': CODEC}) - self.listitem.setPath(path=self.pulseaudio.url) - - def __enter__(self): - self.pulseaudio.load_modules() - self.panics = 0 - self.librespot = None - self.is_playing_librespot = False - if not self.isPlaying(): - self.start_librespot() - - def __exit__(self, *args): - self.stop_librespot() - self.pulseaudio.unload_modules() - - def on_event_panic(self): - self.pulseaudio.suspend_sink(1) - self.panics += 1 - log('event panic {}/{}'.format(self.panics, MAX_PANICS)) - self.is_dead = self.panics >= MAX_PANICS - self.stop_librespot(True) - - def on_event_playing(self, type, id): - log('event playing') - SPOTIFY.update_listitem(self.listitem, type, id, self.country) - if not self.isPlaying(): - log('starting librespot playback') - self.pulseaudio.suspend_sink(0) - self.play(self.pulseaudio.url, self.listitem) - elif self.is_playing_librespot: - log('updating librespot playback') - self.updateInfoTag(self.listitem) - - def on_event_stopped(self): - self.pulseaudio.suspend_sink(1) - log('event stopped') - self.panics = 0 - self.stop() - - def onPlayBackEnded(self): - self.onPlayBackStopped() - - def onPlayBackError(self): - self.onPlayBackStopped() - - def onPlayBackStarted(self): - log('Kodi playback started') - self.is_playing_librespot = self.getPlayingFile() == self.pulseaudio.url - if not self.is_playing_librespot: - self.stop_librespot() - - def onPlayBackStopped(self): - if self.is_playing_librespot: - log('librespot playback stopped') - self.is_playing_librespot = False - self.stop_librespot(True) - else: - log('Kodi playback stopped') - self.start_librespot() - - def run_librespot(self): - log('librespot thread started') - self.restart = True - while self.restart and not self.is_dead: - self.librespot = subprocess.Popen( - self.command, - cwd=ADDON_HOME, - env=ADDON_ENVT, - stderr=subprocess.STDOUT, - stdout=subprocess.PIPE, - text=True, - encoding='utf-8') - log('librespot started') - with self.librespot.stdout: - for line in self.librespot.stdout: - words = line.split() - if words[0] == '@Playing': - self.on_event_playing(words[1], words[2]) - elif words[0] == '@Stopped': - self.on_event_stopped() - elif words[0] == 'stack': - self.on_event_panic() - else: - log(line.rstrip()) - if 'Country:' in line: - self.country = words[-1].strip('"') - log('country={}'.format(self.country)) - self.pulseaudio.suspend_sink(1) - self.stop() - self.librespot.wait() - log('librespot stopped') - self.librespot = None - log('librespot thread stopped') - - def start_librespot(self): - if self.librespot is None: - self.thread = threading.Thread(target=self.run_librespot) - self.thread.start() - - def stop(self): - if self.is_playing_librespot and not self.is_aborted: - log('stopping librespot playback') - self.is_playing_librespot = False - super().stop() - - def stop_librespot(self, restart=False): - self.restart = restart - if self.librespot is not None: - self.librespot.terminate() - if not restart: - self.thread.join() diff --git a/packages/addons/service/librespot/source/resources/lib/ls_monitor.py b/packages/addons/service/librespot/source/resources/lib/ls_monitor.py deleted file mode 100644 index fb56eb7cfe..0000000000 --- a/packages/addons/service/librespot/source/resources/lib/ls_monitor.py +++ /dev/null @@ -1,35 +0,0 @@ -import xbmc - -from ls_addon import log as log -from ls_addon import notification as notification -from ls_librespot import Librespot as Librespot - - -class Monitor(xbmc.Monitor): - - def onSettingsChanged(self): - self.is_changed = True - - def run(self): - log('monitor started') - is_aborted = False - is_dead = False - while not (is_aborted or is_dead): - self.is_changed = False - librespot = Librespot() - with librespot: - while True: - is_aborted = self.waitForAbort(1) - if is_aborted: - log('monitor aborted') - librespot.is_aborted = True - break - is_dead = librespot.is_dead - if is_dead: - log('librespot died') - notification('Too many errors') - break - if self.is_changed: - log('settings changed') - break - log('monitor stopped') diff --git a/packages/addons/service/librespot/source/resources/lib/ls_pulseaudio.py b/packages/addons/service/librespot/source/resources/lib/ls_pulseaudio.py deleted file mode 100644 index 4fcbfdb570..0000000000 --- a/packages/addons/service/librespot/source/resources/lib/ls_pulseaudio.py +++ /dev/null @@ -1,46 +0,0 @@ -import subprocess - -from ls_addon import log as log - - -def run(command): - return subprocess.check_output(command.split(), text=True) - - -class Pulseaudio: - - def __init__(self, settings): - self.null_sink = dict( - module='module-null-sink', - args='sink_name={device}'.format(**settings) - ) - self.rtp_send = dict( - module='module-rtp-send', - args='destination_ip={rtp_dest} port={rtp_port}' - ' source={device}.monitor'.format(**settings) - ) - self.suspend = 'pactl suspend-sink {device} {{}}'.format(**settings) - self.url = 'rtp://{rtp_dest}:{rtp_port}'.format(**settings) - - def list_modules(self): - return [module.split('\t') - for module in run('pactl list modules short').splitlines()[::-1]] - - def load_modules(self): - args = [module[2] for module in self.list_modules()] - for module in [self.null_sink, self.rtp_send]: - if module['args'] not in args: - run('pactl load-module {} {}'.format( - module['module'], module['args'])) - log('loaded {} {}'.format(module['module'], module['args'])) - self.suspend_sink(1) - - def suspend_sink(self, bit): - run(self.suspend.format(bit)) - log('suspended sink {}'.format(bit)) - - def unload_modules(self): - for module in self.list_modules(): - if module[2] in [self.null_sink['args'], self.rtp_send['args']]: - run('pactl unload-module {}'.format(module[0])) - log('unloaded {} {}'.format(module[1], module[2])) diff --git a/packages/addons/service/librespot/source/resources/lib/ls_spotify.py b/packages/addons/service/librespot/source/resources/lib/ls_spotify.py deleted file mode 100644 index d3f1f06ebc..0000000000 --- a/packages/addons/service/librespot/source/resources/lib/ls_spotify.py +++ /dev/null @@ -1,80 +0,0 @@ -import requests -import time - - -from ls_addon import ADDON_ICON as ADDON_ICON -from ls_addon import log as log - - -SPOTIFY_ENDPOINT_EPISODES = 'https://api.spotify.com/v1/episodes/' -SPOTIFY_ENDPOINT_TRACKS = 'https://api.spotify.com/v1/tracks/' -SPOTIFY_HEADERS = { - 'Accept': 'application/json', - 'Content-Type': 'application/json', -} -SPOTIFY_REQUEST_TOKEN = { - 'url': 'https://accounts.spotify.com/api/token', - 'data': {'grant_type': 'client_credentials'}, - 'headers': {'Authorization': 'Basic MTY5ZGY1NTMyZGVlNDdhNTk5MTNmODUyOGU4M2FlNzE6MWYzZDhiNTA3YmJlNGY2OGJlYjNhNDQ3MmU4YWQ0MTE='} -} - - -def get(info, indices, default): - try: - for index in indices: - info = info[index] - return info.encode('utf-8') - except LookupError: - return default - - -class Spotify: - - def __init__(self): - self.headers = SPOTIFY_HEADERS - self.expiration = time.time() - - def get_headers(self): - if time.time() > self.expiration: - log('token expired') - token = requests.post(**SPOTIFY_REQUEST_TOKEN).json() - log(token) - self.expiration = time.time() + float(token['expires_in']) - 5 - self.headers['Authorization'] = 'Bearer {}'.format( - token['access_token']) - - def get_endpoint(self, endpoint, id, market): - try: - self.get_headers() - return requests.get(url=endpoint + id, - headers=self.headers, - params=dict(market=market)).json() - except Exception as e: - log('failed to get {} from Spotify {}'.format(endpoint, e)) - return {} - - def update_listitem(self, listitem, type, id, market='SE'): - if type == 'Podcast': - info = self.get_endpoint(SPOTIFY_ENDPOINT_EPISODES, id, market) - album = get(info, ['show', 'name'], 'unknown show',) - artist = get(info, ['show', 'publisher'], 'unknown publisher') - thumb = get(info, ['images', 0, 'url'], ADDON_ICON) - title = get(info, ['name'], 'unknown episode') - elif type == 'Track': - info = self.get_endpoint(SPOTIFY_ENDPOINT_TRACKS, id, market) - album = get(info, ['album', 'name'], 'unknown album') - artist = get(info, ['artists', 0, 'name'], 'unknown artist') - thumb = get(info, ['album', 'images', 0, 'url'], ADDON_ICON) - title = get(info, ['name'], 'unknown title') - else: - album = '' - artist = 'Unknown Media Type' - thumb = ADDON_ICON - title = '' - listitem.setArt(dict(fanart=thumb, thumb=thumb)) - listitem.setInfo('music', dict( - album=album, artist=artist, title=title)) - log('{}#{}#{}#{}'.format(title, artist, album, thumb)) - - -SPOTIFY = Spotify() diff --git a/packages/addons/service/librespot/source/resources/lib/player.py b/packages/addons/service/librespot/source/resources/lib/player.py new file mode 100644 index 0000000000..6ed93dab1e --- /dev/null +++ b/packages/addons/service/librespot/source/resources/lib/player.py @@ -0,0 +1,71 @@ +import threading +import xbmc + +import onevent +import service + + +class Player(xbmc.Player): + + 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() + else: + self.librespot.start() + self.last_file = None + + def onPlayBackError(self): + self.onPlayBackEnded() + + 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() diff --git a/packages/addons/service/librespot/source/resources/lib/service.py b/packages/addons/service/librespot/source/resources/lib/service.py new file mode 100644 index 0000000000..74b21c83e2 --- /dev/null +++ b/packages/addons/service/librespot/source/resources/lib/service.py @@ -0,0 +1,88 @@ +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() + + +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) + + def run(self): + self.waitForAbort() + log('abort requested') + self._get_librespot.close() diff --git a/packages/addons/service/librespot/source/resources/settings.xml b/packages/addons/service/librespot/source/resources/settings.xml index 4ad74191b5..c446d47724 100644 --- a/packages/addons/service/librespot/source/resources/settings.xml +++ b/packages/addons/service/librespot/source/resources/settings.xml @@ -1,12 +1,10 @@ - - - - - - - + + + + +