diff --git a/documentation/developers/status.md b/documentation/developers/status.md index 0a40f8125..f01b3937d 100644 --- a/documentation/developers/status.md +++ b/documentation/developers/status.md @@ -27,7 +27,6 @@ Topics marked _in progress_ are already in the process of implementation by comm - [Volume](#volume) - [GPIO](#gpio) - [WLAN](#wlan) - - [Spotify](#spotify) - [Others](#others) - [Start-up stuff](#start-up-stuff) - [Debug Tools](#debug-tools) diff --git a/installation/routines/setup_jukebox_core.sh b/installation/routines/setup_jukebox_core.sh index 2e4fbad39..2459ffb5e 100644 --- a/installation/routines/setup_jukebox_core.sh +++ b/installation/routines/setup_jukebox_core.sh @@ -95,6 +95,7 @@ _jukebox_core_build_and_install_pyzmq() { _jukebox_core_install_settings() { print_lc " Register Jukebox settings" cp -f "${INSTALLATION_PATH}/resources/default-settings/jukebox.default.yaml" "${SETTINGS_PATH}/jukebox.yaml" + cp -f "${INSTALLATION_PATH}/resources/default-settings/player.default.yaml" "${SETTINGS_PATH}/player.yaml" cp -f "${INSTALLATION_PATH}/resources/default-settings/logger.default.yaml" "${SETTINGS_PATH}/logger.yaml" } diff --git a/requirements.txt b/requirements.txt index 8ddfc881a..39bd4f02a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,6 +19,8 @@ ruamel.yaml requests # For the publisher event reactor loop: tornado +# for collecting audiofiles +pyyaml # RPi's GPIO packages: RPi.GPIO diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index 9bb214f3d..5a94b3e5b 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -11,7 +11,7 @@ modules: jingle: jingle jingle.alsawave: jingle.alsawave jingle.jinglemp3: jingle.jinglemp3 - player: playermpd + player: player.plugin cards: rfid.cards rfid: rfid.reader timers: timers @@ -76,23 +76,6 @@ jinglemp3: alsawave: # Config of the Wave through ALSA Jingle Service device: default -playermpd: - host: localhost - status_file: ../../shared/settings/music_player_status.json - second_swipe_action: - # Note: Does not follow the RPC alias convention (yet) - # Must be one of: 'toggle', 'play', 'skip', 'rewind', 'replay', 'none' - alias: toggle - library: - update_on_startup: true - check_user_rights: true - mpd_conf: ~/.config/mpd/mpd.conf - # Must be one of: 'none', 'stop', 'rewind': - end_of_playlist_next_action: none - # Must be one of: 'none', 'prev', 'rewind': - stopped_prev_action: prev - # Must be one of: 'none', 'next', 'rewind': - stopped_next_action: next rpc: tcp_port: 5555 websocket_port: 5556 diff --git a/resources/default-settings/player.default.yaml b/resources/default-settings/player.default.yaml new file mode 100644 index 000000000..96e18f192 --- /dev/null +++ b/resources/default-settings/player.default.yaml @@ -0,0 +1,17 @@ +# IMPORTANT: +# Always use relative path from settingsfile `../../`, but do not use relative paths with `~/`. +# Sole (!) exception is in playermpd.mpd_conf +players: + content: + audiofile: /../../shared/audiofolders/audiofiles.yaml +playermpd: + host: localhost + status_file: ../../shared/settings/music_player_status.json + second_swipe_action: + # Note: Does not follow the RPC alias convention (yet) + # Must be one of: 'toggle', 'play', 'skip', 'rewind', 'replay', 'none' + alias: toggle + library: + update_on_startup: true + check_user_rights: true + mpd_conf: ~/.config/mpd/mpd.conf diff --git a/src/jukebox/components/player/__init__.py b/src/jukebox/components/player/__init__.py deleted file mode 100644 index ecff33449..000000000 --- a/src/jukebox/components/player/__init__.py +++ /dev/null @@ -1,55 +0,0 @@ -import os -import re -import logging -import jukebox.cfghandler -from typing import Optional - - -logger = logging.getLogger('jb.player') -cfg = jukebox.cfghandler.get_handler('jukebox') - - -def _get_music_library_path(conf_file): - """Parse the music directory from the mpd.conf file""" - pattern = re.compile(r'^\s*music_directory\s*"(.*)"', re.I) - directory = None - with open(conf_file, 'r') as f: - for line in f: - res = pattern.match(line) - if res: - directory = res.group(1) - break - else: - logger.error(f"Could not find music library path in {conf_file}") - logger.debug(f"MPD music lib path = {directory}; from {conf_file}") - return directory - - -class MusicLibPath: - """Extract the music directory from the mpd.conf file""" - def __init__(self): - self._music_library_path = None - mpd_conf_file = cfg.setndefault('playermpd', 'mpd_conf', value='~/.config/mpd/mpd.conf') - try: - self._music_library_path = _get_music_library_path(os.path.expanduser(mpd_conf_file)) - except Exception as e: - logger.error(f"Could not determine music library directory from '{mpd_conf_file}'") - logger.error(f"Reason: {e.__class__.__name__}: {e}") - - @property - def music_library_path(self): - return self._music_library_path - - -# --------------------------------------------------------------------------- - - -_MUSIC_LIBRARY_PATH: Optional[MusicLibPath] = None - - -def get_music_library_path(): - """Get the music library path""" - global _MUSIC_LIBRARY_PATH - if _MUSIC_LIBRARY_PATH is None: - _MUSIC_LIBRARY_PATH = MusicLibPath() - return _MUSIC_LIBRARY_PATH.music_library_path diff --git a/src/jukebox/components/player/backends/__init__.py b/src/jukebox/components/player/backends/__init__.py new file mode 100644 index 000000000..8daf7ba37 --- /dev/null +++ b/src/jukebox/components/player/backends/__init__.py @@ -0,0 +1,96 @@ +from abc import ABC, abstractmethod + + +class BackendPlayer(ABC): + """ + Abstract Class to inherit, so that you can build a proper new Player + """ + + @abstractmethod + def next(self): + pass + + @abstractmethod + def prev(self): + pass + + @abstractmethod + def play(self): + pass + + @abstractmethod + def play_single(self, uri): + pass + + @abstractmethod + def play_album(self, albumartist, album): + pass + + @abstractmethod + def play_folder(self, folder: str, recursive: bool): + """ + Playback a music folder. + + :param folder: Folder path relative to music library path + :param recursive: Add folder recursively + """ + pass + + @abstractmethod + def toggle(self): + pass + + @abstractmethod + def shuffle(self): + pass + + @abstractmethod + def pause(self): + pass + + @abstractmethod + def stop(self): + pass + + @abstractmethod + def get_queue(self): + pass + + @abstractmethod + def repeat(self): + pass + + @abstractmethod + def seek(self): + pass + + @abstractmethod + def get_albums(self): + pass + + @abstractmethod + def get_single_coverart(self, song_url): + pass + + @abstractmethod + def get_album_coverart(self): + pass + + @abstractmethod + def list_dirs(self): + pass + + @abstractmethod + def get_song_by_url(self, song_url): + pass + + @abstractmethod + def get_folder_content(self, folder): + """ + Get the folder content as content list with meta-information. Depth is always 1. + + Call repeatedly to descend in hierarchy + + :param folder: Folder path relative to music library path + """ + pass diff --git a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py new file mode 100644 index 000000000..1cdd6503b --- /dev/null +++ b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py @@ -0,0 +1,420 @@ +# Copyright: 2022 +# SPDX License Identifier: MIT License + +import asyncio +import logging +import os.path +import re +from pathlib import Path +from typing import Optional + +import jukebox.plugs as plugin +import jukebox.cfghandler +import jukebox.playlistgenerator as playlistgenerator + +from mpd.asyncio import MPDClient +from components.player.backends import BackendPlayer +from components.player.core.coverart_cache_manager import CoverartCacheManager +from jukebox import publishing + +logger = logging.getLogger('jb.mpd') +cfg = jukebox.cfghandler.get_handler('player') + + +def sanitize(path: str): + """ + Trim path to enable MPD to search in database + + :param path: File or folder path + """ + _music_library_path_absolute = os.path.expanduser(get_music_library_path()) + return os.path.normpath(path).replace(f'{_music_library_path_absolute}/', '') + + +class MPDBackend(BackendPlayer): + + def __init__(self, event_loop): + self.client = MPDClient() + self.loop = event_loop + self.host = cfg.setndefault('playermpd', 'host', value='localhost') + self.port = cfg.setndefault('playermpd', 'port', value='6600') + self.coverart_cache_manager = CoverartCacheManager() + self._flavors = {'folder': self.get_files, + 'file': self.get_track, + 'album': self.get_album_from_uri, + 'podcast': self.get_podcast, + 'livestream': self.get_livestream} + self._active_uri = '' + # TODO: If connect fails on first try this is non recoverable + self.connect() + # Start the status listener in an endless loop in the event loop + # asyncio.run_coroutine_threadsafe(self._status_listener(), self.loop) + + # ------------------------------------------------------------------------------------------------------ + # Bring calls to client functions from the synchronous part into the async domain + # Async function of the MPD client return a asyncio.future as a result + # That means we must + # - first await the function execution in the event loop + # _run_cmd_async: an async function + # - second then wait for the future result to be available in the sync domain + # _run_cmd: a sync function that schedules the async function in the event loop for execution + # and wait for the future result by calling ..., self.loop).result() + # Since this must be done for every command crossing the async/sync domain, we keep it generic and + # pass method and arguments to these two wrapper functions that do the scheduling and waiting + + async def _run_cmd_async(self, afunc, *args, **kwargs): + return await afunc(*args, **kwargs) + + def _run_cmd(self, afunc, *args, **kwargs): + logger.debug(f"executing command {afunc.__name__} with params {args} {kwargs}") + return asyncio.run_coroutine_threadsafe(self._run_cmd_async(afunc, *args, **kwargs), self.loop).result() + + # ----------------------------------------------------- + # Check and update statues + + async def _connect(self): + return await self.client.connect(self.host, self.port) + + def connect(self): + """ + Connect to the MPD backend + :raises: mpd.base.ConnectionError + """ + result = asyncio.run_coroutine_threadsafe(self._connect(), self.loop).result() + logger.debug(f"Connected to MPD version {self.client.mpd_version} @ {self.host}:{self.port}") + return result + + # ----------------------------------------------------- + # Check and update statues + + async def _status_listener(self): + """The endless status listener: updates the status whenever there is a change in one MPD subsystem""" + # Calls to logger do not work + # logger.debug("MPD Status Listener started") + async for subsystem in self.client.idle(): + # logger.debug("MPD: Idle change in", subsystem) + s = await self.client.status() + # logger.debug(f"MPD: New Status: {s.result()}") + # print(f"MPD: New Status: {type(s)} // {s}") + # Now, do something with it ... + publishing.get_publisher().send('playerstatus', s) + + async def _status(self): + return await self.client.status() + + @plugin.tag + def status(self): + """Refresh the current MPD status (by a manual, sync trigger)""" + # Example + # Status: {'volume': '40', 'repeat': '0', 'random': '0', 'single': '0', 'consume': '0', 'partition': 'default', + # 'playlist': '94', 'playlistlength': '22', 'mixrampdb': '0.000000', 'state': 'play', 'song': '0', + # 'songid': '71', 'time': '1:126', 'elapsed': '1.108', 'bitrate': '96', 'duration': '125.988', + # 'audio': '44100:24:2', 'nextsong': '1', 'nextsongid': '72'} + f = asyncio.run_coroutine_threadsafe(self._status(), self.loop).result() + # print(f"Status: {f}") + # Put it into unified structure and notify global player control + # ToDo: propagate to core player + # publishing.get_publisher().send('playerstatus', f) + return f + + # ----------------------------------------------------- + # Stuff that controls current playback (i.e. moves around in the current playlist, termed "the queue") + + def next(self): + return self._run_cmd(self.client.next) + + def prev(self): + return self._run_cmd(self.client.previous) + + def play(self, idx=None): + """ + If idx /= None, start playing song idx from queue + If stopped, start with first song in queue + If paused, resume playback at current position + """ + # self.client.play() continues playing at current position + if idx is None: + return self._run_cmd(self.client.play) + else: + return self._run_cmd(self.client.play, idx) + + @plugin.tag + def play_folder(self, folder: str, recursive: bool = False): + """ + Playback a music folder. + + :param folder: Folder path relative to music library path + :param recursive: Add folder recursively + """ + self.play_uri(f"mpd:folder:{folder}", recursive=recursive) + + def play_single(self, uri): + pass + + def play_album(self, albumartist, album): + pass + + def toggle(self): + """Toggle between playback / pause""" + return self._run_cmd(self.client.pause) + + def shuffle(self): + pass + + def repeat(self): + pass + + def seek(self): + pass + + def pause(self): + """Pause playback if playing + + This is what you want as card removal action: pause the playback, so it can be resumed when card is placed + on the reader again. What happens on re-placement depends on configured second swipe option + """ + return self._run_cmd(self.client.pause, 1) + + def stop(self): + return self._run_cmd(self.client.stop) + + @plugin.tag + def get_queue(self): + return self._run_cmd(self.client.playlistinfo) + + # ----------------------------------------------------- + # Volume control (for developing only) + + async def _volume(self, value): + return await self.client.setvol(value) + + @plugin.tag + def set_volume(self, value): + return asyncio.run_coroutine_threadsafe(self._volume(value), self.loop).result() + + # ---------------------------------- + # Stuff that replaces the current playlist and starts a new playback for URI + + def play_uri(self, uri: str, **kwargs): + """Decode URI and forward play call + + mpd:folder:path/to/folder + --> Build playlist from $MUSICLIB_DIR/path/to/folder/* + + mpd:file:path/to/file.mp3 + --> Plays single file + + mpd:album:Feuerwehr:albumartist:Benjamin + -> Searches MPD database for album Feuerwehr from artist Benjamin + + Conceptual at the moment (i.e. means it will likely change): + mpd:podcast:path/to/file.yaml + --> Reads local file: $PODCAST_FOLDER/path/to/file.yaml + --> which contains: https://cool-stuff.de/podcast.xml + + mpd:livestream:path/to/file.yaml + --> Reads local file: $LIVESTREAM_FOLDER/path/to/file.yaml + --> which contains: https://hot-stuff.de/livestream.mp3 + Why go via a local file? We need to have a database with all podcasts that we can pull out and display + to the user so he can select "play this one" + + """ + self.clear() + # Clear the active uri before retrieving the track list, to avoid stale active uri in case something goes wrong + self._active_uri = '' + tracklist = self.get_from_uri(uri, **kwargs) + self._active_uri = uri + self.enqueue(tracklist) + self._restore_state() + self.play() + + def clear(self): + return self._run_cmd(self.client.clear) + + async def _enqueue(self, tracklist): + for entry in tracklist: + path = entry.get('file') + if path is not None: + await self.client.add(path) + + def enqueue(self, tracklist): + return asyncio.run_coroutine_threadsafe(self._enqueue(tracklist), self.loop).result() + + # ---------------------------------- + # Get track lists + + @plugin.tag + def get_from_uri(self, uri: str, **kwargs): + player_type, list_type, path = uri.split(':', 2) + if player_type != 'mpd': + raise KeyError(f"URI prefix must be 'mpd' not '{player_type}") + func = self._flavors.get(list_type) + if func is None: + raise KeyError(f"URI flavor '{list_type}' unknown. Must be one of: {self._flavors.keys()}.") + return func(path, **kwargs) + + @plugin.tag + def get_files(self, path, recursive=False): + """ + List file meta data for single file or all files of folder + + :returns: List of file(s) and directories including meta data + """ + path = sanitize(path) + self._run_cmd(self.client.update, path) + if os.path.isfile(path): + files = self._run_cmd(self.client.find, 'file', path) + elif not recursive: + files = self._run_cmd(self.client.lsinfo, path) + else: + files = self._run_cmd(self.client.find, 'base', path) + return files + + @plugin.tag + def get_track(self, path): + path = sanitize(path) + self._run_cmd(self.client.update, path) + playlist = self._run_cmd(self.client.find, 'file', path) + if len(playlist) != 1: + raise ValueError(f"Path decodes to more than one file: '{path}'") + file = playlist[0].get('file') + if file is None: + raise ValueError(f"Not a music file: '{path}'") + return playlist + + # ---------------------------------- + # Get albums / album tracks + + @plugin.tag + def get_albums(self): + """Returns all albums in database""" + # return asyncio.run_coroutine_threadsafe(self._get_albums(), self.loop).result() + return self._run_cmd(self.client.list, 'album', 'group', 'albumartist') + + @plugin.tag + def get_album_tracks(self, album_artist, album): + """Returns all songs of an album""" + return self._run_cmd(self.client.find, 'albumartist', album_artist, 'album', album) + + def get_album_from_uri(self, uri: str): + """Accepts full or partial uri (partial means without leading 'mpd:album:')""" + p = re.match(r"((mpd:)?album:)?(.*):albumartist:(.*)", uri) + if not p: + raise ValueError(f"Cannot decode album and/or album artist from URI: '{uri}'") + return self.get_album_tracks(album_artist=p.group(4), album=p.group(3)) + + def get_single_coverart(self, song_url): + mp3_file_path = Path(get_music_library_path(), song_url).expanduser() + cache_filename = self.coverart_cache_manager.get_cache_filename(mp3_file_path) + + return cache_filename + + def get_album_coverart(self): + pass + + def list_dirs(self): + pass + + def get_song_by_url(self, song_url): + pass + + def get_folder_content(self, folder): + logger.debug(f"get_folder_content param: {folder}") + plc = playlistgenerator.PlaylistCollector(get_music_library_path()) + plc.get_directory_content(folder) + return plc.playlist + + # ---------------------------------- + # Get podcasts / livestreams + + def _get_podcast_items(self, path): + """Decode playlist of one podcast file""" + pass + + @plugin.tag + def get_podcast(self, path): + """ + If :attr:`path is a + + * directory: List all stored podcasts in directory + * file: List podcast playlist + + """ + path = sanitize(path) + pass + + def _get_livestream_items(self, path): + """Decode playlist of one livestream file""" + pass + + @plugin.tag + def get_livestream(self, path): + """ + If :attr:`path is a + + * directory: List all stored livestreams in directory + * file: List livestream playlist + + """ + path = sanitize(path) + pass + + # ----------------------------------------------------- + # Queue / URI state (save + restore e.g. random, resume, ...) + + def save_state(self): + """Save the configuration and state of the current URI playback to the URIs state file""" + pass + + def _restore_state(self): + """ + Restore the configuration state and last played status for current active URI + """ + pass + + +# ToDo: refactor code +def _get_music_library_path(conf_file): + """Parse the music directory from the mpd.conf file""" + pattern = re.compile(r'^\s*music_directory\s*"(.*)"', re.I) + directory = None + with open(conf_file, 'r') as f: + for line in f: + res = pattern.match(line) + if res: + directory = res.group(1) + break + else: + logger.error(f"Could not find music library path in {conf_file}") + logger.debug(f"MPD music lib path = {directory}; from {conf_file}") + return directory + + +class MusicLibPath: + """Extract the music directory from the mpd.conf file""" + def __init__(self): + self._music_library_path = None + mpd_conf_file = cfg.setndefault('playermpd', 'mpd_conf', value='~/.config/mpd/mpd.conf') + try: + self._music_library_path = _get_music_library_path(os.path.expanduser(mpd_conf_file)) + except Exception as e: + logger.error(f"Could not determine music library directory from '{mpd_conf_file}'") + logger.error(f"Reason: {e.__class__.__name__}: {e}") + + @property + def music_library_path(self): + return self._music_library_path + + +# --------------------------------------------------------------------------- + + +_MUSIC_LIBRARY_PATH: Optional[MusicLibPath] = None + + +def get_music_library_path(): + """Get the music library path""" + global _MUSIC_LIBRARY_PATH + if _MUSIC_LIBRARY_PATH is None: + _MUSIC_LIBRARY_PATH = MusicLibPath() + return _MUSIC_LIBRARY_PATH.music_library_path diff --git a/src/jukebox/components/player/core/__init__.py b/src/jukebox/components/player/core/__init__.py new file mode 100644 index 000000000..c68232ccb --- /dev/null +++ b/src/jukebox/components/player/core/__init__.py @@ -0,0 +1,227 @@ +# Copyright: 2022 +# SPDX License Identifier: MIT License + +""" +Top-level player control across all player backends + +Usage concept: + +# Stuff to control playback +player.ctrl.play(...) +player.ctrl.next() + +# To get MPD specific playlists +player.mpd.get_album(...) +player.mpd.get_...(...) + +""" +import logging +from typing import Dict, Callable, Optional, Any + +import jukebox.plugs as plugin +from components.player.core.player_status import PlayerStatus +from jukebox import multitimer + +logger = logging.getLogger('jb.player') + + +class PlayerCtrl: + """The top-level player instance through which all calls go. Arbitrates between the different backends""" + + def __init__(self): + self._backends: Dict[str, Any] = {} + self._active = None + self.player_status = None + self.status_poll_interval = 0.25 + self.status_thread = multitimer.GenericEndlessTimerClass('player.timer_status', + self.status_poll_interval, self._status_poll) + self.status_thread.start() + + def _status_poll(self): + ret_status = self._active.status() + if ret_status.get('state') == 'play': + self.player_status.update(playing=True, elapsed=ret_status.get('elapsed', '0.0'), + duration=ret_status.get('duration', '0.0')) + + def register(self, name: str, backend): + self._backends[name] = backend + # For now simply default to first registered backend + if self._active is None: + self._active = self._backends.values().__iter__().__next__() + self.player_status.update(player=name) + + @plugin.tag + def get_active(self): + name = 'None' + for n, b in self._backends.items(): + if self._active == b: + name = n + break + return name + + @plugin.tag + def list_backends(self): + logger.debug(f"Backend list: {self._backends.items()}") + return [b for b in self._backends.keys()] + + @plugin.tag + def play_uri(self, uri, check_second_swipe=False, **kwargs): + # Save the current state and stop the current playback + self.stop() + # Decode card second swipe (TBD) + # And finally play + try: + player_type, _ = uri.split(':', 1) + except ValueError: + raise ValueError(f"Malformed URI: {uri}") + inst = self._backends.get(player_type) + if inst is None: + raise KeyError(f"URI player type unknown: '{player_type}'. Available backends are: {self._backends.keys()}.") + self._active = self._backends.get(player_type) + self.player_status.update(player=player_type) + self._active.play_uri(uri, **kwargs) + + def _is_second_swipe(self): + """ + Check if play request is a second swipe + + Definition second swipe: + successive swipes of the same registered ID card + + A second swipe triggers a different action than the first swipe. In certain scenarios a second + swipe needs to be treated as a first swipe: + + * if playlist has stopped playing + * playlist has run out + * playlist was stopped by trigger + * if in a place-not-swipe setup, the card remains on reader until playlist expires and player enters state stop. + Card is the removed and triggers 'card removal action' on stopped playlist. Card is placed on reader + again and must be treated as first swipe + * after reboot when last state is restored (first swipe is play which starts from the beginning or resumes, + depending on playlist configuration) + + Second swipe actions can be + + * toggle + * ignore (do nothing) + * next + * restart playlist --> is always like first swipe? + + """ + pass + + @plugin.tag + def next(self): + self._active.next() + + @plugin.tag + def prev(self): + self._active.prev() + + @plugin.tag + def play(self): + self._active.play() + self.player_status.update(playing=True) + + @plugin.tag + def play_single(self, uri): + self.play_uri(uri) + self.player_status.update(playing=True) + + @plugin.tag + def play_album(self, albumartist, album): + self._active.play_album(albumartist, album) + self.player_status.update(playing=True) + + @plugin.tag + def play_folder(self, folder, recursive): + self._active.play_folder(folder, recursive) + self.player_status.update(playing=True) + + @plugin.tag + def toggle(self): + self._active.toggle() + if self.player_status.get_value('playing') is False: + self.player_status.update(playing=True) + else: + self.player_status.update(playing=False) + + @plugin.tag + def shuffle(self, option='toggle'): + """ + Force the player to toggle the shuffle option of the current playing list/album + """ + self._active.shuffle(option) + + @plugin.tag + def pause(self): + self._active.pause() + self.player_status.update(playing=False) + + @plugin.tag + def stop(self): + # Save current state for resume functionality + self._save_state() + self._active.stop() + self.player_status.update(playing=False) + + @plugin.tag + def get_queue(self): + self._active.get_queue() + + @plugin.tag + def repeat(self): + self._active.repeat() + + @plugin.tag + def seek(self): + self._active.seek() + + @plugin.tag + def get_single_coverart(self, song_url): + self._active.get_single_coverart(song_url) + + @plugin.tag + def get_album_coverart(self): + self._active.get_album_coverart() + + @plugin.tag + def list_all_dirs(self): + list_of_all_dirs = [] + for name, bkend in self._backends.items(): + list_of_all_dirs.append(bkend.list_dirs()) + return list_of_all_dirs + + @plugin.tag + def list_albums(self): + """ + Coolects from every backend the albums and albumartists + """ + album_list = [] + for name, bkend in self._backends.items(): + album_list.extend(bkend.get_albums()) + + return album_list + + @plugin.tag + def list_songs_by_artist_and_album(self, albumartist, album): + for name, bkend in self._backends.items(): + t_list = bkend.get_album_tracks(albumartist, album) + if t_list: + return t_list + return None + + @plugin.tag + def get_song_by_url(self, song_url): + return self._active.get_song_by_url(song_url) + + # ToDo: make it iterate through all player, so that the whole content is displayed + @plugin.tag + def get_folder_content(self, folder): + return self._active.get_folder_content(folder) + + def _save_state(self): + # Get the backend to save the state of the current playlist to the URI's config file + self._active.save_state() + # Also need to save which backend and URI was currently playing to be able to restore it after reboot + pass diff --git a/src/jukebox/components/player/core/coverart_cache_manager.py b/src/jukebox/components/player/core/coverart_cache_manager.py new file mode 100644 index 000000000..bb2346497 --- /dev/null +++ b/src/jukebox/components/player/core/coverart_cache_manager.py @@ -0,0 +1,90 @@ +from mutagen.mp3 import MP3 +from mutagen.id3 import ID3, APIC +from pathlib import Path +import hashlib +import logging +from queue import Queue +from threading import Thread +import jukebox.cfghandler + +COVER_PREFIX = 'cover' +NO_COVER_ART_EXTENSION = 'no-art' +NO_CACHE = '' +CACHE_PENDING = 'CACHE_PENDING' + +logger = logging.getLogger('jb.CoverartCacheManager') +cfg = jukebox.cfghandler.get_handler('jukebox') + + +class CoverartCacheManager: + def __init__(self): + coverart_cache_path = cfg.setndefault('webapp', 'coverart_cache_path', value='../../src/webapp/build/cover-cache') + self.cache_folder_path = Path(coverart_cache_path).expanduser() + self.write_queue = Queue() + self.worker_thread = Thread(target=self.process_write_requests) + self.worker_thread.daemon = True # Ensure the thread closes with the program + self.worker_thread.start() + + def generate_cache_key(self, base_filename: str) -> str: + return f"{COVER_PREFIX}-{hashlib.sha256(base_filename.encode()).hexdigest()}" + + def get_cache_filename(self, mp3_file_path: str) -> str: + base_filename = Path(mp3_file_path).stem + cache_key = self.generate_cache_key(base_filename) + + for path in self.cache_folder_path.iterdir(): + if path.stem == cache_key: + if path.suffix == f".{NO_COVER_ART_EXTENSION}": + return NO_CACHE + return path.name + + self.save_to_cache(mp3_file_path) + return CACHE_PENDING + + def save_to_cache(self, mp3_file_path: str): + self.write_queue.put(mp3_file_path) + + def _save_to_cache(self, mp3_file_path: str): + base_filename = Path(mp3_file_path).stem + cache_key = self.generate_cache_key(base_filename) + file_extension, data = self._extract_album_art(mp3_file_path) + + cache_filename = f"{cache_key}.{file_extension}" + full_path = self.cache_folder_path / cache_filename # Works due to Pathlib + + with full_path.open('wb') as file: + file.write(data) + logger.debug(f"Created file: {cache_filename}") + + return cache_filename + + def _extract_album_art(self, mp3_file_path: str) -> tuple: + try: + audio_file = MP3(mp3_file_path, ID3=ID3) + except Exception as e: + logger.error(f"Error reading MP3 file {mp3_file_path}: {e}") + return (NO_COVER_ART_EXTENSION, b'') + + for tag in audio_file.tags.values(): + if isinstance(tag, APIC): + mime_type = tag.mime + file_extension = 'jpg' if mime_type == 'image/jpeg' else mime_type.split('/')[-1] + return (file_extension, tag.data) + + return (NO_COVER_ART_EXTENSION, b'') + + def process_write_requests(self): + while True: + mp3_file_path = self.write_queue.get() + try: + self._save_to_cache(mp3_file_path) + except Exception as e: + logger.error(f"Error processing write request: {e}") + self.write_queue.task_done() + + def flush_cache(self): + for path in self.cache_folder_path.iterdir(): + if path.is_file(): + path.unlink() + logger.debug(f"Deleted cached file: {path.name}") + logger.info("Cache flushed successfully.") diff --git a/src/jukebox/components/player/core/player_content.py b/src/jukebox/components/player/core/player_content.py new file mode 100644 index 000000000..7149f2a9e --- /dev/null +++ b/src/jukebox/components/player/core/player_content.py @@ -0,0 +1,57 @@ +import logging + +import yaml + +import jukebox.plugs as plugin +import jukebox.cfghandler +from jukebox import playlistgenerator + +logger = logging.getLogger('jb.player_content') +cfg = jukebox.cfghandler.get_handler('player') + + +class PlayerData: + + def __init__(self): + self.audiofile = cfg.setndefault('players', 'content', 'audiofile', value='../../shared/audiofolders/audiofiles.yaml') + self.audiofile_basedir = cfg.setndefault('players', 'content', 'audiofile_basedir', value='../../shared/audiofolders') + self._database = {'file': [{}], + 'podcasts': [{}], + 'livestreams': [{}]} + self._fill_database(self.audiofile) + + def _fill_database(self, yaml_file): + with open(yaml_file, 'r') as stream: + try: + self._database = yaml.safe_load(stream) + logger.debug("audiofiles database read") + except yaml.YAMLError as err: + logger.error(f"Error occured while reading {yaml_file}: {err}") + + @plugin.tag + def read_player_content(self, content_type): + self._fill_database(self.audiofile) + return self._database.get(content_type, "empty") + + @plugin.tag + def get_uri(self, titlename): + for key, value in self._database.items(): + for elem in value: + return f"mpd:{key}:{elem['location']}" if elem['name'] == titlename else None + + @plugin.tag + def list_content(self): + return self._database + + @plugin.tag + def get_folder_content(self, folder: str): + """ + Get the folder content as content list with meta-information. Depth is always 1. + + Call repeatedly to descend in hierarchy + + :param folder: Folder path relative to music library path + """ + plc = playlistgenerator.PlaylistCollector(self.audiofile_basedir) + plc.get_directory_content(folder) + return plc.playlist diff --git a/src/jukebox/components/player/core/player_status.py b/src/jukebox/components/player/core/player_status.py new file mode 100644 index 000000000..57401ccc6 --- /dev/null +++ b/src/jukebox/components/player/core/player_status.py @@ -0,0 +1,48 @@ +import logging + +import jukebox.plugs as plugin +from jukebox import publishing + +logger = logging.getLogger('jb.player') + + +class PlayerStatus: + STATUS = { + 'album': '', + 'albumartist': '', + 'artist': '', + 'coverArt': '', + 'duration': 0, + 'elapsed': 0, + 'file': '', # required for MPD // check if really is required + 'player': '', + 'playing': False, + 'shuffle': False, + 'repeat': 0, + 'title': '', + 'trackid': '', + } + + def __init__(self): + self._player_status = self.STATUS + + def update(self, **kwargs): + for key, value in kwargs.items(): + if key in self.STATUS: + self._player_status[key] = value + + self.publish() + + def get_value(self, key): + return self.STATUS.get(key) + + def publish(self): + logger.debug(f'Published: {self._player_status}') + return publishing.get_publisher().send( + 'player_status', + self._player_status + ) + + @plugin.tag + def status(self): + return self._player_status diff --git a/src/jukebox/components/player/plugin/__init__.py b/src/jukebox/components/player/plugin/__init__.py new file mode 100644 index 000000000..4e2f159d7 --- /dev/null +++ b/src/jukebox/components/player/plugin/__init__.py @@ -0,0 +1,88 @@ +# Copyright: 2022 +# SPDX License Identifier: MIT License + +import asyncio +import logging +import threading +from typing import Optional + +import jukebox.plugs as plugin +import jukebox.cfghandler +from components.player.backends.mpd.interfacing_mpd import MPDBackend +from components.player.core import PlayerCtrl +from components.player.core.player_status import PlayerStatus + +from components.player.core.player_content import PlayerData + + +logger = logging.getLogger('jb.player') +cfg = jukebox.cfghandler.get_handler('jukebox') +cfg_player = jukebox.cfghandler.get_handler('player') + +# Background event loop in a separate thread to be used by backends as needed for asyncio tasks +event_loop: asyncio.AbstractEventLoop + +# The top-level player arbiter that acts as the single interface to the outside +player_arbiter: PlayerCtrl + +# Player status needed for webapp +player_status: PlayerStatus + +# The various backends +backend_mpd: Optional[MPDBackend] = None + + +def start_event_loop(loop: asyncio.AbstractEventLoop): + # https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.shutdown_asyncgens + logger.debug("Start player AsyncIO Background Event Loop") + try: + loop.run_forever() + finally: + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() + + +def register_mpd(): + global event_loop + global backend_mpd + global player_arbiter + + backend_mpd = MPDBackend(event_loop) + # Register with plugin interface to call directly + plugin.register(backend_mpd, package='player', name='mpd') + player_arbiter.register('mpd', backend_mpd) + + +@plugin.initialize +def initialize(): + global event_loop + global player_arbiter + global player_status + + jukebox.cfghandler.load_yaml(cfg_player, '../../shared/settings/player.yaml') + # Create the event loop and start it in a background task + # the event loop can be shared across different backends (if the backends require a async event loop) + event_loop = asyncio.new_event_loop() + t = threading.Thread(target=start_event_loop, args=(event_loop,), daemon=True, name='PlayerEventLoop') + t.start() + + player_arbiter = PlayerCtrl() + + player_status = PlayerStatus() + player_arbiter.player_status = player_status + + # ToDo: remove player_content + # player_content = PlayerData() + + # Create and register the players (this is explicit for the moment) + register_mpd() + + plugin.register(player_arbiter, package='player', name='ctrl') + plugin.register(player_status, package='player', name='playerstatus') + # plugin.register(player_content, package='player', name='content') + + +@plugin.atexit +def atexit(**ignored_kwargs): + global event_loop + event_loop.stop() diff --git a/src/jukebox/components/synchronisation/rfidcards/__init__.py b/src/jukebox/components/synchronisation/rfidcards/__init__.py index 0fa0969a9..3295a214f 100644 --- a/src/jukebox/components/synchronisation/rfidcards/__init__.py +++ b/src/jukebox/components/synchronisation/rfidcards/__init__.py @@ -19,7 +19,6 @@ import logging import subprocess import components.player -import components.playermpd import components.rfid.reader import components.synchronisation.syncutils as syncutils import jukebox.cfghandler @@ -29,7 +28,7 @@ import shutil from components.rfid.reader import RfidCardDetectState -from components.playermpd.playcontentcallback import PlayCardState +# from components.playermpd.playcontentcallback import PlayCardState logger = logging.getLogger('jb.sync_rfidcards') @@ -73,7 +72,8 @@ def __init__(self): self._sync_remote_ssh_user = cfg_sync_rfidcards.getn('sync_rfidcards', 'credentials', 'username') components.rfid.reader.rfid_card_detect_callbacks.register(self._rfid_callback) - components.playermpd.play_card_callbacks.register(self._play_card_callback) + # ToDo multi-player: check if this is needed with the new player + # components.playermpd.play_card_callbacks.register(self._play_card_callback) else: logger.info("Sync RFID cards deactivated") @@ -84,9 +84,9 @@ def _rfid_callback(self, card_id: str, state: RfidCardDetectState): if state == RfidCardDetectState.received: self.sync_card_database(card_id) - def _play_card_callback(self, folder: str, state: PlayCardState): - if state == PlayCardState.firstSwipe: - self.sync_folder(folder) + # def _play_card_callback(self, folder: str, state: PlayCardState): + # if state == PlayCardState.firstSwipe: + # self.sync_folder(folder) @plugs.tag def sync_change_on_rfid_scan(self, option: str = 'toggle') -> None: diff --git a/src/jukebox/components/volume/__init__.py b/src/jukebox/components/volume/__init__.py index b99e94616..efffec0a5 100644 --- a/src/jukebox/components/volume/__init__.py +++ b/src/jukebox/components/volume/__init__.py @@ -63,7 +63,6 @@ import threading import time import traceback - import pulsectl import jukebox.cfghandler import jukebox.plugs as plugin diff --git a/src/jukebox/jukebox/playlistgenerator.py b/src/jukebox/jukebox/playlistgenerator.py index e7f5b50d3..e61fd5e9f 100755 --- a/src/jukebox/jukebox/playlistgenerator.py +++ b/src/jukebox/jukebox/playlistgenerator.py @@ -12,6 +12,8 @@ An directory may contain a mixed set of files and multiple ``*.txt`` files, e.g. +.. code-block:: bash + 01-livestream.txt 02-livestream.txt music.mp3 @@ -69,23 +71,23 @@ class PlaylistEntry: - def __init__(self, filetype: int, name: str, path: str): + def __init__(self, filetype: int, name: str, uri: str = None): self._type = filetype self._name = name - self._path = path + self._uri = uri @property def name(self): return self._name - @property - def path(self): - return self._path - @property def filetype(self): return self._type + @property + def uri(self): + return self._uri + def decode_podcast_core(url, playlist): # Example url: @@ -210,7 +212,7 @@ def _is_valid(cls, direntry: os.DirEntry) -> bool: Check if filename is valid """ return direntry.is_file() and not direntry.name.startswith('.') \ - and PlaylistCollector._exclude_re.match(direntry.name) is None and direntry.name.find('.') >= 0 + and PlaylistCollector._exclude_re.match(direntry.name) is None and direntry.name.find('.') >= 0 @classmethod def set_exclusion_endings(cls, endings: List[str]): @@ -267,6 +269,7 @@ def get_directory_content(self, path='.'): """ self.playlist = [] self._folder = os.path.abspath(os.path.join(self._music_library_base_path, path)) + logger.debug(self._folder) try: content = self._get_directory_content(self._folder) except NotADirectoryError as e: @@ -274,16 +277,12 @@ def get_directory_content(self, path='.'): except FileNotFoundError as e: logger.error(f" {e.__class__.__name__}: {e}") else: + logger.debug(f"Playlist Content: {content}") for m in content: - self.playlist.append({ - 'type': TYPE_DECODE[m.filetype], - 'name': m.name, - 'path': m.path, - 'relpath': os.path.relpath(m.path, self._music_library_base_path) - }) + self.playlist.append({'type': TYPE_DECODE[m.filetype], 'name': m.name, 'path': m.uri}) def _parse_nonrecusive(self, path='.'): - return [x.path for x in self._get_directory_content(path) if x.filetype != TYPE_DIR] + return [x.uri for x in self._get_directory_content(path) if x.filetype != TYPE_DIR] def _parse_recursive(self, path='.'): # This can certainly be optimized, as os.walk is called on all @@ -299,7 +298,7 @@ def _parse_recursive(self, path='.'): return recursive_playlist def parse(self, path='.', recursive=False): - """Parse the folder ``path`` and create a playlist from its content + """Parse the folder ``path`` and create a playlist from it's content :param path: Path to folder **relative** to ``music_library_base_path`` :param recursive: Parse folder recursivley, or stay in top-level folder diff --git a/src/webapp/src/commands/index.js b/src/webapp/src/commands/index.js index f6f772875..ee8fb0250 100644 --- a/src/webapp/src/commands/index.js +++ b/src/webapp/src/commands/index.js @@ -49,8 +49,8 @@ const commands = { }, playerstatus: { _package: 'player', - plugin: 'ctrl', - method: 'playerstatus' + plugin: 'player_status', + method: 'status' }, // Player Actions @@ -65,6 +65,7 @@ const commands = { method: 'play_single', argKeys: ['song_url'] }, + // ToDo: verify if this is really needed? play_folder: { _package: 'player', plugin: 'ctrl',