Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Working prototype for muting spotify on pause/unmuting on resume #883

Draft
wants to merge 16 commits into
base: main
Choose a base branch
from
Prev Previous commit
Next Next commit
fully functional spotify
  • Loading branch information
klay2000 committed Aug 23, 2024
commit 646c4e5cbcd74013f3a5a0a9396c124f02b90f47
3 changes: 3 additions & 0 deletions amplipi/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
41 changes: 2 additions & 39 deletions amplipi/mpris.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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",
Expand Down Expand Up @@ -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."""

Expand Down
97 changes: 80 additions & 17 deletions amplipi/streams/base_streams.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import os
import shutil
import subprocess
import sys
import time
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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'
Expand All @@ -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):
Expand All @@ -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()
Expand All @@ -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}')

Expand Down Expand Up @@ -186,22 +220,38 @@ 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}')
return models.SourceInfo(name=self.full_name(), state='stopped')

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:
Expand All @@ -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 """
Expand Down Expand Up @@ -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'})")
Expand Down Expand Up @@ -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):
Expand Down
62 changes: 15 additions & 47 deletions amplipi/streams/spotify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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}')
Expand All @@ -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:
Expand All @@ -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}')
Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions streams/MPRIS_metadata_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()