Posts: 6,018
Threads: 176
Joined: Apr 2018
Reputation:
235
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
Posts: 87
Threads: 20
Joined: Apr 2018
Reputation:
2
06-03-2020, 04:28 PM
(This post was last modified: 06-03-2020, 04:31 PM by mancio61.)
(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
Posts: 6,018
Threads: 176
Joined: Apr 2018
Reputation:
235
06-03-2020, 04:44 PM
(This post was last modified: 06-03-2020, 04:46 PM by TheOldPresbyope.)
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
Posts: 87
Threads: 20
Joined: Apr 2018
Reputation:
2
Here's the original project on GitHub , it seems to come from Audiophonics...
https://github.com/dhrone/Raspdac-Display
Posts: 71
Threads: 3
Joined: Nov 2022
Reputation:
7
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
|