šŸ“œ ā¬†ļø ā¬‡ļø

Your own Python cover server for Internet radio

image

I am a perfectionist who loves order in everything. Most of all I am pleased when things work exactly as they should work (in my, of course, understanding). And I have long had my own personal Internet radio based on IceCast-KH + LiquidSoap. And for many years I wasn’t allowed to sleep in peace the fact that streaming broadcasting servers do not know how to give the covers (artwork) of the tracks being played in the stream. And not only in the stream - they can not do it at all. I went to IceCast-KH (fork from IceCast2) only because of one of its uber-features - he can give mp3-tags inside the flv stream (this is needed to display the executable track when playing online on the site via a flash player). And now it's time to close the last question - the release of the covers of the tracks being played - and calm down. Since there were no ready-made solutions, I didn’t think of anything better than writing my own cover server for .mp3 files. How? Welcome under cat.

Prehistory


I usually listen to the radio in the car, on a 2-din tape recorder based on Android 4.4 KitKat (and at home on a tablet under the same Android). To listen, after a long and thoughtful search of existing programs, XiiaLive was chosen, mainly because it knows how to use custom radio stations (such a banal, seemingly feature, but not supported by most streaming radio players - here’s the ShoutCast / Uber Stations catalog - choose and listen what they give), as well as for the fact that he can pump up and display the covers of the tracks being played. Yes, of course, not all, but can. The music played, the covers were partially shown and for some time the inner perfectionist calmed down, but as it turned out, not for long.

After a while, an extremely unpleasant application bug related to incorrect processing of Unicode emerged - if the track name and artist were not in Latin, the album cover was displayed incorrectly. Not only that - always the same. And I will tell you even more - for some reason this has always been Nyusha. This is what I could not tolerate.

image
Screenshot illustrating how XiiaLive encroached on the holy.
')
It would be possible to wait until the developers fix this bug , but sensibly judging that they hardly have covers for everything that is in rotation exactly at my station (they definitely won't have covers for Ishome, Interior Disposition, tmtnsft and more than MĪ£ $ † Ī›MN Ī£KCƘNĪ› †), it seemed more correct to write my api for the covers. Which will be able to work precisely on the local database of music files and, if possible, without being tied to a specific broadcast server.

Investigate the issue


It was not possible to find a description of the standard protocol for the return of the covers (I assume that there is no single standard at all), so I decided to go from the opposite - to see how this is done for big guys, in particular for the same XiiaLive. Armed with Packet Capture on Android, we catch packages and see where the application goes and why:

GET /songart.php?partner_token=7144969234&title=Umbrella&artist=The+Baseballs&res=hi HTTP/1.1 User-Agent: Dalvik/1.6.0 (Linux; U; Android 4.4.2; QuadCore-R16 Build/KVT49L) Host: api.dar.fm Connection: Keep-Alive Accept-Encoding: gzip HTTP/1.1 200 OK Server: Apache/2.2.15 (CentOS) X-Powered-By: PHP/5.3.3 Set-Cookie: PHPSESSID=u5sgs13h1315k9184nvvutaf33; expires=Fri, 03-Aug-2018 18:39:08 GMT; path=/; domain=.dar.fm Expires: Thu, 19 Nov 1981 08:52:00 GMT Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0 Pragma: no-cache X-Train: wreck="mofas16" Content-Type: application/xml; charset=UTF-8 Content-Length: 57 Accept-Ranges: bytes Date: Thu, 03 Aug 2017 18:39:08 GMT Via: 1.1 varnish Age: 0 Connection: keep-alive X-Served-By: cache-ams4143-AMS X-Cache: MISS X-Cache-Hits: 0 X-Timer: S1501785548.973935,VS0,VE390 

It turned out that a normal GET request with four variables is being sent:


In response to a request, the application expects the following xml in response:

 <?xml version="1.0" encoding="UTF-8" ?> <songs> <song> <arturl>http://coverartarchive.org/release/c8b16143-e87e-440d-bbb2-5c96615bed2b/2098621288-500.jpg </arturl> <artist>The Baseballs</artist> <title>Tik Tok</title> <album></album> <size>1080</size> </song> </songs> 

And the next request, the application is expected to go to the statics server behind the picture. If the ā€œArtistā€ + ā€œTrack Nameā€ combination didn’t find anything, then an empty xml is returned:

 <?xml version="1.0" encoding="UTF-8" ?> <songs> </songs> 

Design


Okay, the input and output parameters of the black box are defined, it remains to build the logic of its work. And most importantly - decide where we will get the cover for the requested track.

To make a separate database of cover images, somehow link it with the tracks, keep it up to date - I'm lazy. Yes, and you should not multiply entities, create any extra bases and links, because the ID3v2 mp3 tags format supports storing covers in mp3 files for many years, so we’ll go inside the file behind the cover (if it’s there, of course, there is). And if the file is not found (or there is no cover in it), then instead of an empty xml, we'd rather give one of the default covers for the radio station, so that the user does not look in the empty square.

In general, I prefer to script everything and do less work with my hands. For example, how now adding a file to the rotation looks like: threw the file via ftp / scp into the inbox directory and forgot. A minute later, the maintenance script arrived, found the file, renamed it as needed, and put it in the radio station directory. And once every 10 minutes, LiquidSoap will reread the directory, find a new file and add it to the playlist. A request for a cover will come - the script will find the file and extract the cover from it.

A good system administrator even automatically Sysadmin Day.
By cron

The truth in the process of implementation and testing of the logic is somewhat complicated. After all, there is often still a cover.jpg in the album catalog (for performers who are present in rotation in whole albums). And there are still numerous performers from SoundCloud / PromoDJ and simply from vk who rarely collect tracks into albums, or even care about the issue of the cover for the track. For these artists (there are not so many of them), we will create a separate directory on the static server with default covers by the artist’s name.

Last question: how to find the corresponding requested tags file on the disk, given that at the time of the start of the search, we only have the name of the artist and the name of the track? You can store information somewhere in the database using the keys ā€œartist, track -> file on diskā€, you can go through the files, look at them by comparing the mp3 tags with the query (but this is a long time), but you can simply follow the principle of not multiplying the entities store files on disk with names like "% artist% -% title% .mp3". I have done just that. Once for this, I used the best, in my opinion, for these purposes, the program TagScanner from Sergey Serkov, and then switched to a python script that automatically renames files in the desired format.

The final logic of the work was as follows:


And now, when the logic of work is defined, it remains only to formulate it in the form of functions.

Code


To extract covers from mp3 files, use the mutagen module. The function that extracts covers from mp3 files and writes them in .jpg:

 import mutagen.mp3 def extract_cover(local_file, covers_dir, cover_name): """ Extracts cover art from mp3 file :param local_file: file name (with path) :param covers_dir: path to store cover art files :param cover_name: name for extracted cover art file :rtype: bool :return: False - file not found or contains no cover art True - all ok, cover extracted """ try: tags = mutagen.mp3.Open(local_file) data = "" for i in tags: if i.startswith("APIC"): data = tags[i].data break if not data: return False else: with open(covers_dir + cover_name, "w") as cover: cover.write(data) return True except: logging.error('extract_cover: File \"%s\" not found in %s', local_file, covers_dir) return False 

If there is a cover in the file and we have successfully extracted it - we make a resize for the required dimensions while preserving the proportions of the picture (for standard square covers are not always in the file). Python Imaging Library (PIL), which is also capable of antialias:

 from PIL import Image def resize_image(image_file, new_size): """ Resizes image keeping aspect ratio :param image_file: file name (with full path) :param new_size: new file max size :rtype bool :return: False - resize unsuccessful or file not found True - otherwise """ try: img = Image.open(image_file) except: return False if max(img.size) != new_size: k = float(new_size) / float(max(img.size)) new_img = img.resize(tuple([int(k * x) for x in img.size]), Image.ANTIALIAS) img.close() new_img.save(image_file) return True 

Despite the fact that almost all modern programs are able to customize the size of the cover to fit the screen size, I would highly recommend doing it yourself, on the server side.

In my practice, there was a case when a half of a 15 megabyte .mp3 file (7.62 mb) was occupied by a 3508x3508 cover with a non-standard color profile. This file tightly hung TagScanner program, which I use to edit tags. I do not know how much this file would be sent via 3G connection, and what would happen with Android when I tried to adjust it to the screen size.

Since XiiaLive has no settings for selecting the cover server, it was necessary to change the address of api.dar.fm to which it refers to its own. On Android, it's simple:

 /etc/hosts <my_api_ip> api.dar.fm 

And we explain to Nginx that all incoming requests, regardless of where they came from or what they want, are served by our script. At the same time, we are raising a virtual host for statics, from where pictures will be given. Of course, you can do everything within a single host, but still it is better to fly api separately, and burgers static - separately.

 upstream fcgiwrap_factory { server unix:/run/fcgiwrap.socket; keepalive 32; } server { listen 80; server_name api.<yourserver> api.dar.fm; root /var/wwws/<yourserver>/api; access_log /var/log/nginx/api.access.log main; error_log /var/log/nginx/api.error.log; location / { try_files $uri /api.py?$args; } location ~ api.py { fastcgi_pass fcgiwrap_factory; include /etc/nginx/fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } } server { listen 80; server_name static.<yourserver> root /var/wwws/<yourserver>/static; access_log /var/log/nginx/static.access.log main; error_log /var/log/nginx/static.error.log; index index.html; location / { } } 

After fixing bugs and finishing thin spots, everything worked. Music plays, pictures are extracted from mp3 files and added to the host directory with statics for upload via the web. In theory, after a while, all the covers will move from the depths of mp3 files to the static directory, but, firstly, the process of extracting the cover takes an average of 100 ms, and secondly, the place on the hosting is not rubber, so the pictures will that time is deleted by the simplest one-liner on the bash, which hangs itself in the crown and deletes files that were accessed more than a week ago:

 find /var/wwws/<yourserver>/static/covers/ -maxdepth 1 -type f -iname '*.jpg' -atime +7 -exec rm {} \; 

Of course, for this to work, noatime should not be installed on the music section.

image
Well, it all worked, as it should work.

Revision


A week later, I analyzed the server logs and found something interesting: right after launch, the application sends a request of the form:

 GET /songart.php?partner_token=7144969234&res=hi HTTP/1.1" 200 334 "-" "Dalvik/1.6.0 (Linux; U; Android 4.4.2; QuadCore-R16 Build/KVT49L) 

And only some time later:

 GET /songart.php?partner_token=7144969234&title=Summer+Nights&artist=John+Travolta+and+Olivia+Newton-John&res=hi HTTP/1.1" 200 334 "-" "Dalvik/1.6.0 (Linux; U; Android 4.4.2; QuadCore-R16 Build/KVT49L) 

Accordingly, there is no cover on the screen between these two requests, darkness and despondency.

Disorder.

The reason is clear: the application has not yet managed to extract tags from the stream and does not know what is playing, why not help it? Add the first item to another condition in the logic of the program:


But where to get the name of the current track? Do not get the same server logs. It is very successful that Icecast can give the state of the mounted points in XML or JSON format. JSON for Python is more native, so we will use it. Since in Icecast-KH, there are no such statistics out of the box, we will use the xsl file from the article by the respected namikiri , which is insensitively modified by me:

 <xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0"> <xsl:output omit-xml-declaration="yes" method="text" doctype-public="-//W3C//DTD XHTML 1.0 Strict//EN" doctype-system="http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd" indent="no" encoding="UTF-8" media-type="application/json; charset=utf-8"/> <xsl:strip-space elements="*"/> <xsl:template match="/icestats"> { <xsl:for-each select="source"> "<xsl:value-of select="@mount"/>": { "name" : "<xsl:value-of select="server_name"/>", "listeners" : "<xsl:value-of select="listeners"/>", "listener_peak" : "<xsl:value-of select="listener_peak"/>", "description" : "<xsl:value-of select="server_description"/>", "title" : "<xsl:value-of select="title"/>", "genre" : "<xsl:value-of select="genre"/>", "url" : "<xsl:value-of select="server_url"/>" } <xsl:if test="position() != last()"> <xsl:text>,</xsl:text> </xsl:if> </xsl:for-each> } </xsl:template> </xsl:stylesheet> 

We put the file in the Icecast-kh web directory (on Ubuntu, the default is / usr / local / share / icecast / web /), and when accessing via http, we get something like this in reply:

 { "/256": { "name" : "Radio /256kbps", "listeners" : "2", "listener_peak" : "5", "description" : "mp3, 265kbit", "title" : "The Kelly Family - Fell In Love With An Alien", "genre" : "Various", "url" : "" }, "/128": { "name" : "Radio /128kbps", "listeners" : "0", "listener_peak" : "1", "description" : "mp3, 128kbit", "title" : "The Kelly Family - Fell In Love With An Alien", "genre" : "Various", "url" : "" }, "/64": { "name" : "Radio /64kbps", "listeners" : "0", "listener_peak" : "2", "description" : "mp3, 64kbit", "title" : "The Kelly Family - Fell In Love With An Alien", "genre" : "Various", "url" : "" } } 

As you can see, the radio has three mount points (in fact, some more) broadcasting the same stream, but with different quality. Well, then everything is quite simple:

 import urllib2 import json def get_now_playing(stats_url, stats_stream): """ Retruns current playing song - artist and title :param stats_url: url points to icecast stats url (JSON format) :param stats_stream: main stream to get info :return: string "Artist - Title" """ try: stats = json.loads(urllib2.urlopen(stats_url).read()) except: logging.error('get_current_song: Can not open stats url \"%s\"', stats_url) return False if stats_stream not in stats: logging.error('get_current_song: Can not find stream \"%s\" in stats data', stats_stream) return False return stats[stats_stream]['title'].encode("utf-8") 

The function walks to the specified statistics address, and returns the artist and the title of the current composition from the desired stream. The stream comes or in request, or default (from adjustments) undertakes.

Web


Now it's time to go to the site. For online playback, I have long since used a free flash player from uppod in minimalist settings that looks into the / flv stream and displays the track being played when playing. It looks like this:

image

And to display the current track, when the player is minimized or inactive, I, like many others confronted with this problem, until recently used a pad in the form of a .php script on the server that went to Icecast for statistics and returned a string with the name of the track being played. It’s time to get rid of intermediate steps, and I would like to show the covers on the site during online playback, since I now know how to give them away.

The problem is solved in two steps:

Add a custom header to the Nginx configuration for api that allows access to it via jQuery from another host:

 add_header Access-Control-Allow-Origin *; 

And put in the body of the web page of the radio station such a script:

 var now_playing = ''; setInterval(function () { jQuery.ajax( { type: "GET", url: "http://api.<yoursite>/?partner_token=<token>&stream=/<stream>", dataType: "xml", success: xmlParser }) }, 5000); function xmlParser(xml) { var parsedXml = jQuery(xml); var title = parsedXml.find('title').text(); var artist = parsedXml.find('artist').text(); var arturl = parsedXml.find('arturl').text(); var song = artist.concat(" — ").concat(title); if (now_playing !== song) { jQuery('div.now_playing').html(song); jQuery('div.cover_art').html(arturl); now_playing = song; } }; 

As you can see, once every five seconds, the script goes to the same place as the application, authorizes there, gets the .xml file and takes the played track and the link to the cover from it. And if they have changed since the last check, then they are written in the necessary divs of the radio station’s web page for display. Immediately I ask the gentlemen of the front-end developers not to swear at the possible clumsiness of the script - jQuery I see the first (well, well - the second), once in a lifetime. The script may be unsightly, but it works great.

image
Under the player, another div has been added in which the covers dynamically change.

Conclusion


At this all the tasks are solved. Radio broadcasts as many years before, but now also displays the covers of the tracks being played and does it correctly. The little perfectionist inside my head is sleeping, snoring contentedly and not distracting from work.

I understand that the topic described is rather narrow-specific, and may be interesting to a small circle of people, but I think that my experience will still be useful to someone. So the full texts of all the above code, plus examples of Nginx settings and a description of the installation, are available on GitHub .

All music!

Source: https://habr.com/ru/post/338564/


All Articles