From bdab3eafd44e91bfb0835d0ec2b5d7514cb1aa3f Mon Sep 17 00:00:00 2001 From: Klayton Smith II Date: Tue, 13 Aug 2024 17:52:06 -0400 Subject: [PATCH 01/16] wip --- amplipi/mpris.py | 60 ++++++++++++------------- amplipi/streams/base_streams.py | 75 +++++++++++++++++++++++++++++--- amplipi/streams/spotify.py | 50 ++++++++++----------- requirements.txt | 1 + streams/MPRIS_metadata_reader.py | 16 +++---- 5 files changed, 134 insertions(+), 68 deletions(-) diff --git a/amplipi/mpris.py b/amplipi/mpris.py index 035a4ca34..9d95f8942 100644 --- a/amplipi/mpris.py +++ b/amplipi/mpris.py @@ -53,13 +53,13 @@ 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: + # 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, @@ -91,36 +91,36 @@ 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() + # 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] + # 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_obj + # except Exception as e: + # logger.exception(f"MPRIS loading metadata at {self.metadata_path} failed: {e}") - return Metadata() + # return Metadata() - def metadata(self) -> Metadata: - """Returns metadata from MPRIS.""" - return self._load_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_playing(self) -> bool: + # """Playing?""" + # return self._load_metadata().state == 'Playing' - def is_stopped(self) -> bool: - """Stopped?""" - return self._load_metadata().state == 'Stopped' + # 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 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/base_streams.py b/amplipi/streams/base_streams.py index b8645d237..8f78785aa 100644 --- a/amplipi/streams/base_streams.py +++ b/amplipi/streams/base_streams.py @@ -5,6 +5,9 @@ import logging from amplipi import models from amplipi import utils +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler +import json logger = logging.getLogger(__name__) logger.level = logging.DEBUG @@ -59,6 +62,10 @@ def __init__(self, stype: str, name: str, only_src=None, disabled: bool = False, 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._watch_metadata: bool = True + self._observer: Optional[Observer] = None + if validate: self.validate_stream(name=name, mock=mock, **kwargs) @@ -72,7 +79,6 @@ 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. """ @@ -91,11 +97,50 @@ 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 + 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): + print("file changed") + self._read_info() + + self._observer = Observer() + # self._fs_event_handler = FileSystemEventHandler() + self._fs_event_handler = handler() + # self._fs_event_handler.on_modified = lambda _: self._read_info + self._observer.schedule(self._fs_event_handler, metadata_path) + self._observer.start() + + def _stop_info_watcher(self): + logger.debug(f'Stopping metadata watcher for {self.name}') + + if self._watch_metadata: + if self._observer: + # TODO: why does this hang???? + # self._observer.stop() + # self._observer.join() + self._observer = None + self._fs_event_handler = None def restart(self): """Reset this stream by disconnecting and reconnecting""" @@ -128,11 +173,26 @@ 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 """ + logger.debug(f'Reading metadata for {self.name}') + try: + with open(f'{self._get_config_folder()}/metadata.json', 'r') as file: + info = json.loads(file.read()) + info['name'] = self.full_name() + info['type'] = self.stype + self._cached_info = models.SourceInfo(**info) + 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 @@ -289,6 +349,10 @@ def connect(self, src: int): logger.exception(f'Failed to start alsaloop connection: {exc}') 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 """ @@ -303,3 +367,4 @@ def disconnect(self): logger.exception(f'PersistentStream disconnect error: {e}') pass self.src = None + self._stop_info_watcher() diff --git a/amplipi/streams/spotify.py b/amplipi/streams/spotify.py index 33f1172ff..e3c85d124 100644 --- a/amplipi/streams/spotify.py +++ b/amplipi/streams/spotify.py @@ -84,31 +84,31 @@ 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 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): try: 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..457647fc7 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') ] @@ -90,10 +90,10 @@ def read_metadata(_a, _b, _c): 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 @@ -149,7 +149,7 @@ def read_metadata(_a, _b, _c): parser.add_argument('-d', '--debug', action='store_true', help='print debug messages') args = parser.parse_args() -if args.debug: - logger.setLevel(logging.DEBUG) +# if args.debug: +logger.setLevel(logging.DEBUG) MPRISMetadataReader(args.service_suffix, args.metadata_path, logger).run() From a8e85f7d3d9574f5c21e73e7f0f4ef0c81b44727 Mon Sep 17 00:00:00 2001 From: Klayton Smith II Date: Wed, 14 Aug 2024 16:09:31 -0400 Subject: [PATCH 02/16] working prototype with spotify --- amplipi/streams/base_streams.py | 17 +++++++++++++++-- streams/MPRIS_metadata_reader.py | 7 +++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/amplipi/streams/base_streams.py b/amplipi/streams/base_streams.py index 8f78785aa..7cf4bc16c 100644 --- a/amplipi/streams/base_streams.py +++ b/amplipi/streams/base_streams.py @@ -5,14 +5,15 @@ 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 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): @@ -123,6 +124,16 @@ class handler(FileSystemEventHandler): def on_modified(_, event): print("file changed") self._read_info() + # logger.debug(f'Metadata changed for {self.name}, info: {self._cached_info}') + # mute if paused + if self._cached_info.state == 'paused': + logger.debug(f'Muting {self.name} because it is paused') + 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))) + if self._cached_info.state == 'playing': + logger.debug(f'Unmuting {self.name} because it is playing') + 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))) self._observer = Observer() # self._fs_event_handler = FileSystemEventHandler() @@ -189,6 +200,8 @@ def _read_info(self) -> models.SourceInfo: def info(self) -> models.SourceInfo: """ Get cached stream info and source metadata """ + # TODO: implement a way to hold default info, e.g. "connect to xyz on spotify" with the spotify logo as art + # TODO: send possible commands if self._watch_metadata: return self._cached_info else: diff --git a/streams/MPRIS_metadata_reader.py b/streams/MPRIS_metadata_reader.py index 457647fc7..32aba886b 100644 --- a/streams/MPRIS_metadata_reader.py +++ b/streams/MPRIS_metadata_reader.py @@ -85,8 +85,9 @@ 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 @@ -142,6 +143,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') From 646c4e5cbcd74013f3a5a0a9396c124f02b90f47 Mon Sep 17 00:00:00 2001 From: Klayton Smith II Date: Thu, 15 Aug 2024 15:35:24 -0400 Subject: [PATCH 03/16] fully functional spotify --- amplipi/app.py | 3 + amplipi/mpris.py | 41 +------------- amplipi/streams/base_streams.py | 97 ++++++++++++++++++++++++++------ amplipi/streams/spotify.py | 62 +++++--------------- streams/MPRIS_metadata_reader.py | 4 +- 5 files changed, 102 insertions(+), 105 deletions(-) diff --git a/amplipi/app.py b/amplipi/app.py index 74c0a4ea6..d3919461d 100644 --- a/amplipi/app.py +++ b/amplipi/app.py @@ -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 9d95f8942..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/base_streams.py b/amplipi/streams/base_streams.py index 7cf4bc16c..0be0efffa 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 @@ -9,6 +11,10 @@ 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 = 30.0 logger = logging.getLogger(__name__) logger.level = logging.DEBUG @@ -62,10 +68,16 @@ 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._watch_metadata: bool = True 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) @@ -85,6 +97,18 @@ def full_name(self): """ 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' @@ -104,6 +128,11 @@ 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): @@ -122,18 +151,20 @@ def _start_info_watcher(self): # set up watchdog to watch for metadata changes class handler(FileSystemEventHandler): def on_modified(_, event): - print("file changed") + # logger.debug(f'metadata file modified for {self.name}') + last_state = self._cached_info.state self._read_info() - # logger.debug(f'Metadata changed for {self.name}, info: {self._cached_info}') - # mute if paused - if self._cached_info.state == 'paused': - logger.debug(f'Muting {self.name} because it is paused') - 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))) - if self._cached_info.state == 'playing': - logger.debug(f'Unmuting {self.name} because it is playing') - 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))) + 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._observer = Observer() # self._fs_event_handler = FileSystemEventHandler() @@ -142,6 +173,9 @@ def on_modified(_, event): 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}') @@ -186,13 +220,31 @@ def _is_running(self): def _read_info(self) -> models.SourceInfo: """ Read the current stream info and metadata, caching it """ - logger.debug(f'Reading metadata for {self.name}') try: 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._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}') @@ -200,8 +252,6 @@ def _read_info(self) -> models.SourceInfo: def info(self) -> models.SourceInfo: """ Get cached stream info and source metadata """ - # TODO: implement a way to hold default info, e.g. "connect to xyz on spotify" with the spotify logo as art - # TODO: send possible commands if self._watch_metadata: return self._cached_info else: @@ -218,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 """ @@ -288,6 +345,11 @@ 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'})") @@ -362,6 +424,7 @@ def connect(self, src: int): logger.exception(f'Failed to start alsaloop connection: {exc}') time.sleep(0.1) # Delay a bit self.src = src + self._start_info_watcher() def _get_config_folder(self): diff --git a/amplipi/streams/spotify.py b/amplipi/streams/spotify.py index e3c85d124..b5936569c 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') # TODO: MPRIS should just need a path! 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': + 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') except Exception as e: raise Exception(f"Error sending command {cmd}: {e}") from e diff --git a/streams/MPRIS_metadata_reader.py b/streams/MPRIS_metadata_reader.py index 32aba886b..13dbee37c 100644 --- a/streams/MPRIS_metadata_reader.py +++ b/streams/MPRIS_metadata_reader.py @@ -152,7 +152,7 @@ def read_metadata(_a, _b, _c): parser.add_argument('-d', '--debug', action='store_true', help='print debug messages') args = parser.parse_args() -# if args.debug: -logger.setLevel(logging.DEBUG) +if args.debug: + logger.setLevel(logging.DEBUG) MPRISMetadataReader(args.service_suffix, args.metadata_path, logger).run() From 40381af655ffc0b2988cb6f23e9eb181047bd91a Mon Sep 17 00:00:00 2001 From: Klayton Smith II Date: Fri, 16 Aug 2024 11:31:06 -0400 Subject: [PATCH 04/16] working shairport sync --- amplipi/app.py | 2 +- amplipi/streams/airplay.py | 87 +++++++++++++------------------- amplipi/streams/spotify.py | 4 +- streams/MPRIS_metadata_reader.py | 6 ++- 4 files changed, 43 insertions(+), 56 deletions(-) diff --git a/amplipi/app.py b/amplipi/app.py index d3919461d..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 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/spotify.py b/amplipi/streams/spotify.py index b5936569c..e9c6385e9 100644 --- a/amplipi/streams/spotify.py +++ b/amplipi/streams/spotify.py @@ -64,7 +64,7 @@ def _activate(self, vsrc: int): 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'{self._get_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}') @@ -90,7 +90,7 @@ def send_cmd(self, cmd): elif cmd == 'next': self.mpris.next() elif cmd == 'prev': - self.mpris.previous() + self.mpris.previous() except Exception as e: raise Exception(f"Error sending command {cmd}: {e}") from e diff --git a/streams/MPRIS_metadata_reader.py b/streams/MPRIS_metadata_reader.py index 13dbee37c..e86d3524f 100644 --- a/streams/MPRIS_metadata_reader.py +++ b/streams/MPRIS_metadata_reader.py @@ -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: @@ -152,7 +154,7 @@ def read_metadata(_a, _b, _c): parser.add_argument('-d', '--debug', action='store_true', help='print debug messages') args = parser.parse_args() -if args.debug: - logger.setLevel(logging.DEBUG) +# if args.debug: +logger.setLevel(logging.DEBUG) MPRISMetadataReader(args.service_suffix, args.metadata_path, logger).run() From eec419165f0251ab6e5dfb82ccc8b7a51b71e999 Mon Sep 17 00:00:00 2001 From: Klayton Smith II Date: Fri, 16 Aug 2024 15:59:44 -0400 Subject: [PATCH 05/16] wip --- amplipi/streams/bluetooth.py | 42 ++++++++++++++++---------------- streams/MPRIS_metadata_reader.py | 4 +-- streams/bluetooth.py | 12 ++++----- 3 files changed, 28 insertions(+), 30 deletions(-) diff --git a/amplipi/streams/bluetooth.py b/amplipi/streams/bluetooth.py index 00cca3655..c0b79bf69 100644 --- a/amplipi/streams/bluetooth.py +++ b/amplipi/streams/bluetooth.py @@ -54,7 +54,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,26 +79,26 @@ 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 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}') diff --git a/streams/MPRIS_metadata_reader.py b/streams/MPRIS_metadata_reader.py index e86d3524f..1f1168231 100644 --- a/streams/MPRIS_metadata_reader.py +++ b/streams/MPRIS_metadata_reader.py @@ -154,7 +154,7 @@ def read_metadata(_a, _b, _c): parser.add_argument('-d', '--debug', action='store_true', help='print debug messages') args = parser.parse_args() -# if args.debug: -logger.setLevel(logging.DEBUG) +if args.debug: + logger.setLevel(logging.DEBUG) MPRISMetadataReader(args.service_suffix, args.metadata_path, logger).run() 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') From 06b578b098f2bf9dcb523d932664817ca5b34a82 Mon Sep 17 00:00:00 2001 From: Klayton Smith II Date: Fri, 16 Aug 2024 17:27:50 -0400 Subject: [PATCH 06/16] working bluetooth --- amplipi/streams/base_streams.py | 2 +- amplipi/streams/bluetooth.py | 23 ++--------------------- 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/amplipi/streams/base_streams.py b/amplipi/streams/base_streams.py index 0be0efffa..e0d3ea218 100644 --- a/amplipi/streams/base_streams.py +++ b/amplipi/streams/base_streams.py @@ -236,7 +236,7 @@ def _read_info(self) -> models.SourceInfo: self._cached_info = models.SourceInfo(**info) # set stopped message if stream is stopped - if self._cached_info.state == '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 = '' diff --git a/amplipi/streams/bluetooth.py b/amplipi/streams/bluetooth.py index c0b79bf69..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() @@ -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: From 63de126a70d3f45be8d11f08e4f42e4594c7c847 Mon Sep 17 00:00:00 2001 From: Klayton Smith II Date: Mon, 19 Aug 2024 14:33:51 -0400 Subject: [PATCH 07/16] working DLNA --- amplipi/streams/base_streams.py | 1 + amplipi/streams/dlna.py | 72 +++++++++++++++++---------------- streams/dlna_meta.py | 12 +++--- 3 files changed, 44 insertions(+), 41 deletions(-) diff --git a/amplipi/streams/base_streams.py b/amplipi/streams/base_streams.py index e0d3ea218..0990f5fb3 100644 --- a/amplipi/streams/base_streams.py +++ b/amplipi/streams/base_streams.py @@ -252,6 +252,7 @@ def _read_info(self) -> models.SourceInfo: def info(self) -> models.SourceInfo: """ Get cached stream info and source metadata """ + if self._watch_metadata: return self._cached_info else: diff --git a/amplipi/streams/dlna.py b/amplipi/streams/dlna.py index f5dd24a26..1a8600e00 100644 --- a/amplipi/streams/dlna.py +++ b/amplipi/streams/dlna.py @@ -25,6 +25,10 @@ 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 +50,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 +60,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 +77,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,32 +98,32 @@ 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 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: 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}") From cf6fbea50a65eb6ea848265bd97637e50055194e Mon Sep 17 00:00:00 2001 From: Klayton Smith II Date: Wed, 21 Aug 2024 10:27:32 -0400 Subject: [PATCH 08/16] wip --- amplipi/streams/dlna.py | 28 ---------- amplipi/streams/fm_radio.py | 86 +++++++++++++++---------------- amplipi/streams/internet_radio.py | 58 +++++++++++---------- streams/fmradio.py | 45 ++++++++-------- 4 files changed, 95 insertions(+), 122 deletions(-) diff --git a/amplipi/streams/dlna.py b/amplipi/streams/dlna.py index 1a8600e00..9d3065e35 100644 --- a/amplipi/streams/dlna.py +++ b/amplipi/streams/dlna.py @@ -28,7 +28,6 @@ def __init__(self, name: str, disabled: bool = False, mock: bool = False): 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: @@ -98,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/fm_radio.py b/amplipi/streams/fm_radio.py index b5c9b2ecf..0c8697fd3 100644 --- a/amplipi/streams/fm_radio.py +++ b/amplipi/streams/fm_radio.py @@ -18,7 +18,8 @@ class FMRadio(BaseStream): def __init__(self, name: str, freq, logo: Optional[str] = None, disabled: bool = False, mock: bool = False): super().__init__(self.stream_type, name, disabled=disabled, mock=mock) self.freq = freq - self.logo = logo + self.default_image_url = logo + self.stopped_message = None def reconfig(self, **kwargs): reconnect_needed = False @@ -42,11 +43,8 @@ def connect(self, src): 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), @@ -68,44 +66,44 @@ def disconnect(self): 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 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 @staticmethod def is_hw_available(): diff --git a/amplipi/streams/internet_radio.py b/amplipi/streams/internet_radio.py index b1f2118a6..c1675cbae 100644 --- a/amplipi/streams/internet_radio.py +++ b/amplipi/streams/internet_radio.py @@ -20,8 +20,12 @@ class InternetRadio(BaseStream): def __init__(self, name: str, url: str, logo: Optional[str], disabled: bool = False, mock: bool = False, validate: bool = True): super().__init__(self.stream_type, name, disabled=disabled, mock=mock, validate=validate, url=url, logo=logo) self.url = url - self.logo = logo self.supported_cmds = ['play', 'stop'] + if logo: + self.default_image_url = logo + else: + self.default_image_url = 'static/imgs/internet_radio.png' + self.stopped_message = None def reconfig(self, **kwargs): self.validate_stream(**kwargs) @@ -45,16 +49,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 +75,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 +95,27 @@ 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) - 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 + # 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) + # 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 def send_cmd(self, cmd): try: diff --git a/streams/fmradio.py b/streams/fmradio.py index 86af6e923..8a0982997 100644 --- a/streams/fmradio.py +++ b/streams/fmradio.py @@ -57,9 +57,8 @@ def log(info): def main(): latest_info = { 'station': '', - 'callsign': '', - 'prog_type': '', - 'radiotext': '' + 'track': '', + 'artist': '', } update = False @@ -96,29 +95,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") From 8e41129cfedbc3d6faedf9a5f9355a5aa0457d9d Mon Sep 17 00:00:00 2001 From: Klayton Smith II Date: Wed, 21 Aug 2024 11:33:00 -0400 Subject: [PATCH 09/16] wip --- amplipi/streams/fm_radio.py | 52 +++++++------------------------ amplipi/streams/internet_radio.py | 35 ++++++--------------- 2 files changed, 21 insertions(+), 66 deletions(-) diff --git a/amplipi/streams/fm_radio.py b/amplipi/streams/fm_radio.py index 0c8697fd3..a44755943 100644 --- a/amplipi/streams/fm_radio.py +++ b/amplipi/streams/fm_radio.py @@ -18,7 +18,7 @@ class FMRadio(BaseStream): def __init__(self, name: str, freq, logo: Optional[str] = None, disabled: bool = False, mock: bool = False): super().__init__(self.stream_type, name, disabled=disabled, mock=mock) self.freq = freq - self.default_image_url = logo + self.logo = logo self.stopped_message = None def reconfig(self, **kwargs): @@ -39,8 +39,8 @@ 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 song_info_path = f'{self._get_config_folder()}/metadata.json' @@ -62,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 c1675cbae..a5bae19df 100644 --- a/amplipi/streams/internet_radio.py +++ b/amplipi/streams/internet_radio.py @@ -20,11 +20,8 @@ class InternetRadio(BaseStream): def __init__(self, name: str, url: str, logo: Optional[str], disabled: bool = False, mock: bool = False, validate: bool = True): super().__init__(self.stream_type, name, disabled=disabled, mock=mock, validate=validate, url=url, logo=logo) self.url = url + self.logo = logo self.supported_cmds = ['play', 'stop'] - if logo: - self.default_image_url = logo - else: - self.default_image_url = 'static/imgs/internet_radio.png' self.stopped_message = None def reconfig(self, **kwargs): @@ -95,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) - # 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 + 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 def send_cmd(self, cmd): try: @@ -134,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: From 6fb566f6d75af37a47c6678651909c8b447eebbc Mon Sep 17 00:00:00 2001 From: Klayton Smith II Date: Wed, 21 Aug 2024 11:40:47 -0400 Subject: [PATCH 10/16] working fm radio and internet radio --- amplipi/streams/base_streams.py | 41 +++++++++++++++++---------------- streams/fmradio.py | 15 ++++++++---- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/amplipi/streams/base_streams.py b/amplipi/streams/base_streams.py index 0990f5fb3..9f314ddc4 100644 --- a/amplipi/streams/base_streams.py +++ b/amplipi/streams/base_streams.py @@ -221,38 +221,39 @@ def _is_running(self): def _read_info(self) -> models.SourceInfo: """ Read the current stream info and metadata, caching it """ try: - with open(f'{self._get_config_folder()}/metadata.json', 'r') as file: - info = json.loads(file.read()) + 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 + # 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' + # 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) + 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 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 + # 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 + 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 cached stream info and source metadata """ - + if self._watch_metadata: return self._cached_info else: diff --git a/streams/fmradio.py b/streams/fmradio.py index 8a0982997..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: @@ -148,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) From 5929064d1c3d3502de9e8647930c6151693cb71c Mon Sep 17 00:00:00 2001 From: Klayton Smith II Date: Wed, 21 Aug 2024 15:27:46 -0400 Subject: [PATCH 11/16] working lms --- amplipi/streams/lms.py | 58 +++++------------------------------------ pyproject.toml | 2 +- streams/lms_metadata.py | 13 ++++----- 3 files changed, 14 insertions(+), 59 deletions(-) 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/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/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() From 7fd5c82965890e7512fadff3cddc0981c3d80239 Mon Sep 17 00:00:00 2001 From: Klayton Smith II Date: Thu, 22 Aug 2024 09:04:37 -0400 Subject: [PATCH 12/16] wip --- amplipi/streams/base_streams.py | 2 + amplipi/streams/media_device.py | 150 ++++++++++++++++++-------------- streams/fileplayer.py | 20 +++-- 3 files changed, 96 insertions(+), 76 deletions(-) diff --git a/amplipi/streams/base_streams.py b/amplipi/streams/base_streams.py index 9f314ddc4..586e20655 100644 --- a/amplipi/streams/base_streams.py +++ b/amplipi/streams/base_streams.py @@ -399,8 +399,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 """ diff --git a/amplipi/streams/media_device.py b/amplipi/streams/media_device.py index 25f210484..91f28d6a9 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,15 @@ 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 = "Nothing is playing, please select a song in the browser." + self.default_image_url = 'static/imgs/no_note.png' def reconfig(self, **kwargs): reconnect_needed = False @@ -48,15 +49,11 @@ 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' + 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 remake_list and self.playing is not None: try: self.song_list, _ = self.make_song_list(os.path.dirname(self.playing)) @@ -67,7 +64,7 @@ def _activate(self, vsrc: int, remake_list: bool = True): 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 + '--song-info', song_info_path, '--log', log_file_path, '--cmd', self.command_file_path, '--verbose' ] logger.info(f'running: {self.vlc_args}') self.proc = subprocess.Popen(args=self.vlc_args, preexec_fn=os.setpgrp) @@ -76,7 +73,6 @@ def _activate(self, vsrc: int, remake_list: bool = True): # 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' self.src = vsrc return @@ -108,14 +104,14 @@ 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 + # 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: self.next_song() @@ -140,58 +136,78 @@ def change_song(self, new_song_id): 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) - - def info(self) -> models.SourceInfo: + super().send_cmd(cmd) + + logger.info(f'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() + 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 + if self._cached_info.state == 'playing': + self._cached_info.img_url = 'static/imgs/note.png' + else: + self._cached_info.img_url = 'static/imgs/no_note.png' + + logger.info(f'cached info: {self._cached_info}') + + return self._cached_info + + # self._cached_info.track = self.playing.split('/')[-1] if self.playing is not None else '' + + # 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 def browse(self, parent=None, path=None) -> List[models.BrowsableItem]: browsables = [] diff --git a/streams/fileplayer.py b/streams/fileplayer.py index d09b3045e..8521837c0 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') @@ -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': 'ENDED'}) + sys.exit(0) else: if args.test: log('fail') From 21c36af6e1343c791634daf372e9cbf86a1c2c39 Mon Sep 17 00:00:00 2001 From: Klayton Smith II Date: Thu, 22 Aug 2024 12:03:24 -0400 Subject: [PATCH 13/16] working media device --- amplipi/streams/base_streams.py | 2 +- amplipi/streams/media_device.py | 114 ++++++++++++-------------------- streams/fileplayer.py | 2 +- 3 files changed, 44 insertions(+), 74 deletions(-) diff --git a/amplipi/streams/base_streams.py b/amplipi/streams/base_streams.py index 586e20655..9a1cac437 100644 --- a/amplipi/streams/base_streams.py +++ b/amplipi/streams/base_streams.py @@ -14,7 +14,7 @@ from threading import Timer # time before a stream auto-mutes on pause in seconds -AUTO_MUTE_TIMEOUT = 30.0 +AUTO_MUTE_TIMEOUT = 5.0 logger = logging.getLogger(__name__) logger.level = logging.DEBUG diff --git a/amplipi/streams/media_device.py b/amplipi/streams/media_device.py index 91f28d6a9..22074ac6a 100644 --- a/amplipi/streams/media_device.py +++ b/amplipi/streams/media_device.py @@ -30,7 +30,7 @@ def __init__(self, name: str, url: Optional[str], disabled: bool = False, mock: self.playing = None self.device: Optional[str] = None self.supported_cmds = ['play', 'pause', 'prev'] - self.stopped_message = "Nothing is playing, please select a song in the browser." + self.stopped_message = None self.default_image_url = 'static/imgs/no_note.png' def reconfig(self, **kwargs): @@ -47,34 +47,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: - - # 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 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, '--verbose' - ] - 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() + 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 = [] @@ -104,19 +82,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.ended 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' + self.send_cmd('paused') def next_song(self): self.change_song(self.song_index + 1) @@ -125,9 +94,31 @@ 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') @@ -138,8 +129,6 @@ def change_song(self, new_song_id): def send_cmd(self, cmd): super().send_cmd(cmd) - logger.info(f'cmd: {cmd}') - if cmd == 'stop': self._deactivate() if self.command_file_path is not None: @@ -175,39 +164,20 @@ def _read_info(self) -> models.SourceInfo: if self.song_index != len(self.song_list) - 1 and self.playing in self.song_list: self.supported_cmds.append('next') - if self._cached_info.state == 'playing': - self._cached_info.img_url = 'static/imgs/note.png' - else: - self._cached_info.img_url = 'static/imgs/no_note.png' - - logger.info(f'cached info: {self._cached_info}') + # 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 - # self._cached_info.track = self.playing.split('/')[-1] if self.playing is not None else '' - - # 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 + 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/streams/fileplayer.py b/streams/fileplayer.py index 8521837c0..f9332ede1 100644 --- a/streams/fileplayer.py +++ b/streams/fileplayer.py @@ -206,7 +206,7 @@ def restart_vlc(): 'track': '', 'artist': '', 'station': '', - 'state': 'playing', + 'state': 'playing'if state == 'State.Playing' else 'paused', } if args.verbose: From 973799d850e6773384f8a486f61a64e4e5bbe19b Mon Sep 17 00:00:00 2001 From: Klayton Smith II Date: Thu, 22 Aug 2024 15:15:00 -0400 Subject: [PATCH 14/16] working fileplayer and wip pandora --- amplipi/streams/base_streams.py | 2 +- amplipi/streams/file_player.py | 74 ++++++++-------- amplipi/streams/media_device.py | 6 +- amplipi/streams/pandora.py | 146 ++++++++++++++++++++------------ streams/eventcmd.sh | 3 + streams/fileplayer.py | 2 +- 6 files changed, 139 insertions(+), 94 deletions(-) diff --git a/amplipi/streams/base_streams.py b/amplipi/streams/base_streams.py index 9a1cac437..fd392c193 100644 --- a/amplipi/streams/base_streams.py +++ b/amplipi/streams/base_streams.py @@ -354,7 +354,7 @@ def activate(self): 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: diff --git a/amplipi/streams/file_player.py b/amplipi/streams/file_player.py index 279b5aebe..9c70496d4 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' - - 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' + super().send_cmd(cmd) + + 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(): @@ -117,12 +121,12 @@ def disconnect(self): 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 + # 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/media_device.py b/amplipi/streams/media_device.py index 22074ac6a..356a524c5 100644 --- a/amplipi/streams/media_device.py +++ b/amplipi/streams/media_device.py @@ -25,7 +25,6 @@ def __init__(self, name: str, url: Optional[str], disabled: bool = False, mock: self.bkg_thread: Optional[threading.Thread] = None 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 @@ -82,9 +81,9 @@ def wait_on_proc(self): else: time.sleep(0.3) # handles mock case - if self._cached_info.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): + 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): @@ -124,7 +123,6 @@ def change_song(self, new_song_id): f.write('play') f.close() self._prev_timeout = datetime.datetime.now() + datetime.timedelta(seconds=3) - self.ended = False def send_cmd(self, cmd): super().send_cmd(cmd) diff --git a/amplipi/streams/pandora.py b/amplipi/streams/pandora.py index 548a5e254..b14398641 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,105 @@ def _deactivate(self): self.proc = None self.ctrl = '' - 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 - ) + 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: + i = json.load(file) - if len(self.stations) == 0: - self.load_stations() + 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 - 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 + self._cached_info.img_url = self._cached_info.img_url.replace('http:', 'https:') # HACK: hack to just replace with https + + return super()._read_info() + + def info(self) -> models.SourceInfo: + i = super().info() + + if i.rating is not None: + i.rating = models.PandoraRating(i.rating) + + # 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 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 + # ) + + # if len(self.stations) == 0: + # self.load_stations() + + # 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 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 +285,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/streams/eventcmd.sh b/streams/eventcmd.sh index 002095638..8af54496d 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 f9332ede1..5d5fe9435 100644 --- a/streams/fileplayer.py +++ b/streams/fileplayer.py @@ -255,7 +255,7 @@ def restart_vlc(): 'track': '', 'artist': '', 'station': '', - 'state': 'ENDED'}) + 'state': 'stopped'}) sys.exit(0) else: if args.test: From 05f63914f00f80a71307360683a7c6e29ae37286 Mon Sep 17 00:00:00 2001 From: Klayton Smith II Date: Thu, 22 Aug 2024 15:16:26 -0400 Subject: [PATCH 15/16] no longer hanging --- amplipi/streams/base_streams.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/amplipi/streams/base_streams.py b/amplipi/streams/base_streams.py index fd392c193..2c8d29c12 100644 --- a/amplipi/streams/base_streams.py +++ b/amplipi/streams/base_streams.py @@ -181,9 +181,8 @@ def _stop_info_watcher(self): if self._watch_metadata: if self._observer: - # TODO: why does this hang???? - # self._observer.stop() - # self._observer.join() + self._observer.stop() + self._observer.join() self._observer = None self._fs_event_handler = None From 2b1e35532cc8d4afb2c53a7df0e52f6527c7fc2a Mon Sep 17 00:00:00 2001 From: Klayton Smith II Date: Fri, 23 Aug 2024 10:35:29 -0400 Subject: [PATCH 16/16] first completely functional version --- amplipi/streams/base_streams.py | 9 +++-- amplipi/streams/file_player.py | 10 ------ amplipi/streams/pandora.py | 64 ++++++--------------------------- streams/eventcmd.sh | 2 +- 4 files changed, 16 insertions(+), 69 deletions(-) diff --git a/amplipi/streams/base_streams.py b/amplipi/streams/base_streams.py index 2c8d29c12..f3d308574 100644 --- a/amplipi/streams/base_streams.py +++ b/amplipi/streams/base_streams.py @@ -166,10 +166,8 @@ def on_modified(_, event): # logger.debug(f'mute timer cancelled for {self.name}') self.unmute() - self._observer = Observer() - # self._fs_event_handler = FileSystemEventHandler() self._fs_event_handler = handler() - # self._fs_event_handler.on_modified = lambda _: self._read_info + self._observer = Observer() self._observer.schedule(self._fs_event_handler, metadata_path) self._observer.start() @@ -181,10 +179,11 @@ def _stop_info_watcher(self): if self._watch_metadata: if self._observer: - self._observer.stop() - self._observer.join() + 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""" diff --git a/amplipi/streams/file_player.py b/amplipi/streams/file_player.py index 9c70496d4..c7779b975 100644 --- a/amplipi/streams/file_player.py +++ b/amplipi/streams/file_player.py @@ -120,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/pandora.py b/amplipi/streams/pandora.py index b14398641..7a2d411df 100644 --- a/amplipi/streams/pandora.py +++ b/amplipi/streams/pandora.py @@ -148,7 +148,10 @@ 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: - i = json.load(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 @@ -156,9 +159,13 @@ def _read_info(self, write_state=False) -> models.SourceInfo: json.dump(i, file) return self._cached_info - self._cached_info.img_url = self._cached_info.img_url.replace('http:', 'https:') # HACK: hack to just replace with https - return super()._read_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: i = super().info() @@ -175,56 +182,7 @@ def info(self) -> models.SourceInfo: i.rating = models.PandoraRating.DEFAULT return i - - # 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 - # ) - - # if len(self.stations) == 0: - # self.load_stations() - - # 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 - + def send_cmd(self, cmd): """ Pianobar's commands cmd: Command string sent to pianobar's control fifo diff --git a/streams/eventcmd.sh b/streams/eventcmd.sh index 8af54496d..a02d695f5 100755 --- a/streams/eventcmd.sh +++ b/streams/eventcmd.sh @@ -43,7 +43,7 @@ case "$1" in 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" + 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 ;;