Thank you for your donation!


Cloudsmith graciously provides open-source package management and distribution for our project.


Alternative renderers and metadata
#11
What is this Python script you refer to? Examining it would be the first step in a reverse-engineering process to find out what LMS API is being accessed or what stream decoding method is being used to capture the metadata of the currently playing track. I still haven't found either approach documented on the InterWeb™, which leaves me dubious.

Regards,
Kent
Reply
#12
(06-03-2020, 03:53 PM)TheOldPresbyope Wrote: What is this Python script you refer to? Examining it would be the first step in a reverse-engineering process to find out what LMS API is being accessed or what stream decoding method is being used to capture the metadata of the currently playing track. I still haven't found either approach documented on the InterWeb™, which leaves me dubious.

Regards,
Kent

Try to attach the .py file but with no success. Anyway here's the  portions of code that create the connection with the LMS Server, and the one able to retrieve from it the music info 

1) Constants and initial comments

Code:
LMS_ENABLED = False
LMS_SERVER = "localhost"
LMS_PORT = 9090
LMS_USER = ""
LMS_PASSWORD = ""

# Set this to MAC address of the Player you want to monitor.
# THis should be the MAC of the RaspDac system if using Max2Play with SqueezePlayer
# Note: if you have another Logitech Media Server running in your network, it is entirely
#       possible that your player has decided to join it, instead of the LMS on Max2Play
#       To fix this, go to the SqueezeServer interface and change move the player to the
#       correct server.
LMS_PLAYER = "00:01:02:aa:bb:cc"

Note: don't ask me the meaning of the above comment, is quite obscured for me....

2 ) Establishing Connection:
Code:
        if LMS_ENABLED:
            for i in range (1,ATTEMPTS):
                try:
                    # Connect to the LMS daemon
                    self.lmsserver = pylms.server.Server(LMS_SERVER, LMS_PORT, LMS_USER, LMS_PASSWORD)
                    self.lmsserver.connect()

                    # Find correct player
                    players = self.lmsserver.get_players()
                    for p in players:
                        ### Need to find out how to get the MAC address from player
                        if p.get_ref().lower() == LMS_PLAYER.lower():
                            self.lmsplayer = p
                            break
                    if self.lmsplayer is None:
                        self.lmsplayer = self.lmsserver.get_players()[0]
                        if self.lmsplayer is None:
                            raise Exception('Could not find any LMS player')
                    break
                except (socket_error, AttributeError, IndexError):
                    logging.debug("Connect attempt {0} to LMS server failed".format(i))
                    time.sleep(2)
            else:
                # After the alloted number of attempts did not succeed in connecting
                logging.warning("Unable to connect to LMS service on startup")

3 - Retrieving info from LMS Server

Code:
    def status_lms(self):
        # Try to get status from LMS daemon

        try:
            lms_status = self.lmsplayer.get_mode()
        except:
            # Try to reestablish connection to daemon
            try:
                self.lmsserver = pylms.server.Server(LMS_SERVER, LMS_PORT, LMS_USER, LMS_PASSWORD)
                self.lmsserver.connect()

                # Find correct player
                players = self.lmsserver.get_players()
                for p in players:
                    ### Need to find out how to get the MAC address from player
                    if p.get_ref().lower() == LMS_PLAYER.lower():
                        self.lmsplayer = p
                        break
                if self.lmsplayer is None:
                    self.lmsplayer = self.lmsserver.get_players()[0]
                    if self.lmsplayer is None:
                        raise Exception('Could not find any LMS player')

                lms_status = self.lmsplayer.get_mode()
            except (socket_error, AttributeError, IndexError):
                logging.debug("Could not get status from LMS daemon")
                return { 'state':u"stop", 'artist':u"", 'title':u"", 'album':u"", 'remaining':u"", 'current':0, 'duration':0, 'position':u"", 'volume':0, 'playlist_display':u"", 'playlist_position':0, 'playlist_count':0, 'bitrate':u"", 'type':u"", 'current_time':u""}


          if lms_status == "play":
            import urllib

            artist = urllib.unquote(str(self.lmsplayer.request("artist ?", True))).decode('utf-8')
            title = urllib.unquote(str(self.lmsplayer.request("title ?", True))).decode('utf-8')
            album = urllib.unquote(str(self.lmsplayer.request("album ?", True))).decode('utf-8')
            playlist_position = int(self.lmsplayer.request("playlist index ?"))+1
            playlist_count = self.lmsplayer.playlist_track_count()
            volume = self.lmsplayer.get_volume()
            current = self.lmsplayer.get_time_elapsed()
            duration = self.lmsplayer.get_track_duration()
            url = self.lmsplayer.get_track_path()

            # Get bitrate and tracktype if they are available.  Try blocks used to prevent array out of bounds exception if values are not found
            try:
                bitrate = urllib.unquote(str(self.lmsplayer.request("songinfo 2 1 url:"+url+" tags:r", True))).decode('utf-8').split("bitrate:", 1)[1]
            except:
                bitrate = u""

            try:
                tracktype = urllib.unquote(str(self.lmsplayer.request("songinfo 2 1 url:"+url+" tags:o", True))).decode('utf-8').split("type:",1)[1]
            except:
                tracktype = u""

            playlist_display = "{0}/{1}".format(playlist_position, playlist_count)
            # If the track count is greater than 1, we are playing from a playlist and can display track position and track count
            if self.lmsplayer.playlist_track_count() > 1:
                playlist_display = "{0}/{1}".format(playlist_position, playlist_count)
            # if the track count is exactly 1, this is either a short playlist or it is streaming
            elif self.lmsplayer.playlist_track_count() == 1:
                try:
                    # if streaming
                    if self.lmsplayer.playlist_get_info()[0]['duration'] == 0.0:
                        playlist_display = "Streaming"
                    # it really is a short playlist
                    else:
                        playlist_display = "{0}/{1}".format(playlist_position, playlist_count)
                except KeyError:
                    logging.debug("In LMS couldn't get valid track information")
                    playlist_display = u""
            else:
                logging.debug("In LMS track length is <= 0")
                playlist_display = u""

              # since we are returning the info as a JSON formatted return, convert
              # any None's into reasonable values

            if artist is None: artist = u""
            if title is None: title = u""
            if album is None: album = u""
            if current is None: current = 0
            if volume is None: volume = 0
            if bitrate is None: bitrate = u""
            if tracktype is None: tracktype = u""
            if duration is None: duration = 0

            # if duration is not available, then suppress its display
            if int(duration) > 0:
                timepos = time.strftime("%M:%S", time.gmtime(int(current))) + "/" + time.strftime("%M:%S", time.gmtime(int(duration)))
                remaining = time.strftime("%M:%S", time.gmtime(int(duration) - int(current) ) )

            else:
                timepos = time.strftime("%M:%S", time.gmtime(int(current)))
                remaining = timepos

            return { 'state':u"play", 'artist':artist, 'title':title, 'album':album, 'remaining':remaining, 'current':current, 'duration':duration, 'position':timepos, 'volume':volume, 'playlist_display':playlist_display,'playlist_position':playlist_position, 'playlist_count':playlist_count, 'bitrate':bitrate, 'type':tracktype }
          else:
            return { 'state':u"stop", 'artist':u"", 'title':u"", 'album':u"", 'remaining':u"", 'current':0, 'duration':0, 'position':u"", 'volume':0, 'playlist_display':u"", 'playlist_position':0, 'playlist_count':0, 'bitrate':u"", 'type':u""}

The returned state object is then passed to the rendering part of the script, that uses it to send info to the OLED display

Hope this helps the discussion

Andrea
Reply
#13
Cool. Thanks.

So, searching the InterWEB™ on the class name pylms I infer this script is using jinglemansweep's PyLMS module.

Ok. I see what he's doing. Basically, as a third party, asking LMS what is the current album, artist, track you are sending to a specified player, e.g., using a LMS API.

jinglemansweep refers to a 4-year old document describing the HTTP API on a french website http://tutoriels.domotique-store.fr/cont...-http.html

Don't know why I didn't run across this before (or why the API is not documented in more official places) but that's the breaks.

Regards,
Kent
Reply
#14
Here's the original project on GitHub , it seems to come from Audiophonics...

https://github.com/dhrone/Raspdac-Display
Reply
#15
I had a play with the AirPlay decoder and was able to create a HTML page that had track info and relevant cover art.

Using the example, I extended it to write a HTML file, but I understand that writing cuurentsong.txt might be a better approach.
I am not familiar enough with how the moode code changes the display when airplay is active.

To test I run the following:

Code:
cat /tmp/shairport-sync-metadata | shairport-sync-metadata-reader | sudo ~/reader.py


Where the reader has the following:

Code:
#!/usr/bin/env python3
# @TheOldPresbyope
# this code was copied from
# https://appcodelabs.com/show-artist-song-metadata-using-airplay-on-raspberry-pi
# and modified to print to console as if it were a three-line display

# NOTE - doesn't clear previous value of specific key when no new value is received

# @steve4star
# Adapted to clear key values when track/album identifier changes
# Writes to HTML page /var/www/airplay.html
# NOTE needs permission 755 for html and jpg file

import sys
import re
import os
import shutil

# a device isn't needed but let's define a dummy just
# to keep the same render() syntax as in the original code
device = "null"

artist = ""
track = ""
album = ""
tracklength = ""
picture = ""
pictureURL =""
status = ""
progress = ""
ID = ""

def extract(line):
  #Progress String "388666875/630526809/706186875".
  m = re.match('^(Title|Artist|Album Name|Track length|Progress String): \"(.*?)\"\.$', line)
  if m:
      return m.group(1), m.group(2)
  else:
      #Track length: 300000 milliseconds.
      m = re.match('^(Track length): (.*?) milliseconds\.$', line)
      if m:
          return m.group(1), m.group(2)
      else:
          #Picture received, length 98020 bytes.
          m = re.match('^(Picture received, length) (.*?) bytes\.$', line)
          if m:
              return m.group(1), m.group(2)
          else:
              m = re.match('^(Play Session) (.*?)\.$', line)
              if m:
                  return m.group(1), m.group(2)
              else:
                  m = re.match('^(Persistent ID): (.*?)\.$', line)
                  if m:
                      return m.group(1), m.group(2)
                  else:
                      return None, None

def update(key, val):
  global artist, album, track, tracklength, picture, status, progress, ID
   
  if key == "Artist":
      artist = val
  elif key == "Album Name":
      album = val
  elif key == "Title":
       track = val
  elif key == "Track length":
       tracklength = val
  elif key == "Picture received, length":
       picture = val
  elif key == "Progress String":
       progress = val
  elif key == "Play Session":
       status = val
  elif key == "Persistent ID":
       if ID != val: # new item, clear cached values
          ID = val
          artist = album = track = tracklength = picture = progress = ""

def render(device):
  global pictureURL
  sTime=""

  # print to console instead of writing output to a device
  # use an ASCII escape sequence to imitate a three-line display
  print(f'\u001b[2J\
ID: {ID}\n\
Artist: {artist}\n\
Album Name: {album}\n\
Title: {track}\n\
Cover: {pictureURL}\n\
Status: {status}')

  if tracklength != "":
       mill_sec = int(tracklength)
       total_sec = mill_sec / 1000
       hours = int(total_sec // 3600 )
       min = int((total_sec // 60) % 60)
       sec = int(total_sec % 60)
       sTime = f"{hours:02d}:{min:02d}:{sec:02d}"
       print(f"Length: {sTime}")

  if picture != "":
       # directory/folder path
       dir_path = r'/tmp/shairport-sync/.cache/coverart'
       res = []
       for file_path in os.listdir(dir_path):
           if os.path.isfile(os.path.join(dir_path, file_path)):
               res.append(file_path)

       if res:
           pictureURL = res[-1:][0]
           shutil.copyfile( f'{dir_path}/{pictureURL}', '/var/www/images/airplay.jpg' )
     
  with open('/var/www/airplay.html', 'w') as html:
     html.write(f'<!DOCTYPE html>\n\
<html>\n\
<head><title>Airplay Metadata</title><meta http-equiv="refresh" content="60"></head>\n\
<body>\n\
 <table>\n\
   <tbody>\n\
     <tr><td>Artist</td><td>{artist}</td></tr>\n\
     <tr><td>Album Title</td><td>{album}</td></tr>\n\
     <tr><td>Track Title</td><td>{track}</td></tr>\n\
     <tr><td>Track Length</td><td>{sTime}</td></tr>\n\
     <tr><td><img src="/images/airplay.jpg"></td><td>{status}</td></tr>\n\
   </tbody>\n\
 </table>\n\
</body>\n\
</html>\n')

# Create devices
  # deleted all the device code

# Welcome message
  # No, let's not

# Main loop
try:
  while True:
      line = sys.stdin.readline()
      key, val = extract(line)
      if key and val:
          update(key, val)
          render(device)

except KeyboardInterrupt:
  sys.stdout.flush()
  pass
Reply


Forum Jump: