diff --git a/amplipi/app.py b/amplipi/app.py index 74c0a4ea6..e87551250 100644 --- a/amplipi/app.py +++ b/amplipi/app.py @@ -66,7 +66,7 @@ # start in the web directory TEMPLATE_DIR = os.path.abspath('web/templates') STATIC_DIR = os.path.abspath('web/static') -GENERATED_DIR = os.path.abspath('web/generated') +GENERATED_DIR = os.path.abspath(f'{utils.get_folder("web")}/generated') # web/generated is now in the user's home directory with all other runtime-generated files WEB_DIR = os.path.abspath('web/dist') # we host docs using rapidoc instead via a custom endpoint, so the default endpoints need to be disabled @@ -74,6 +74,9 @@ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") +uvicorn_access = logging.getLogger("uvicorn.access") +uvicorn_access.disabled = True + # This will get generated as a tmpfs on AmpliPi, # but won't exist if testing on another machine. os.makedirs(GENERATED_DIR, exist_ok=True) diff --git a/amplipi/mpris.py b/amplipi/mpris.py index 035a4ca34..c7f622d19 100644 --- a/amplipi/mpris.py +++ b/amplipi/mpris.py @@ -36,6 +36,8 @@ class Metadata: connected: bool = False state_changed_time: float = 0 +# TODO: consider removing the script this starts and doing it all here since we no longer poll + class MPRIS: """A class for interfacing with an MPRIS MediaPlayer2 over dbus.""" @@ -53,14 +55,6 @@ def __init__(self, service_suffix, metadata_path) -> None: self.metadata_path = metadata_path self._closing = False - try: - with open(self.metadata_path, "w", encoding='utf-8') as f: - m = Metadata() - m.state = "Stopped" - json.dump(m.__dict__, f) - except Exception as e: - logger.exception(f'Exception clearing metadata file: {e}') - try: child_args = [sys.executable, f"{utils.get_folder('streams')}/MPRIS_metadata_reader.py", @@ -91,37 +85,6 @@ def play_pause(self) -> None: """Plays or pauses depending on current state.""" self.mpris.PlayPause() - def _load_metadata(self) -> Metadata: - try: - with open(self.metadata_path, 'r', encoding='utf-8') as f: - metadata_dict = json.load(f) - metadata_obj = Metadata() - - for k in metadata_dict.keys(): - metadata_obj.__dict__[k] = metadata_dict[k] - - return metadata_obj - except Exception as e: - logger.exception(f"MPRIS loading metadata at {self.metadata_path} failed: {e}") - - return Metadata() - - def metadata(self) -> Metadata: - """Returns metadata from MPRIS.""" - return self._load_metadata() - - def is_playing(self) -> bool: - """Playing?""" - return self._load_metadata().state == 'Playing' - - def is_stopped(self) -> bool: - """Stopped?""" - return self._load_metadata().state == 'Stopped' - - def is_connected(self) -> bool: - """Returns true if we can talk to the MPRIS dbus object.""" - return self._load_metadata().connected - def get_capabilities(self) -> List[CommandTypes]: """Returns a list of supported commands.""" diff --git a/amplipi/streams/airplay.py b/amplipi/streams/airplay.py index a000ae097..7496d9d1b 100644 --- a/amplipi/streams/airplay.py +++ b/amplipi/streams/airplay.py @@ -42,6 +42,11 @@ def __init__(self, name: str, ap2: bool, disabled: bool = False, mock: bool = Fa self._connect_time = 0.0 self._coverart_dir = '' self._log_file: Optional[io.TextIOBase] = None + self.last_info: Optional[models.SourceInfo] = None + self.change_time = time.time() - self.STATE_TIMEOUT + + self.default_image_url = 'static/imgs/shairport.png' + self.stopped_message = f'Nothing is playing, please connect to {self.name} to play music' def reconfig(self, **kwargs): self.validate_stream(**kwargs) @@ -109,23 +114,21 @@ def _activate(self, vsrc: int): except FileNotFoundError: pass os.makedirs(self._coverart_dir, exist_ok=True) - os.makedirs(src_config_folder, exist_ok=True) - config_file = f'{src_config_folder}/shairport.conf' + config_file = f'{self._get_config_folder()}/shairport.conf' write_sp_config_file(config_file, config) - self._log_file = open(f'{src_config_folder}/log', mode='w') + self._log_file = open(f'{self._get_config_folder()}/log', mode='w') shairport_args = f"{utils.get_folder('streams')}/shairport-sync{'-ap2' if self.ap2 else ''} -c {config_file}".split(' ') logger.info(f'shairport_args: {shairport_args}') self.proc = subprocess.Popen(args=shairport_args, stdin=subprocess.PIPE, stdout=self._log_file, stderr=self._log_file) - try: mpris_name = 'ShairportSync' # If there are multiple shairport-sync processes, add the pid to the mpris name # shairport sync only adds the pid to the mpris name if it cannot use the default name if len(os.popen("pgrep shairport-sync").read().strip().splitlines()) > 1: mpris_name += f".i{self.proc.pid}" - self.mpris = MPRIS(mpris_name, f'{src_config_folder}/metadata.txt') + self.mpris = MPRIS(mpris_name, f'{self._get_config_folder()}/metadata.json') except Exception as exc: logger.exception(f'Error starting airplay MPRIS reader: {exc}') @@ -151,69 +154,51 @@ def _deactivate(self): self._disconnect() self.proc = None + def _read_info(self) -> models.SourceInfo: + self.change_time = time.time() # keep track of the last time the state changed + return super()._read_info() + def info(self) -> models.SourceInfo: - source = models.SourceInfo( - name=f"Connect to {self.name} on Airplay{'2' if self.ap2 else ''}", - state=self.state, - img_url='static/imgs/shairport.png', - type=self.stream_type - ) + + source = super().info() + + # fake a paused state if the stream has stopped and it hasn't been stopped for too long since airplay doesn't have a paused state + if self.last_info and source.state == 'stopped' and not (time.time() - self.change_time > self.STATE_TIMEOUT): + source = self.last_info + source.state = 'paused' # if stream is airplay2 and other airplay2s exist show error message if self.ap2: if self.ap2_exists: - source.name = 'An Airplay2 stream already exists!\n Please disconnect it and try again.' + source.artist = 'An Airplay2 stream already exists!\n Please disconnect it and try again.' return source if not self.mpris: logger.info(f'Airplay: No MPRIS object for {self.name}!') return source - try: - md = self.mpris.metadata() - - if self.mpris.is_playing(): - source.state = 'playing' - else: - # if we've been paused for a while and the state has changed since connecting, then say - # we're stopped since shairport-sync doesn't really differentiate between paused and stopped - if self._connect_time < md.state_changed_time and time.time() - md.state_changed_time < self.STATE_TIMEOUT: - source.state = 'paused' - else: - source.state = 'stopped' - - if source.state != 'stopped': - source.artist = md.artist - source.track = md.title - source.album = md.album - source.supported_cmds = list(self.supported_cmds) - - if md.title != '': - # if there is a title, attempt to get coverart - images = os.listdir(self._coverart_dir) - if len(images) > 0: - source.img_url = f'generated/v{self.vsrc}/{images[0]}' - else: - source.track = "No metadata available" + if source.track != '': + # if there is a title, attempt to get coverart + images = os.listdir(self._coverart_dir) + logger.info(f'images: {images}') + if len(images) > 0: + source.img_url = f'generated/v{self.vsrc}/{images[0]}' - except Exception as e: - logger.exception(f"error in airplay: {e}") + self.last_info = source return source def send_cmd(self, cmd): + super().send_cmd(cmd) try: - if cmd in self.supported_cmds: - if cmd == 'play': - self.mpris.play_pause() - elif cmd == 'pause': - self.mpris.play_pause() - elif cmd == 'next': - self.mpris.next() - elif cmd == 'prev': - self.mpris.previous() - else: - raise NotImplementedError(f'"{cmd}" is either incorrect or not currently supported') + if cmd == 'play': + self.mpris.play_pause() + elif cmd == 'pause': + self.mpris.play_pause() + elif cmd == 'next': + self.mpris.next() + elif cmd == 'prev': + self.mpris.previous() except Exception as e: logger.exception(f"error in shairport: {e}") diff --git a/amplipi/streams/base_streams.py b/amplipi/streams/base_streams.py index b8645d237..f3d308574 100644 --- a/amplipi/streams/base_streams.py +++ b/amplipi/streams/base_streams.py @@ -1,3 +1,5 @@ +import os +import shutil import subprocess import sys import time @@ -5,11 +7,19 @@ import logging from amplipi import models from amplipi import utils +from amplipi import app +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler +import json +from threading import Timer + +# time before a stream auto-mutes on pause in seconds +AUTO_MUTE_TIMEOUT = 5.0 logger = logging.getLogger(__name__) logger.level = logging.DEBUG -sh = logging.StreamHandler(sys.stdout) -logger.addHandler(sh) +# handler is registered in __init__.py +# registering here will cause duplicate log messages def write_config_file(filename, config): @@ -58,7 +68,17 @@ def __init__(self, stype: str, name: str, only_src=None, disabled: bool = False, self.only_src: Optional[int] = only_src self.state = 'disconnected' self.stype = stype - self.browsable = isinstance(self, Browsable) + self._cached_info: models.SourceInfo = models.SourceInfo(name=self.full_name(), type=self.stype, state=self.state) + self._observer: Optional[Observer] = None + self._mute_timer: Optional[Timer] = None + + # TODO: better way to populate the following in a given stream type??? + self.browsable: bool = isinstance(self, Browsable) + self._watch_metadata: bool = True + self.stopped_message = "The stream is currently stopped." + self.supported_cmds = [] + self.default_image_url = 'static/imgs/internet_radio.png' + if validate: self.validate_stream(name=name, mock=mock, **kwargs) @@ -72,12 +92,23 @@ def __str__(self): def full_name(self): """ Combine name and type of a stream to make a stream easy to identify. - Many streams will simply be named something like AmpliPi or John, so embedding the '- stype' into the name makes the name easier to identify. """ return f'{self.name} - {self.stype}' + def mute(self): + """ Mute the stream """ + logger.info(f'{self.name} muted') + zones = [z.id for z in app.get_ctrl().status.zones if z.source_id == self.src] + app.get_ctrl().set_zones(models.MultiZoneUpdate(zones=zones, update=models.ZoneUpdate(mute=True))) + + def unmute(self): + """ Unmute the stream """ + logger.debug(f'unmuting {self.name}') + zones = [z.id for z in app.get_ctrl().status.zones if z.source_id == self.src] + app.get_ctrl().set_zones(models.MultiZoneUpdate(zones=zones, update=models.ZoneUpdate(mute=False))) + def _disconnect(self): logger.info(f'{self.name} disconnected') self.state = 'disconnected' @@ -91,12 +122,69 @@ def disconnect(self): except Exception: pass self._disconnect() + self._stop_info_watcher() def _connect(self, src): logger.info(f'{self.name} connected to {src}') self.state = 'connected' self.src = src + # clear and create the config folder + shutil.rmtree(self._get_config_folder(), ignore_errors=True) + os.makedirs(self._get_config_folder(), exist_ok=True) + + self._start_info_watcher() + + def _get_config_folder(self): + return f'{utils.get_folder("config")}/srcs/{self.src}' + + def _start_info_watcher(self): + metadata_path = f'{self._get_config_folder()}/metadata.json' + + logger.debug(f'Starting metadata watcher for {self.name} at {metadata_path}') + + # create metadata file + with open(metadata_path, 'w+') as f: + f.write('{}') + + if self._watch_metadata: + # set up watchdog to watch for metadata changes + class handler(FileSystemEventHandler): + def on_modified(_, event): + # logger.debug(f'metadata file modified for {self.name}') + last_state = self._cached_info.state + self._read_info() + if (self._cached_info.state == 'paused' or self._cached_info.state == 'stopped') \ + and last_state == 'playing': + self._mute_timer = Timer(AUTO_MUTE_TIMEOUT, self.mute) + self._mute_timer.start() + # logger.debug(f'mute timer started for {self.name}') + if self._cached_info.state == 'playing' and last_state != 'playing': + if self._mute_timer: + self._mute_timer.cancel() + self._mute_timer = None + # logger.debug(f'mute timer cancelled for {self.name}') + self.unmute() + + self._fs_event_handler = handler() + self._observer = Observer() + self._observer.schedule(self._fs_event_handler, metadata_path) + self._observer.start() + + # read the info once to get the initial state (probably empty) + self._read_info() + + def _stop_info_watcher(self): + logger.debug(f'Stopping metadata watcher for {self.name}') + + if self._watch_metadata: + if self._observer: + logger.debug(f' observer stopped for {self.name}') + del self._observer + self._observer = None + self._fs_event_handler = None + logger.debug(" metadata watcher stopped") + def restart(self): """Reset this stream by disconnecting and reconnecting""" try: @@ -128,11 +216,46 @@ def _is_running(self): return self.proc.poll() is None return False + def _read_info(self) -> models.SourceInfo: + """ Read the current stream info and metadata, caching it """ + try: + if os.path.exists(f'{self._get_config_folder()}/metadata.json'): + with open(f'{self._get_config_folder()}/metadata.json', 'r') as file: + info = json.loads(file.read()) + + # populate fields that are type-consistent + info['name'] = self.full_name() + info['type'] = self.stype + info['supported_cmds'] = self.supported_cmds + + # set state to stopped if it is not present in the metadata (e.g. on startup) + if 'state' not in info: + info['state'] = 'stopped' + + self._cached_info = models.SourceInfo(**info) + + # set stopped message if stream is stopped + if self.stopped_message and self._cached_info.state == 'stopped': + self._cached_info.artist = self.stopped_message + self._cached_info.track = '' + self._cached_info.album = '' + + # set default image if none is provided + if not self._cached_info.img_url: + self._cached_info.img_url = self.default_image_url + + return self._cached_info + except Exception as e: + logger.exception(f'Error reading metadata for {self.name}: {e}') + return models.SourceInfo(name=self.full_name(), state='stopped') + def info(self) -> models.SourceInfo: - """ Get stream info and song metadata """ - return models.SourceInfo( - name=self.full_name(), - state=self.state) + """ Get cached stream info and source metadata """ + + if self._watch_metadata: + return self._cached_info + else: + return self._read_info() def requires_src(self) -> Optional[int]: """ Check if this stream needs to be connected to a specific source @@ -145,7 +268,14 @@ def send_cmd(self, cmd: str) -> None: """ Generic send_cmd function. If not implemented in a stream, and a command is sent, this error will be raised. """ - raise NotImplementedError(f'{self.name} does not support commands') + if cmd not in self.supported_cmds: + raise Exception(f'{self.stype} does not support command {cmd}') + + # duplicated unmute logic to make unmutes faster from the amplipi API + if (cmd == 'play'): + if (self._mute_timer): + self._mute_timer.cancel() + self.unmute() def play(self, item: str): """ Play a BrowsableItem """ @@ -215,9 +345,14 @@ def activate(self): vsrc = vsources.alloc() self.vsrc = vsrc self.state = "connected" # optimistically make this look like a normal stream for now + + # clear and create the config folder + shutil.rmtree(self._get_config_folder(), ignore_errors=True) + os.makedirs(self._get_config_folder(), exist_ok=True) + if not self.mock: self._activate(vsrc) # might override self.state - logger.info(f"Activating {self.name} ({'persistant' if self.is_persistent() else 'temporarily'})") + logger.info(f"Activating {self.name} ({'persistent' if self.is_persistent() else 'temporarily'})") except Exception as e: logger.exception(f'Failed to activate {self.name}: {e}') if vsrc is not None: @@ -262,8 +397,10 @@ def reactivate(self): def restart(self): """Reset this stream by disconnecting and reconnecting""" self.deactivate() + self._stop_info_watcher() time.sleep(0.1) self.activate() + self._start_info_watcher() def connect(self, src: int): """ Connect an output to a given audio source """ @@ -290,6 +427,11 @@ def connect(self, src: int): time.sleep(0.1) # Delay a bit self.src = src + self._start_info_watcher() + + def _get_config_folder(self): + return f'{utils.get_folder("config")}/srcs/v{self.vsrc}' + def disconnect(self): """ Disconnect from a DAC """ if '_cproc' in self.__dir__() and self._cproc: @@ -303,3 +445,4 @@ def disconnect(self): logger.exception(f'PersistentStream disconnect error: {e}') pass self.src = None + self._stop_info_watcher() diff --git a/amplipi/streams/bluetooth.py b/amplipi/streams/bluetooth.py index 00cca3655..e65a6966d 100644 --- a/amplipi/streams/bluetooth.py +++ b/amplipi/streams/bluetooth.py @@ -19,6 +19,8 @@ def __init__(self, name, disabled=False, mock=False): self.logo = "static/imgs/bluetooth.png" self.bt_proc = None self.supported_cmds = ['play', 'pause', 'next', 'prev', 'stop'] + self.default_image_url = 'static/imgs/bluetooth.png' + self.stopped_message = None def __del__(self): self.disconnect() @@ -54,7 +56,7 @@ def connect(self, src): # Start metadata watcher src_config_folder = f"{utils.get_folder('config')}/srcs/{src}" os.system(f'mkdir -p {src_config_folder}') - song_info_path = f'{src_config_folder}/currentSong' + song_info_path = f'{src_config_folder}/metadata.json' device_info_path = f'{src_config_folder}/btDevice' btmeta_args = f'{sys.executable} {utils.get_folder("streams")}/bluetooth.py --song-info={song_info_path} ' \ f'--device-info={device_info_path} --output-device={utils.real_output_device(src)}' @@ -79,27 +81,6 @@ def disconnect(self): self._disconnect() - def info(self) -> models.SourceInfo: - src_config_folder = f"{utils.get_folder('config')}/srcs/{self.src}" - loc = f'{src_config_folder}/currentSong' - source = models.SourceInfo(name=self.full_name(), - state=self.state, - img_url=self.logo, - supported_cmds=self.supported_cmds, - type=self.stream_type) - try: - with open(loc, 'r') as file: - data = json.loads(file.read()) - source.artist = data['artist'] - source.track = data['title'] - source.album = data['album'] - source.state = data['status'] - return source - except Exception as e: - logger.exception(f'bluetooth: exception {e}') - traceback.print_exc() - return source - def send_cmd(self, cmd): logger.info(f'bluetooth: sending command {cmd}') try: diff --git a/amplipi/streams/dlna.py b/amplipi/streams/dlna.py index f5dd24a26..9d3065e35 100644 --- a/amplipi/streams/dlna.py +++ b/amplipi/streams/dlna.py @@ -25,6 +25,9 @@ def __init__(self, name: str, disabled: bool = False, mock: bool = False): self._fifo_open = False self._fifo = None + self.stopped_message = f'Nothing is playing, please connect a player to {self.name} and play music' + self.default_image_url = 'static/imgs/dlna.png' + def reconfig(self, **kwargs): reconnect_needed = False if 'disabled' in kwargs: @@ -46,8 +49,9 @@ def connect(self, src): """ Connect a DLNA device to a given audio source This creates a DLNA streaming option based on the configuration """ + + self._connect(src) if self.mock: - self._connect(src) return # Generate some of the DLNA_Args @@ -55,17 +59,14 @@ def connect(self, src): self._uuid = uuid_gen() portnum = 49494 + int(src) - # Make the (per-source) config and web directories - self._src_config_folder = f'{utils.get_folder("config")}/srcs/{src}' - os.system(f'rm -r {self._src_config_folder}') - os.system(f'mkdir -p {self._src_config_folder}') + # Make the (per-source) web directory self._src_web_folder = f'{utils.get_folder("web")}/generated/{src}' os.system(f'rm -r {self._src_web_folder}') os.system(f'mkdir -p {self._src_web_folder}') # Make the fifo to be used for commands - os.mkfifo(f'{self._src_config_folder}/cmd') # lazily open fifo so startup is faster + os.mkfifo(f'{self._get_config_folder()}/cmd') # lazily open fifo so startup is faster # startup the metadata process and the DLNA process dlna_args = ['gmediarender', '--gstout-audiosink', 'alsasink', @@ -75,11 +76,11 @@ def connect(self, src): meta_args = [sys.executable, f'{utils.get_folder("streams")}/dlna_meta.py', f'{self.name}', - f'{self._src_config_folder}/cmd', - f'{self._src_config_folder}/meta.json', + f'{self._get_config_folder()}/cmd', + f'{self._get_config_folder()}/metadata.json', self._src_web_folder, ] - # '-d'] + # '-d'] self.proc = subprocess.Popen(args=dlna_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) self._metadata_proc = subprocess.Popen(args=meta_args, stdin=subprocess.PIPE, stdout=sys.stdout, stderr=sys.stderr) @@ -96,33 +97,6 @@ def disconnect(self): self._metadata_proc = None self.dlna_proc = None - def info(self) -> models.SourceInfo: - source = models.SourceInfo( - name=self.full_name(), - state=self.state, - img_url='static/imgs/dlna.png', - type=self.stream_type - ) - try: - - data = json.load(open(f'{self._src_config_folder}/meta.json')) - source.state = data.get('state', 'stopped') if data else 'stopped' - if source.state != 'stopped': # if the state is stopped, just use default values - source.artist = data.get('artist', '') - source.track = data.get('title', '') - source.album = data.get('album', '') - if data.get('album_art', '') != '': - source.img_url = f'generated/{self.src}/{data.get("album_art", "")}' - - source.supported_cmds = self.supported_cmds # set supported commands only if we hear back from the DLNA server - self._got_data = True - except Exception as e: - if self._got_data: # ignore if we havent gotten data yet since we're still waiting for the metadata process to start - logger.exception(f'Error getting DLNA info: {e}') - pass - - return source - def send_cmd(self, cmd): if not self._fifo_open: # open the fifo for writing but don't block in case something goes wrong diff --git a/amplipi/streams/file_player.py b/amplipi/streams/file_player.py index 279b5aebe..c7779b975 100644 --- a/amplipi/streams/file_player.py +++ b/amplipi/streams/file_player.py @@ -19,11 +19,14 @@ def __init__(self, name: str, url: str, temporary: bool = False, timeout: Option self.url = url self.bkg_thread = None if has_pause: - self.supported_cmds = ['play', 'pause'] + self.supported_cmds = ['play', 'pause', 'stop'] else: self.supported_cmds = ['play', 'stop'] self.temporary = temporary self.timeout = timeout + self.default_image_url = 'static/imgs/plexamp.png' + self.stopped_message = None + self.command_file_path = None def reconfig(self, **kwargs): reconnect_needed = False @@ -59,7 +62,7 @@ def connect(self, src): os.system(f'mkdir -p {src_config_folder}') # Start audio via runvlc.py - song_info_path = f'{src_config_folder}/currentSong' + song_info_path = f'{src_config_folder}/metadata.json' log_file_path = f'{src_config_folder}/log' self.command_file_path = f'{src_config_folder}/cmd' self.vlc_args = [ @@ -74,40 +77,41 @@ def connect(self, src): self.bkg_thread = threading.Thread(target=self.wait_on_proc) self.bkg_thread.start() self._connect(src) - self.state = 'playing' return def wait_on_proc(self): """ Wait for the vlc process to finish """ if self.proc is not None: self.proc.wait() # TODO: add a time here + self.send_cmd('stop') # notify that the audio is done playing else: time.sleep(0.3) # handles mock case - self.state = 'stopped' # notify that the audio is done playing def send_cmd(self, cmd): - if cmd in self.supported_cmds: - if cmd == 'stop': - if self._is_running(): - self.proc.kill() - if self.bkg_thread: - self.bkg_thread.join() - self.proc = None - if self.command_file_path is not None: - if cmd == 'pause': - f = open(self.command_file_path, 'w') - f.write('pause') - f.close() - self.state = 'paused' + super().send_cmd(cmd) - if cmd == 'play': - if not self._is_running(): - logger.info(f'running: {self.vlc_args}') - self.proc = subprocess.Popen(args=self.vlc_args, preexec_fn=os.setpgrp) - f = open(self.command_file_path, 'w') - f.write('play') - f.close() - self.state = 'playing' + if cmd == 'stop': + if self._is_running(): + self.proc.kill() + if self.bkg_thread: + self.bkg_thread.join() + self.state = 'stopped' + self.proc = None + if self.command_file_path is not None: + if cmd == 'pause': + f = open(self.command_file_path, 'w') + f.write('pause') + f.close() + self.state = 'paused' + + if cmd == 'play': + if not self._is_running(): + logger.info(f'running: {self.vlc_args}') + self.proc = subprocess.Popen(args=self.vlc_args, preexec_fn=os.setpgrp) + f = open(self.command_file_path, 'w') + f.write('play') + f.close() + self.state = 'playing' def disconnect(self): if self._is_running(): @@ -116,13 +120,3 @@ def disconnect(self): self.bkg_thread.join() self._disconnect() self.proc = None - - def info(self) -> models.SourceInfo: - source = models.SourceInfo( - name=self.full_name(), - state=self.state, - img_url='static/imgs/plexamp.png', - type=self.stream_type - ) - source.supported_cmds = self.supported_cmds - return source diff --git a/amplipi/streams/fm_radio.py b/amplipi/streams/fm_radio.py index b5c9b2ecf..a44755943 100644 --- a/amplipi/streams/fm_radio.py +++ b/amplipi/streams/fm_radio.py @@ -19,6 +19,7 @@ def __init__(self, name: str, freq, logo: Optional[str] = None, disabled: bool = super().__init__(self.stream_type, name, disabled=disabled, mock=mock) self.freq = freq self.logo = logo + self.stopped_message = None def reconfig(self, **kwargs): reconnect_needed = False @@ -38,15 +39,12 @@ def reconfig(self, **kwargs): def connect(self, src): """ Connect a fmradio.py output to a given audio source """ + self._connect(src) if self.mock: - self._connect(src) return - # Make all of the necessary dir(s) - src_config_folder = f"{utils.get_folder('config')}/srcs/{src}" - os.system('mkdir -p {}'.format(src_config_folder)) - song_info_path = f'{src_config_folder}/currentSong' - log_file_path = f'{src_config_folder}/log' + song_info_path = f'{self._get_config_folder()}/metadata.json' + log_file_path = f'{self._get_config_folder()}/log' fmradio_args = [ sys.executable, f"{utils.get_folder('streams')}/fmradio.py", self.freq, utils.real_output_device(src), @@ -64,48 +62,18 @@ def _is_running(self): def disconnect(self): if self._is_running(): - os.killpg(os.getpgid(self.proc.pid), signal.SIGKILL) + utils.careful_proc_shutdown(self.proc) self.proc = None self._disconnect() - def info(self) -> models.SourceInfo: - src_config_folder = f"{utils.get_folder('config')}/srcs/{self.src}" - loc = f'{src_config_folder}/currentSong' - if not self.logo: - self.logo = "static/imgs/fmradio.png" - source = models.SourceInfo( - name=self.full_name(), - state=self.state, - img_url=self.logo, - type=self.stream_type - ) - try: - with open(loc, 'r', encoding='utf-8') as file: - data = json.loads(file.read()) - # Example JSON: "station": "Mixx96.1", "callsign": "KXXO", "prog_type": "Soft rock", "radiotext": " x96.1" - # logger.debug(json.dumps(data)) - if data['prog_type']: - source.artist = data['prog_type'] - else: - source.artist = self.freq + " FM" - - if data['radiotext']: - source.track = data['radiotext'] - else: - source.track = self.name - - if data['station']: - source.station = data['station'] - elif data['callsign']: - source.station = data['callsign'] - else: - source.station = "" - - return source - except Exception: - pass - # logger.exception('Failed to get currentSong - it may not exist: {}'.format(e)) - return source + def _read_info(self) -> models.SourceInfo: + # we have to override this method because we need to set the img_url + super()._read_info() + if self.logo: + self._cached_info.img_url = self.logo + else: + self._cached_info.img_url = 'static/imgs/internet_radio.png' + return self._cached_info @staticmethod def is_hw_available(): diff --git a/amplipi/streams/internet_radio.py b/amplipi/streams/internet_radio.py index b1f2118a6..a5bae19df 100644 --- a/amplipi/streams/internet_radio.py +++ b/amplipi/streams/internet_radio.py @@ -22,6 +22,7 @@ def __init__(self, name: str, url: str, logo: Optional[str], disabled: bool = Fa self.url = url self.logo = logo self.supported_cmds = ['play', 'stop'] + self.stopped_message = None def reconfig(self, **kwargs): self.validate_stream(**kwargs) @@ -45,16 +46,14 @@ def connect(self, src): """ logger.info(f'connecting {self.name} to {src}...') + self._connect(src) + if self.mock: logger.info(f'{self.name} connected to {src}') self.state = 'playing' self.src = src return - # Make all of the necessary dir(s) - src_config_folder = f"{utils.get_folder('config')}/srcs/{src}" - os.system(f'mkdir -p {src_config_folder}') - # HACK check if url is a playlist and if it is get the first url and play it # this is the most general way to deal with playlists for this stream since the alternative is to actually # parse each playlist type and get the urls from them @@ -73,8 +72,8 @@ def connect(self, src): logger.exception(f'Error getting playlist {e}') # Start audio via runvlc.py - song_info_path = f'{src_config_folder}/currentSong' - log_file_path = f'{src_config_folder}/log' + song_info_path = f'{self._get_config_folder()}/metadata.json' + log_file_path = f'{self._get_config_folder()}/log' inetradio_args = [ sys.executable, f"{utils.get_folder('streams')}/runvlc.py", self.url, utils.real_output_device(src), '--song-info', song_info_path, '--log', log_file_path @@ -93,27 +92,14 @@ def disconnect(self): self._disconnect() self.proc = None - def info(self) -> models.SourceInfo: - src_config_folder = f"{utils.get_folder('config')}/srcs/{self.src}" - loc = f'{src_config_folder}/currentSong' - source = models.SourceInfo(name=self.full_name(), - state=self.state, - img_url='static/imgs/internet_radio.png', - supported_cmds=self.supported_cmds, - type=self.stream_type) + def _read_info(self) -> models.SourceInfo: + # we have to override this method because we need to set the img_url + super()._read_info() if self.logo: - source.img_url = self.logo - try: - with open(loc, 'r', encoding='utf-8') as file: - data = json.loads(file.read()) - source.artist = data['artist'] - source.track = data['track'] - source.station = data['station'] - source.state = data['state'] - return source - except Exception: - pass - return source + self._cached_info.img_url = self.logo + else: + self._cached_info.img_url = 'static/imgs/internet_radio.png' + return self._cached_info def send_cmd(self, cmd): try: @@ -132,6 +118,7 @@ def send_cmd(self, cmd): except Exception: pass self.state = 'stopped' + self._cached_info.state = 'stopped' else: raise NotImplementedError(f'"{cmd}" is either incorrect or not currently supported') except Exception: diff --git a/amplipi/streams/lms.py b/amplipi/streams/lms.py index db9b097f5..2667542bb 100644 --- a/amplipi/streams/lms.py +++ b/amplipi/streams/lms.py @@ -20,8 +20,8 @@ def __init__(self, name: str, server: Optional[str] = None, port: Optional[int] self.server: Optional[str] = server self.port: Optional[int] = port self.meta_proc: Optional[subprocess.Popen] = None - self.meta = {'artist': 'Launching metadata reader', 'album': 'If this step takes a long time,', - 'track': 'please restart the unit/stream, or contact support', 'image_url': 'static/imgs/lms.png'} + self.default_image_url = 'static/imgs/lms.png' + self.stopped_message = None def is_persistent(self): return True @@ -52,12 +52,6 @@ def _activate(self, vsrc: int): self._connect(vsrc) return try: - # Make the (per-source) config directory - self.vsrc = vsrc - src_config_folder = f'{utils.get_folder("config")}/srcs/v{vsrc}' - os.system(f'mkdir -p {src_config_folder}') - with open(f"{src_config_folder}/lms_metadata.json", "w", encoding="UTF-8") as f: - json.dump(self.meta, f, indent=2) # mac address, needs to be unique but not tied to actual NIC MAC hash the name with src id, to avoid aliases on move md5 = hashlib.md5() @@ -72,8 +66,8 @@ def _activate(self, vsrc: int): '-n', self.name, '-m', fake_mac, '-o', utils.virtual_output_device(vsrc), - '-f', f'{src_config_folder}/lms_log.txt', - '-i', f'{src_config_folder}/lms_remote', # specify this to avoid collisions, even if unused + '-f', f'{self._get_config_folder()}/lms_log.txt', + '-i', f'{self._get_config_folder()}/lms_remote', # specify this to avoid collisions, even if unused ] if self.server: # specify the server to connect to (if unspecified squeezelite starts in discovery mode) @@ -98,50 +92,10 @@ def _activate(self, vsrc: int): def _deactivate(self): if self._is_running(): - try: - src_config_folder = f'{utils.get_folder("config")}/srcs/v{self.vsrc}' - os.system(f'rm -f {src_config_folder}') - self.proc.terminate() - self.proc.communicate(timeout=10) - except Exception as e: - logger.exception(f"failed to gracefully terminate LMS stream {self.name}: {e}") - logger.warning(f"forcefully killing LMS stream {self.name}") - os.killpg(self.proc.pid, signal.SIGKILL) - self.proc.communicate(timeout=3) + utils.careful_proc_shutdown(self.proc) if self.meta_proc is not None: - try: - self.meta_proc.terminate() - self.meta_proc.communicate(timeout=10) - except Exception as e: - logger.exception(f"failed to gracefully terminate LMS meta proc for {self.name}: {e}") - logger.warning(f"forcefully killing LMS meta proc for {self.name}") - os.killpg(self.meta_proc.pid, signal.SIGKILL) - self.meta_proc.communicate(timeout=3) + utils.careful_proc_shutdown(self.meta_proc) self.proc = None self.meta_proc = None - - def info(self) -> models.SourceInfo: - # Opens and reads the metadata.json file every time the info def is called - try: - src_config_folder = f"{utils.get_folder('config')}/srcs/v{self.vsrc}" - with open(f"{src_config_folder}/lms_metadata.json", "r", encoding="utf-8") as meta_read: - self.meta = json.loads(meta_read.read()) - except: - self.meta = { - 'track': 'Trying again shortly...', - 'album': 'Make sure your lms player is connected to this source', - 'artist': 'Error: Could Not Find LMS Server', - 'image_url': 'static/imgs/lms.png' - } - source = models.SourceInfo( - name=self.full_name(), - state=self.state, - img_url=self.meta.get('image_url', ''), - track=self.meta.get('track', ''), - album=self.meta.get('album', ''), - artist=self.meta.get('artist', ''), - type=self.stream_type - ) - return source diff --git a/amplipi/streams/media_device.py b/amplipi/streams/media_device.py index 25f210484..356a524c5 100644 --- a/amplipi/streams/media_device.py +++ b/amplipi/streams/media_device.py @@ -7,7 +7,6 @@ import datetime import threading import sys -import json import pathlib MUSIC_EXTENSIONS = ('.mp3', '.wav', '.aac', '.m4a', '.m4b', '.flac', '.aiff', '.mp4', '.avi', '.wmv', '.mov', '.mpg', '.mpeg', '.wma') @@ -24,13 +23,14 @@ def __init__(self, name: str, url: Optional[str], disabled: bool = False, mock: self.directory = '/media' self.local_directory = '/media' self.bkg_thread: Optional[threading.Thread] = None - self.supported_cmds = ['play', 'pause', 'next', 'prev'] self.song_list: List[str] = [] self.song_index = 0 - self.ended = False self._prev_timeout = datetime.datetime.now() self.playing = None self.device: Optional[str] = None + self.supported_cmds = ['play', 'pause', 'prev'] + self.stopped_message = None + self.default_image_url = 'static/imgs/no_note.png' def reconfig(self, **kwargs): reconnect_needed = False @@ -46,39 +46,12 @@ def reconfig(self, **kwargs): def _activate(self, vsrc: int, remake_list: bool = True): """ Connect a short run VLC process with audio output to a given audio source """ - - if not self.mock: - # Make all of the necessary dir(s) - src_config_folder = f"{utils.get_folder('config')}/srcs/v{vsrc}" - if not os.path.exists(src_config_folder): - os.makedirs(src_config_folder) - - # Start audio via runvlc.py - song_info_path = f'{src_config_folder}/currentSong' - log_file_path = f'{src_config_folder}/log' - self.command_file_path = f'{src_config_folder}/cmd' - if remake_list and self.playing is not None: - try: - self.song_list, _ = self.make_song_list(os.path.dirname(self.playing)) - except Exception as e: - logger.error(f'Error processing request: {e}') - - if self.song_index < len(self.song_list) and self.device is not None: - self.url = self.song_list[self.song_index] - self.vlc_args = [ - sys.executable, f"{utils.get_folder('streams')}/fileplayer.py", self.url, self.device, - '--song-info', song_info_path, '--log', log_file_path, '--cmd', self.command_file_path - ] - logger.info(f'running: {self.vlc_args}') - self.proc = subprocess.Popen(args=self.vlc_args, preexec_fn=os.setpgrp) - - # make a thread that waits for the playback to be done and returns after info shows playback stopped - # for the mock condition it just waits a couple seconds - self.bkg_thread = threading.Thread(target=self.wait_on_proc) - self.bkg_thread.start() - self.state = 'playing' + if remake_list and self.playing is not None: + try: + self.song_list, _ = self.make_song_list(os.path.dirname(self.playing)) + except Exception as e: + logger.error(f'Error processing request: {e}') self.src = vsrc - return def make_song_list(self, path): song_list = [] @@ -108,19 +81,10 @@ def wait_on_proc(self): else: time.sleep(0.3) # handles mock case - src_config_folder = f"{utils.get_folder('config')}/srcs/v{self.src}" - loc = f'{src_config_folder}/currentSong' - try: - with open(loc, 'r', encoding='utf-8') as file: - data = json.loads(file.read()) - self.ended = data['state'] == 'ENDED' - except Exception: - pass - - if self.state == 'playing' and self.playing in self.song_list and self.ended and self.song_index < len(self.song_list) - 1: + if self._cached_info.state == 'playing' and self.playing in self.song_list and self.song_index < len(self.song_list) - 1: self.next_song() - elif self.ended and (self.song_index >= len(self.song_list) - 1 or self.playing not in self.song_list): - self.state = 'paused' + elif (self.song_index >= len(self.song_list) - 1 or self.playing not in self.song_list): + self.send_cmd('paused') def next_song(self): self.change_song(self.song_index + 1) @@ -129,69 +93,89 @@ def previous_song(self): self.change_song(self.song_index - 1) def change_song(self, new_song_id): + if self._is_running(): + self.proc.kill() + if self.bkg_thread: + self.bkg_thread.join() + self.song_index = new_song_id self.playing = self.song_list[self.song_index] - self.restart() + # Start audio via runvlc.py + song_info_path = f'{self._get_config_folder()}/metadata.json' + log_file_path = f'{self._get_config_folder()}/log' + self.command_file_path = f'{self._get_config_folder()}/cmd' + + if self.song_index < len(self.song_list) and self.device is not None: + self.url = self.song_list[self.song_index] + self.vlc_args = [ + sys.executable, f"{utils.get_folder('streams')}/fileplayer.py", self.url, self.device, + '--song-info', song_info_path, '--log', log_file_path, '--cmd', self.command_file_path + ] + logger.info(f'running: {self.vlc_args}') + self.proc = subprocess.Popen(args=self.vlc_args, preexec_fn=os.setpgrp) + + # make a thread that waits for the playback to be done and returns after info shows playback stopped + # for the mock condition it just waits a couple seconds + self.bkg_thread = threading.Thread(target=self.wait_on_proc) + self.bkg_thread.start() f = open(self.command_file_path, 'w') f.write('play') f.close() self._prev_timeout = datetime.datetime.now() + datetime.timedelta(seconds=3) - self.ended = False def send_cmd(self, cmd): - if cmd in self.supported_cmds: - if cmd == 'stop': - self._deactivate() - if self.command_file_path is not None: - if cmd == 'pause': - f = open(self.command_file_path, 'w') - f.write('pause') - f.close() - self.state = 'paused' - - if cmd == 'play': - if not self._is_running(): - logger.info(f'running: {self.vlc_args}') - self.proc = subprocess.Popen(args=self.vlc_args, preexec_fn=os.setpgrp) - f = open(self.command_file_path, 'w') - f.write('play') - f.close() - self.state = 'playing' - - if cmd == 'next': - self.next_song() - - if cmd == 'prev': - # Restart current song if we are past the first 3 seconds, otherwise go back - - if self._prev_timeout > datetime.datetime.now() and self.song_index > 0: - self.previous_song() - else: - self.change_song(self.song_index) + super().send_cmd(cmd) + + if cmd == 'stop': + self._deactivate() + if self.command_file_path is not None: + if cmd == 'pause': + f = open(self.command_file_path, 'w') + f.write('pause') + logger.info(f'pausing: {self.vlc_args}') + f.close() + + if cmd == 'play': + if not self._is_running(): + logger.info(f'running: {self.vlc_args}') + self.proc = subprocess.Popen(args=self.vlc_args, preexec_fn=os.setpgrp) + f = open(self.command_file_path, 'w') + f.write('play') + f.close() + + if cmd == 'next': + self.next_song() + + if cmd == 'prev': + # Restart current song if we are past the first 3 seconds, otherwise go back + + if self._prev_timeout > datetime.datetime.now() and self.song_index > 0: + self.previous_song() + else: + self.change_song(self.song_index) + + def _read_info(self) -> models.SourceInfo: + super()._read_info() - def info(self) -> models.SourceInfo: self.supported_cmds = ['play', 'pause', 'prev'] if self.song_index != len(self.song_list) - 1 and self.playing in self.song_list: self.supported_cmds.append('next') - img = 'static/imgs/no_note.png' - if self.playing is not None: - img = 'static/imgs/note.png' - source = models.SourceInfo(name=self.full_name(), - state=self.state, - img_url=img, - supported_cmds=self.supported_cmds, - type=self.stream_type) - if self.playing is not None: - src_config_folder = f"{utils.get_folder('config')}/srcs/v{self.src}" - loc = f'{src_config_folder}/currentSong' - try: - with open(loc, 'r', encoding='utf-8'): - source.track = self.playing.split('/')[-1] - except Exception: - pass - return source + # replace with file name so currently playing song is correct + self._cached_info.track = os.path.basename(self.playing) if self.playing is not None else 'No song playing' + + return self._cached_info + + def info(self) -> models.SourceInfo: + info = super().info() + + if self.playing is None or self._cached_info.state != 'playing': + info.img_url = 'static/imgs/no_note.png' + else: + info.img_url = 'static/imgs/note.png' + + return info def browse(self, parent=None, path=None) -> List[models.BrowsableItem]: browsables = [] diff --git a/amplipi/streams/pandora.py b/amplipi/streams/pandora.py index 548a5e254..7a2d411df 100644 --- a/amplipi/streams/pandora.py +++ b/amplipi/streams/pandora.py @@ -6,6 +6,7 @@ import os import time import re +import json # TODO: A significant amount of complexity could be removed if we switched some features here to using pydora instead of # interfacing with pianobar's TUI @@ -37,11 +38,13 @@ def __init__(self, name: str, user, password: str, station: str, disabled: bool self.pianobar_path = f'{utils.get_folder("streams")}/pianobar' self.pb_stations_file = '' self.pb_output_file = '' + self.stopped_message = None + self.default_image_url = 'static/imgs/pandora.png' self.stations: List[models.BrowsableItem] = [] self.ctrl = '' # control fifo location - self.supported_cmds = { + self.cmds = { 'play': {'cmd': 'P\n', 'state': 'playing'}, 'pause': {'cmd': 'S\n', 'state': 'paused'}, 'next': {'cmd': 'n\n', 'state': 'playing'}, @@ -49,6 +52,7 @@ def __init__(self, name: str, user, password: str, station: str, disabled: bool 'ban': {'cmd': '-\n', 'state': 'playing'}, 'shelve': {'cmd': 't\n', 'state': 'playing'}, } + self.supported_cmds = list(self.cmds.keys()) def reconfig(self, **kwargs): self.validate_stream(**kwargs) @@ -76,9 +80,8 @@ def _activate(self, vsrc: int): pass # make a special home/config to launch pianobar in (this allows us to have multiple pianobars) - src_config_folder = f'{utils.get_folder("config")}/srcs/v{vsrc}' eventcmd_template = f'{utils.get_folder("streams")}/eventcmd.sh' - pb_home = src_config_folder + pb_home = self._get_config_folder() pb_config_folder = f'{pb_home}/.config/pianobar' pb_control_fifo = f'{pb_config_folder}/ctl' pb_status_fifo = f'{pb_config_folder}/stat' @@ -89,7 +92,7 @@ def _activate(self, vsrc: int): pb_src_config_file = f'{pb_home}/.libao' self.pb_stations_file = f'{pb_config_folder}/stationList' # make all of the necessary dir(s) - os.system(f'mkdir -p {pb_config_folder}') + os.makedirs(pb_config_folder, exist_ok=True) os.system(f'cp {eventcmd_template} {pb_eventcmd_file}') # Copy to retain executable status # write pianobar and libao config files pb_conf = { @@ -141,70 +144,63 @@ def _deactivate(self): self.proc = None self.ctrl = '' + def _read_info(self, write_state=False) -> models.SourceInfo: + # HACK skip this read if it doesn't have state and trigger a new one by adding the state + i: dict = {} + with open(f"{self._get_config_folder()}/metadata.json", 'r', encoding='utf-8') as file: + try: # try/catch because the file may be empty due to the way the eventtcmd script is written + i = json.load(file) + except json.JSONDecodeError: + return self._cached_info + + if write_state or ("state" in i.keys() and i["state"] == ""): + i["state"] = self.state + with open(f"{self._get_config_folder()}/metadata.json", 'w', encoding='utf-8') as file: + json.dump(i, file) + return self._cached_info + + + super()._read_info() + + if self._cached_info.img_url is not None: + self._cached_info.img_url = self._cached_info.img_url.replace('http:', 'https:') # HACK: hack to just replace with https + + return self._cached_info + def info(self) -> models.SourceInfo: - src_config_folder = f'{utils.get_folder("config")}/srcs/v{self.vsrc}' - loc = f'{src_config_folder}/.config/pianobar/currentSong' - source = models.SourceInfo( - name=self.full_name(), - state=self.state, - supported_cmds=list(self.supported_cmds.keys()), - img_url='static/imgs/pandora.png', - type=self.stream_type - ) + i = super().info() - if len(self.stations) == 0: - self.load_stations() + if i.rating is not None: + i.rating = models.PandoraRating(i.rating) - try: - with open(loc, 'r', encoding='utf-8') as file: - for line in file.readlines(): - line = line.strip() - if line: - data = line.split(',,,') - if self.track != data[1]: # When song changes, stop inverting state - self.invert_liked_state = False - source.state = self.state - source.artist = data[0] - source.track = data[1] - self.track = data[1] - source.album = data[2] - source.img_url = data[3].replace('http:', 'https:') # HACK: kind of a hack to just replace with https - initial_rating = models.PandoraRating(int(data[4])) - - source.rating = initial_rating - - # Pianobar doesn't update metadata after a song starts playing - # so when you like a song you have to change the state manually until next song - if self.invert_liked_state: - if int(data[4]) == models.PandoraRating.DEFAULT.value: - source.rating = models.PandoraRating.LIKED - elif int(data[4]) == models.PandoraRating.LIKED.value: - source.rating = models.PandoraRating.DEFAULT - - source.station = data[5] - return source - except Exception: - pass - # logger.error('Failed to get currentSong - it may not exist: {}'.format(e)) - # TODO: report the status of pianobar with station name, playing/paused, song info - # ie. Playing: "Cameras by Matt and Kim" on "Matt and Kim Radio" - return source + # Pianobar doesn't update metadata after a song starts playing + # so a song is liked we invert the state until next song + if self.invert_liked_state: + if i.rating == models.PandoraRating.DEFAULT: + i.rating = models.PandoraRating.LIKED + elif i.rating == models.PandoraRating.LIKED: + i.rating = models.PandoraRating.DEFAULT + return i + def send_cmd(self, cmd): """ Pianobar's commands cmd: Command string sent to pianobar's control fifo state: Expected state after successful command execution """ + if not "station" in cmd: + super().send_cmd(cmd) + try: - if cmd in self.supported_cmds: + if cmd in self.cmds: if cmd == "love": self.info() # Ensure liked state is synced with current song self.invert_liked_state = not self.invert_liked_state with open(self.ctrl, 'w', encoding='utf-8') as file: - file.write(self.supported_cmds[cmd]['cmd']) + file.write(self.cmds[cmd]['cmd']) file.flush() - expected_state = self.supported_cmds[cmd]['state'] + expected_state = self.cmds[cmd]['state'] if expected_state is not None: self.state = expected_state @@ -247,6 +243,8 @@ def send_cmd(self, cmd): except Exception as exc: raise RuntimeError(f'Command {cmd} failed to send: {exc}') from exc + self._read_info(write_state=True) + def load_stations(self): try: pd_stations = {s.name.upper(): s.art_url for s in self.pyd_client.get_station_list()} diff --git a/amplipi/streams/spotify.py b/amplipi/streams/spotify.py index 33f1172ff..e9c6385e9 100644 --- a/amplipi/streams/spotify.py +++ b/amplipi/streams/spotify.py @@ -17,7 +17,10 @@ def __init__(self, name: str, disabled: bool = False, mock: bool = False, valida super().__init__(self.stream_type, name, disabled=disabled, mock=mock, validate=validate) self.connect_port: Optional[int] = None self.mpris: Optional[MPRIS] = None + self._sc_name = self.name.replace(" ", "-") self.supported_cmds = ['play', 'pause', 'next', 'prev'] + self.default_image_url = 'static/imgs/spotify.png' + self.stopped_message = f'Nothing is playing, please connect to {self._sc_name} to play music' def reconfig(self, **kwargs): self.validate_stream(**kwargs) @@ -38,15 +41,8 @@ def _activate(self, vsrc: int): This will create a Spotify Connect device based on the given name """ - # Make the (per-source) config directory - src_config_folder = f'{utils.get_folder("config")}/srcs/v{vsrc}' - os.system(f'mkdir -p {src_config_folder}') - toml_template = f'{utils.get_folder("streams")}/spot_config.toml' - toml_useful = f'{src_config_folder}/config.toml' - - # make source folder - os.system(f'mkdir -p {src_config_folder}') + toml_useful = f'{self._get_config_folder()}/config.toml' # Copy the config template os.system(f'cp {toml_template} {toml_useful}') @@ -55,7 +51,7 @@ def _activate(self, vsrc: int): self.connect_port = 4070 + 10 * vsrc with open(toml_useful, 'r', encoding='utf-8') as TOML: data = TOML.read() - data = data.replace('device_name_in_spotify_connect', f'{self.name.replace(" ", "-")}') + data = data.replace('device_name_in_spotify_connect', self._sc_name) data = data.replace("alsa_audio_device", utils.virtual_output_device(vsrc)) data = data.replace('1234', f'{self.connect_port}') with open(toml_useful, 'w', encoding='utf-8') as TOML: @@ -65,10 +61,10 @@ def _activate(self, vsrc: int): spotify_args = [f'{utils.get_folder("streams")}/spotifyd', '--no-daemon', '--config-path', './config.toml'] try: - self.proc = subprocess.Popen(args=spotify_args, cwd=f'{src_config_folder}') + self.proc = subprocess.Popen(args=spotify_args, cwd=f'{self._get_config_folder()}') time.sleep(0.1) # Delay a bit - self.mpris = MPRIS(f'spotifyd.instance{self.proc.pid}', f'{src_config_folder}/metadata.json') # TODO: MPRIS should just need a path! + self.mpris = MPRIS(f'spotifyd.instance{self.proc.pid}', f'{self._get_config_folder()}/metadata.json') except Exception as exc: logger.exception(f'error starting spotify: {exc}') @@ -84,45 +80,17 @@ def _deactivate(self): self.mpris = None self.connect_port = None - def info(self) -> models.SourceInfo: - source = models.SourceInfo( - name=self.full_name(), - state=self.state, - img_url='static/imgs/spotify.png', # report generic spotify image in place of unspecified album art - type=self.stream_type - ) - if self.mpris is None: - return source - try: - md = self.mpris.metadata() - - if not self.mpris.is_stopped(): - source.state = 'playing' if self.mpris.is_playing() else 'paused' - source.artist = str(md.artist).replace("', '", ", ") # When a song has multiple artists, they are comma-separated but the comma has '' around it - source.track = md.title - source.album = md.album - source.supported_cmds = self.supported_cmds - if md.art_url: - source.img_url = md.art_url - - except Exception as e: - logger.exception(f"error in spotify: {e}") - - return source - def send_cmd(self, cmd): + super().send_cmd(cmd) try: - if cmd in self.supported_cmds: - if cmd == 'play': - self.mpris.play() - elif cmd == 'pause': - self.mpris.pause() - elif cmd == 'next': - self.mpris.next() - elif cmd == 'prev': - self.mpris.previous() - else: - raise NotImplementedError(f'"{cmd}" is either incorrect or not currently supported') + if cmd == 'play': + self.mpris.play() + elif cmd == 'pause': + self.mpris.pause() + elif cmd == 'next': + self.mpris.next() + elif cmd == 'prev': + self.mpris.previous() except Exception as e: raise Exception(f"Error sending command {cmd}: {e}") from e diff --git a/pyproject.toml b/pyproject.toml index 73c0be731..2cc73c938 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "amplipi" -version = "0.4.2" +version = "0.4.2+d58fb51-mute-pause-stop-dirty" description = "A Pi-based whole house audio controller" authors = [ "Lincoln Lorenz ", diff --git a/requirements.txt b/requirements.txt index b31243ba7..a2d9c5cab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -39,5 +39,6 @@ types-urllib3==1.26.25.14 upnpy==1.1.8 uvicorn==0.20.0 validators==0.20 +watchdog==3.0.0 wrapt==1.14.1 zeroconf==0.47.1 diff --git a/streams/MPRIS_metadata_reader.py b/streams/MPRIS_metadata_reader.py index 3b319ee81..1f1168231 100644 --- a/streams/MPRIS_metadata_reader.py +++ b/streams/MPRIS_metadata_reader.py @@ -14,8 +14,8 @@ METADATA_MAPPINGS = [ ('artist', 'xesam:artist'), - ('title', 'xesam:title'), - ('art_url', 'mpris:artUrl'), + ('track', 'xesam:title'), + ('img_url', 'mpris:artUrl'), ('album', 'xesam:album') ] @@ -77,6 +77,8 @@ def read_metadata(_a, _b, _c): metadata['connected'] = False logger.error(f"Dbus error getting MPRIS metadata: {e}") + logger.debug(f"raw_metadata: {raw_metadata}") + # iterate over the metadata mappings and try to add them to the metadata dict for mapping in METADATA_MAPPINGS: try: @@ -85,15 +87,16 @@ def read_metadata(_a, _b, _c): # not error since some metadata might not be available on all streams logger.debug(f"Metadata mapping error: {e}") - # Strip playback status of single quotes, for some reason these only appear on stopped - state = mpris.PlaybackStatus.strip("'") + # Strip playback status of single quotes (for some reason these only appear on stopped?) + # and convert to lowercase + state = mpris.PlaybackStatus.strip("'").lower() metadata['state'] = state - if state != self.last_sent['state']: - metadata['state_changed_time'] = time.time() - else: - metadata['state_changed_time'] = self.last_sent['state_changed_time'] + # if state != self.last_sent['state']: + # metadata['state_changed_time'] = time.time() + # else: + # metadata['state_changed_time'] = self.last_sent['state_changed_time'] metadata['connected'] = True @@ -142,6 +145,8 @@ def read_metadata(_a, _b, _c): logger = logging.getLogger(__name__) +sh = logging.StreamHandler(sys.stdout) +logger.addHandler(sh) parser = argparse.ArgumentParser(description='Script to read MPRIS metadata and write it to a file.') parser.add_argument('service_suffix', metavar='service_suffix', type=str, help='end of the MPRIS service name, e.g. "vlc" for org.mpris.MediaPlayer2.vlc') diff --git a/streams/bluetooth.py b/streams/bluetooth.py index 0bbf63bec..5dc699d14 100644 --- a/streams/bluetooth.py +++ b/streams/bluetooth.py @@ -35,10 +35,9 @@ class MediaInfo: """Dataclass to represent the metadata.""" artist: str = '' - title: str = '' + track: str = '' album: str = '' - duration: str = '' - status: str = '' + state: str = '' def as_json(self): return json.dumps(asdict(self)) @@ -224,17 +223,16 @@ def main(): artist = track_details.get("Artist", "") album = track_details.get("Album", "") title = track_details.get("Title", "") - duration = track_details.get("Duration", "") # alter/generate a title to include device name title = alter_title(title, device_name) - info = MediaInfo(artist, title, album, duration, mp.status) + info = MediaInfo(artist, title, album, mp.status) except Exception: # getting info from media player crashed somehow - info = MediaInfo(status='stopped') + info = MediaInfo(state='stopped') else: # selected_device is None - info = MediaInfo(status='stopped', title=f"No device connected - Pair device to '{socket.gethostname()}'") + info = MediaInfo(state='stopped', track=f"No device connected - Pair device to '{socket.gethostname()}'") if args.verbose: log('No media player connected') diff --git a/streams/dlna_meta.py b/streams/dlna_meta.py index 3ad2a5bbd..746297216 100755 --- a/streams/dlna_meta.py +++ b/streams/dlna_meta.py @@ -31,10 +31,10 @@ def metadata_reader(metadata_path: str, album_art_dir: str, service: SSDPDevice, last_file = '' # last cover art file downloaded stop_counter = 0 # counter to prevent empty metadata on transitions metadata = {"state": "stopped", # metadata dict to write to file - "title": "", + "track": "", "artist": "", "album": "", - "album_art": ""} + "img_url": ""} with open(metadata_path, 'w') as f: while True: @@ -70,12 +70,12 @@ def metadata_reader(metadata_path: str, album_art_dir: str, service: SSDPDevice, # try to get song-info from the service and parse it try: meta_xml = ET.fromstring(service.GetMediaInfo(InstanceID=0)["CurrentURIMetaData"]) - metadata["title"] = "" + metadata["track"] = "" metadata["artist"] = "" metadata["album"] = "" for i in meta_xml.iter(): if i.tag == "{http://purl.org/dc/elements/1.1/}title": - metadata["title"] = i.text + metadata["track"] = i.text elif i.tag == "{urn:schemas-upnp-org:metadata-1-0/upnp/}artist": metadata["artist"] = i.text elif i.tag == "{urn:schemas-upnp-org:metadata-1-0/upnp/}album": @@ -96,9 +96,9 @@ def metadata_reader(metadata_path: str, album_art_dir: str, service: SSDPDevice, os.remove(f"{album_art_dir}/{last_file}") last_file = fname - metadata["album_art"] = fname + metadata["img_url"] = f"{album_art_dir.split('/web/')[1]}/{fname}" else: - metadata["album_art"] = "" + metadata["img_url"] = "" except Exception as e: logger.debug(f"Error: could not get song-info: {e}") diff --git a/streams/eventcmd.sh b/streams/eventcmd.sh index 002095638..a02d695f5 100755 --- a/streams/eventcmd.sh +++ b/streams/eventcmd.sh @@ -3,6 +3,7 @@ cd "$(dirname "$0")" stationList="stationList" currentSong="currentSong" +metadata="../../metadata.json" while read L; do k="`echo "$L" | cut -d '=' -f 1`" @@ -41,6 +42,8 @@ case "$1" in clean "$query" echo -n "${artist},,,${title},,,${album},,,${coverArt},,,${rating},,,${stationName}" > "$currentSong" + truncate -s 0 $metadata + printf "{\n\"artist\":\"${artist}\",\n\"track\":\"${title}\",\n\"album\":\"${album}\",\n\"img_url\":\"${coverArt}\",\n\"rating\":${rating},\n\"station\":\"${stationName}\",\n\"state\":\"\"\n}" >> "$metadata" stationList ;; diff --git a/streams/fileplayer.py b/streams/fileplayer.py index d09b3045e..5d5fe9435 100644 --- a/streams/fileplayer.py +++ b/streams/fileplayer.py @@ -193,10 +193,12 @@ def restart_vlc(): if cmd_file: cmd = cmd_file.readline() if cmd == 'play': - player.set_pause(False) + player.set_pause(False) elif cmd == 'pause': - player.set_pause(True) + player.set_pause(True) cmd_file.close() + if args.verbose: + print(f"Command: {cmd}") except: open(args.cmd, 'x') @@ -204,7 +206,7 @@ def restart_vlc(): 'track': '', 'artist': '', 'station': '', - 'state': 'playing', + 'state': 'playing'if state == 'State.Playing' else 'paused', } if args.verbose: @@ -248,13 +250,13 @@ def restart_vlc(): if args.song_info: update_info(cur_info) elif state == 'State.Ended': - if args.song_info: - update_info({ - 'track': '', - 'artist': '', - 'station': '', - 'state': 'ENDED'}) - sys.exit(0) + if args.song_info: + update_info({ + 'track': '', + 'artist': '', + 'station': '', + 'state': 'stopped'}) + sys.exit(0) else: if args.test: log('fail') diff --git a/streams/fmradio.py b/streams/fmradio.py index 86af6e923..a0958f481 100644 --- a/streams/fmradio.py +++ b/streams/fmradio.py @@ -12,6 +12,15 @@ import os import sys import traceback +import signal + + +def signal_handler(sig, _): + """Handle sigterm signal.""" + log(f"Caught signal {sig}, exiting.") + os.system("killall -9 rtl_fm") + traceback.print_exc(file=sys.stdout) + sys.exit(0) parser = argparse.ArgumentParser(prog='runfm', description='play a radio station using an RTL-SDR dongle') parser.add_argument('freq', type=str, help='radio station frequency (ex: 96.1)') @@ -22,6 +31,7 @@ parser.add_argument('--verbose', action='store_true', help='show more verbose output') args = parser.parse_args() +signal.signal(signal.SIGTERM, signal_handler) def log(info): if args.log: @@ -57,9 +67,8 @@ def log(info): def main(): latest_info = { 'station': '', - 'callsign': '', - 'prog_type': '', - 'radiotext': '' + 'track': '', + 'artist': '', } update = False @@ -96,29 +105,31 @@ def main(): """) rds = json.loads(rds_raw) - if "ps" in rds: - if rds["ps"] != latest_info["station"]: - latest_info["station"] = rds["ps"] + + if "prog-type" in rds: + if rds["prog-type"] != latest_info["artist"]: + latest_info["artist"] = rds["prog-type"] update = True - if "callsign" in rds: - if rds["callsign"] != latest_info["callsign"]: - latest_info["callsign"] = rds["callsign"] + else: + latest_info["artist"] = freq + " FM" + + if "radiotext" in rds: + if rds["radiotext"] != latest_info["track"]: + latest_info["track"] = rds["radiotext"] update = True - if "prog_type" in rds: - if rds["prog_type"] != latest_info["prog_type"]: - latest_info["prog_type"] = rds["prog_type"] + else: + latest_info["track"] = "" + + if "station" in rds: + if rds["station"] != latest_info["station"]: + latest_info["station"] = rds["station"] update = True - if "radiotext" in rds: - if rds["radiotext"] != latest_info["radiotext"]: - latest_info["radiotext"] = rds["radiotext"] + elif "callsign" in rds: + if rds["callsign"] != latest_info["station"]: + latest_info["station"] = rds["callsign"] update = True - print(f'rt; "{rds["radiotext"]}"') - # elif "partial_radiotext" in rds: - # this data is bad a lot of times, probably worth updating on - # if rds["partial_radiotext"] != latest_info["radiotext"]: - # latest_info["radiotext"] = rds["partial_radiotext"] - # update = True - # print(f'pr: "{rds["partial_radiotext"]}"') + else: + latest_info["station"] = "" else: if args.verbose: log("No RDS data") @@ -147,11 +158,6 @@ def main(): update = False - except KeyboardInterrupt: - print("Shutdown requested...exiting") - os.system("killall -9 rtl_fm") - traceback.print_exc(file=sys.stdout) - sys.exit(0) except Exception: os.system("killall -9 rtl_fm") traceback.print_exc(file=sys.stdout) diff --git a/streams/lms_metadata.py b/streams/lms_metadata.py index 5fb0cf408..9e3fb229a 100644 --- a/streams/lms_metadata.py +++ b/streams/lms_metadata.py @@ -41,12 +41,13 @@ def save_file(self, folder): 'album': self.album, 'artist': self.artist, 'track': self.track, - 'image_url': self.image_url + 'img_url': self.image_url, + 'state': 'playing' } - with open(f"{folder}/lms_metadata_temp.json", 'wt', encoding='utf-8') as f: - json.dump(data, f, indent=2) - os.replace(f"{folder}/lms_metadata_temp.json", f"{folder}/lms_metadata.json") + with open(f"{folder}/metadata.json", 'wt', encoding='utf-8') as f: + f.write(json.dumps(data)) + self.logger.debug(f"Metadata saved to {folder}/metadata.json") class LMSMetadataReader: @@ -234,8 +235,8 @@ def connect(self): try: self.meta.save_file(self.folder) - except: - pass + except Exception as e: + logging.exception("Error saving metadata to file: {e}") if self.debug: self.meta.log_meta()