Skip to content

Commit

Permalink
Merge pull request #366 from micro-nova/spotifyd
Browse files Browse the repository at this point in the history
Use Spotifyd instead of vollibrespot to get latest librespot changes
  • Loading branch information
linknum23 authored Nov 3, 2022
2 parents 91305eb + df4b6f4 commit 0719ead
Show file tree
Hide file tree
Showing 12 changed files with 259 additions and 147 deletions.
1 change: 1 addition & 0 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ jobs:
python-version: 3.7
- name: Install dependencies
run: |
sudo apt install libgirepository1.0-dev libcairo2-dev # required by Spotifyd
python -m pip install --upgrade pip
pip install pylint mypy pytest pytest-cov
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
# AmpliPi Software Releases

## 0.1.X Upcoming
* web App
* Reject scroll events to volume sliders
* Cleanup version display
* Streams
* Robustify config loading to handle missing streams
* Simplify stream serialization
* Swich to Spotifyd Spotify client
* Add MPRIS metadata/command interface

## 0.1.9
* Web App
Expand Down
173 changes: 173 additions & 0 deletions amplipi/mpris.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"""A module for interfacing with an MPRIS MediaPlayer2 over dbus."""

from dataclasses import dataclass
from enum import Enum, auto
import json
from multiprocessing import Process
import time
from typing import List
from dasbus.connection import SessionMessageBus

from amplipi import utils


METADATA_MAPPINGS = [
('artist', 'xesam:artist'),
('title', 'xesam:title'),
('art_url', 'mpris:artUrl'),
('album', 'xesam:album')
]

METADATA_REFRESH_RATE = 0.5
METADATA_FILE_NAME = "metadata.txt"

class CommandTypes(Enum):
PLAY = auto()
PAUSE = auto()
NEXT = auto()
PREVIOUS = auto()

@dataclass
class Metadata:
"""A data class for storing metadata on a song."""
artist: str = ''
title: str = ''
art_url: str = ''
album: str = ''
state: str = ''


class MPRIS:
"""A class for interfacing with an MPRIS MediaPlayer2 over dbus."""

def __init__(self, service_suffix, src) -> None:
self.mpris = SessionMessageBus().get_proxy(
service_name = f"org.mpris.MediaPlayer2.{service_suffix}",
object_path = "/org/mpris/MediaPlayer2",
interface_name = "org.mpris.MediaPlayer2.Player"
)

self.capabilities: List[CommandTypes] = []

self.service_suffix = service_suffix
self.src = src
self.metadata_path = f'{utils.get_folder("config")}/srcs/{self.src}/{METADATA_FILE_NAME}'

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:
print (f'Exception clearing metadata file: {e}')

self.metadata_process = Process(target=self._metadata_reader)
self.metadata_process.start()

def play(self) -> None:
"""Plays."""
self.mpris.Play()

def pause(self) -> None:
"""Pauses."""
self.mpris.Pause()

def next(self) -> None:
"""Skips song."""
self.mpris.Next()

def previous(self) -> None:
"""Goes back a song."""
self.mpris.Previous()

def play_pause(self) -> None:
"""Plays or pauses depending on current state."""
self.mpris.PlayPause()

def _load_metadata(self):
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:
print(f"mpris loading metadata at {self.metadata_path} failed: {e}")
return None


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 get_capabilities(self) -> List[CommandTypes]:
"""Returns a list of supported commands."""

if len(self.capabilities) == 0:

if self.mpris.CanPlay:
self.capabilities.append(CommandTypes.PLAY)

if self.mpris.CanPause:
self.capabilities.append(CommandTypes.PAUSE)

if self.mpris.CanGoNext:
self.capabilities.append(CommandTypes.NEXT)

if self.mpris.CanGoPrevious:
self.capabilities.append(CommandTypes.PREVIOUS)

return self.capabilities

def __del__(self):
try:
self.metadata_process.kill()
except:
pass

def _metadata_reader(self):
"""Method run by the metadata process, also handles playing/paused."""

mpris = SessionMessageBus().get_proxy(
service_name = f"org.mpris.MediaPlayer2.{self.service_suffix}",
object_path = "/org/mpris/MediaPlayer2",
interface_name = "org.mpris.MediaPlayer2.Player"
)

m = Metadata()
m.state = 'Stopped'

last_sent = m.__dict__

while True:
try:
raw_metadata = mpris.Metadata
metadata = {}

for mapping in METADATA_MAPPINGS:
try:
metadata[mapping[0]] = str(raw_metadata[mapping[1]]).strip("[]'")
except KeyError:
pass

metadata['state'] = mpris.PlaybackStatus.strip("'")

if metadata != last_sent:
last_sent = metadata
with open(self.metadata_path, 'w', encoding='utf-8') as metadata_file:
json.dump(metadata, metadata_file)

except:
pass
time.sleep(1.0/METADATA_REFRESH_RATE)
105 changes: 52 additions & 53 deletions amplipi/streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"""

import os
from re import sub
import sys
import subprocess
import time
Expand All @@ -36,6 +37,7 @@

import amplipi.models as models
import amplipi.utils as utils
from amplipi.mpris import MPRIS

def write_config_file(filename, config):
""" Write a simple config file (@filename) with key=value pairs given by @config """
Expand Down Expand Up @@ -217,15 +219,11 @@ class Spotify(BaseStream):
""" A Spotify Stream """
def __init__(self, name, mock=False):
super().__init__('spotify', name, mock)
self.proc2 = None
self.metaport = None
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.supported_cmds = {
'play': [0x05],
'pause': [0x04],
'next': [0x07],
'prev': [0x08]
}

self.connect_port = None
self.mpris = None
self.proc_pid = None
self.supported_cmds = ['play', 'pause', 'next', 'prev']

def reconfig(self, **kwargs):
reconnect_needed = False
Expand Down Expand Up @@ -256,84 +254,85 @@ def connect(self, src):

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}')

# Copy the config template
os.system(f'cp {toml_template} {toml_useful}')

# Input the proper values
self.metaport = 5030 + 2*src
self.connect_port = 4070 + 10*src
with open(toml_useful, 'r') as TOML:
data = TOML.read()
data = data.replace('AmpliPi_TEMPLATE', f'{self.name}')
data = data.replace("device = 'ch'", f"device = 'ch{src}'")
data = data.replace('5030', f'{self.metaport}')
data = data.replace('device_name_in_spotify_connect', f'{self.name.replace(" ", "-")}')
data = data.replace("alsa_audio_device", f"ch{src}")
data = data.replace('1234', f'{self.connect_port}')
with open(toml_useful, 'w') as TOML:
TOML.write(data)

# PROCESS
meta_args = ['python3', f'{utils.get_folder("streams")}/spot_meta.py', f'{self.metaport}', f'{src_config_folder}']
spotify_args = [f'{utils.get_folder("streams")}/vollibrespot']
spotify_args = [f'{utils.get_folder("streams")}/spotifyd', '--no-daemon', '--config-path', './config.toml']

try:
self.proc = subprocess.Popen(args=meta_args, preexec_fn=os.setpgrp)
self.proc2 = subprocess.Popen(args=spotify_args, cwd=f'{src_config_folder}')
self.proc = subprocess.Popen(args=spotify_args, cwd=f'{src_config_folder}')
time.sleep(0.1) # Delay a bit

self.mpris = MPRIS(f'spotifyd.instance{self.proc.pid}', src)

self._connect(src)
except Exception as exc:
print(f'error starting spotify: {exc}')

def disconnect(self):
if self._is_running():
os.killpg(os.getpgid(self.proc.pid), signal.SIGKILL)
self.proc2.kill()
try:
self.proc.kill()
except Exception:
pass
self._disconnect()
self.connect_port = None
self.mpris = None
self.proc = None
self.proc2 = 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/spotify.png'
img_url='static/imgs/spotify.png' # report generic spotify image in place of unspecified album art
)

try:
with open(loc, 'r') as file:
d = {}
for line in file.readlines():
try:
d = ast.literal_eval(line)
except Exception as exc:
print(f'Error parsing currentSong: {exc}')
if d['state'] and d['state'] != 'stopped':
source.state = d['state']
source.artist = ', '.join(d['artist'])
source.track = d['track']
source.album = d['album']
source.supported_cmds=list(self.supported_cmds.keys())
else:
source.track = f'connect to {self.name}'
if d['img_url']: # report generic spotify image in place of unspecified album art
source.img_url = d['img_url']
except Exception:
pass
md = self.mpris.metadata()

if not self.mpris.is_stopped():
source.state = 'playing' if self.mpris.is_playing() else 'paused'
source.artist = md.artist
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:
print(f"error in spotify: {e}")

return source

def send_cmd(self, cmd):
""" Control of Spotify via commands sent over sockets
Commands include play, pause, next, and previous
Takes src as an input so that it knows which UDP port to send on
"""
udp_ip = "127.0.0.1" # AmpliPi's IP
udp_port = self.metaport + 1 # Adding 1 to the 'metaport' variable used in connect()

try:
if cmd in self.supported_cmds:
self.socket.sendto(bytes(self.supported_cmds[cmd]), (udp_ip, udp_port))
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 exc:
raise RuntimeError(f'Command {cmd} failed to send: {exc}') from exc
except Exception as e:
raise Exception(f"Error sending command {cmd}: {e}") from e

class Pandora(BaseStream):
""" A Pandora Stream """
Expand Down
Binary file added bin/arm/spotifyd
Binary file not shown.
Binary file added bin/x64/spotifyd
Binary file not shown.
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
adafruit-circuitpython-rgb-display
aiofiles
dasbus
deepdiff
fastapi
fastapi_utils
Expand All @@ -11,6 +12,7 @@ numpy
pillow
psutil
pydantic
pygobject
pylint
pyserial
pytest
Expand Down
Loading

0 comments on commit 0719ead

Please sign in to comment.