Le protocole Shoutcast au scalpel

August 6, 2019

Shoutcast est un protocole de diffusion pour webradio créé par Nullsoft, l'éditeur de Winamp entre autre. Il repose sur des technologies bien établies car au final il s'agit principalement de diffuser un fichier en MP3 en continu à travers le protocole HTTP. Il est donc possible d'écouter simplement une radio diffusé via Shoutcast en copiant son lien dans le navigateur. Nous allons prendre pour exemple ce flux :

http://streaming.radionti.com:80/nti-320.mp3

Alors qu'apporte Shoutcast de plus ? Tout d'abord il standardise des en-têtes HTTP, ce qui est bien pratique car l'on peut écrire un lecteur qui fonctionnera avec plein de flux. Mais regardons de plus prêt ces en-têtes :

$ curl -vvv http://streaming.radionti.com:80/nti-320.mp3
*   Trying 51.15.166.151...
* TCP_NODELAY set
* Connected to streaming.radionti.com (51.15.166.151) port 80 (#0)
> GET /nti-320.mp3 HTTP/1.1
> Host: streaming.radionti.com
> User-Agent: curl/7.64.0
> Accept: */*
> 
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
< Server: Icecast 2.4.2
< Date: Tue, 19 Dec 2017 21:45:23 GMT
< Content-Type: audio/mpeg
< Cache-Control: no-cache
< Expires: Mon, 26 Jul 1997 05:00:00 GMT
< Pragma: no-cache
< Access-Control-Allow-Origin: *
< icy-br:320
< icy-description:NTI - LA NOUVELLE TENDANCE (FRANCE)
< icy-genre:DANCE & EDM
< icy-name:NTI - LA NOUVELLE TENDANCE (FRANCE)
< icy-pub:0
< icy-url:http://www.radionti.com

On reconnait facilement les en-têtes définies par le protocole Shoutcast car elles sont préfixées par icy-. On peut retrouver des informations sur la radio, mais pas le titre de la musique qui est en train d'être joué. Et pour comprendre pourquoi, il faut se plonger un peu dans HTTP.

Le streaming HTTP

On commence rarement par aborder HTTP à travers le streaming. Généralement, on fait une requête HTTP, on attend de recevoir la réponse dans sa totalité, si possible dans une magnifique chaine de caractère, on ferme la connexion HTTP et seulement ensuite on traite l'information.

Oui, mais là on veut diffuser de la musique en continu ! Alors on pourrait envoyer des morceaux de musique de quelques secondes par requete et répéter le processus suivant en boucle. Mais ouvrir et fermer plein de connexion est couteux, peu optimisé et introduirait un décalage peu utile.

Nous allons donc utiliser une seule connexion qui ne se fermera jamais, qui va nous envoyer de la donnée en continue, au fur et à mesure qu'elle arrive. Ce qui veut également dire que l'on devra gérer en même temps la connexion et le player audio, faire un petit peu de chaque.

Mais vu que l'on ouvre la connexion qu'une seule fois, les en-têtes ne seront envoyées qu'une seule fois ! Et une fois que l'on a commencé à transférer des données, impossible d'envoyer de nouvelles en-têtes !

Alors comment fait-on ? Dans les données que le serveur Shoutcast va envoyer, se trouvera un mélange de flux audio et de méta donnée. Ce sera alors au client de séparer les deux et de rédiger le flux audio vers le lecteur audio et les méta données vers l'interface utilisateur.

Icy Metadata

Par défaut et pour des raisons de compatibilité, seul le flux audio est envoyé. Pour obtenir les méta données en plus, il est nécessaire de le demander explicitement au moment où l'on réalise la requête. En échange, le serveur va nous fournir une nouvelle en-tête icy-metaint qui nous informera à quelle fréquence les méta données seront envoyées. Plus exactement, tous les combiens d'octets de musique envoyés se trouveront ces méta données.

Une fois arrivée aux métadonnées, on commence par lire un octet. En le multipliant par 16, on peut en déduire la taille totale des métadonnées à lire. Une fois ces méta données lues, on recommence à compter icy-metaint octets, etc.

Tout ça peut paraitre très abstrait, pourtant avec quelques lignes de python et des streams on peut s'en sortir sans trop de mal !

Python, Streams et découpage de webradios

Nous allons commencer avec ce squelette qui nous est un peu imposé par asyncio:

import asyncio

async def icy():
  pass

asyncio.run(icy())

Nous n'utiliserons pas de bibliothèque HTTP mais une simple connexion TCP pour bien comprendre comment ça se passe dans les niveaux en dessous. Nous utiliserons également les objets Streams de Python qui semblent appropriés pour résoudre notre problème.

Nous allons commencer par nous connecter au serveur et lui demander le stream qui nous intéresse :

async def icy():
  reader, writer = await asyncio.open_connection('streaming.radionti.com', 80)

  writer.write(b"""GET /nti-320.mp3 HTTP/1.0
Host: streaming.radionti.com
User-Agent: Icy-Test
Accept: */*
Icy-Metadata: 1

""")

On commence par ouvrire une connexion TCP vers l'URL et le port du serveur. Ensuite, on utilise l'objet writer pour envoyer notre requête HTTP. Pour rappel, HTTP est un protocole texte. La première ligne permet d'indiquer le verbe HTTP, la page ainsi que la version du protocole. Les lignes suivantes sont des en-tête au format clé valeur, une par ligne, séparées par deux points.

Nous avons justement fait attention à préciser l'entête Icy-Metadata: 1 pour demander un flux audio mélangé avec des métadonnées (sinon, nous n'aurions eu que le flux audio sans les métadonnées !).

La ligne vide indique la fin de l'envoie des en-têtes. Puisque nous envoyons une requête GET, nous n'avons pas de données à envoyer. Le serveur sait alors qu'il peut commencer à générer la réponse.

Nous allons donc pouvoir nous préparer à analyser la réponse que le serveur va nous faire. Mais permettons-nous d'écrire une petite fonction utilitaire pour extraire les en-têtes renvoyées par le serveur :

async def readHeaders(reader):
  status_code = await reader.readline()
  assert (status_code, b'HTTP/1.0 200 OK\r\n')

  headers = {}
  while True:
    data = await reader.readline()
    if data == b'\r\n':
      print("End of metadata part, all key/value headers have been read")
      return headers
    header_name, header_value = data.split(b':', 1)
    headers[header_name] = header_value 

Tout d'abord, la première ligne est un peu particulière. On vérifie que le protocole correspond, que le code de status est bien 200 (qui veut dire OK, tout s'est bien passé). Ensuite, nous récuperons les en-têtes envoyées par le serveur sous le même format que celles que nous avons envoyées ! Une ligne vide indique également la fin des en-têtes, et dans notre cas le début du contenu que nous avons demandé !

Armés de cette fonction, complétons notre fonction icy pour les récupérer, et surtout récupérer l'en-tête qui nous intéresse, icy-metaint qui nous indiquera comment découper notre flux !

async def icy():
  # ...

  headers = await readHeaders(reader)
  metaint = int(headers[b'icy-metaint'])

Nous avons donc stocké dans la variable metaint le nombre d'octets d'audio à lire dans le flux envoyé par notre serveur Shoutcast.

Maintenant que nous avons toutes les informations dont nous avons besoin, récupérons ce flux et découpons le !

async def icy():
  # ...

  while True:
    audio = await reader.readexactly(metaint)
    metadata_size_raw = await reader.readexactly(1)
    metadata_size_bytes = \
      16 * int.from_bytes(metadata_size_raw, "big")
    metadata_content = \
      await reader.readexactly(metadata_size)
    if metadata_size > 0: 
      print(metadata_content)

Nous y voilà ! On récupère exactement le nombre de bytes indiqués par l'en-tête. Puis nous lisons un octet, qui va nous permettre de calculer la taille des métadonnées, nous allons lire exactement ce nombre d'octets. Les méta données sont du simple texte, donc on les affiche. Ici, on ne fait rien avec l'audio, mais il faudrait écrire le contenu de audio dans notre lecteur. Ce dernier fournirait probablement un Stream dans lequel on pourrait écrire le contenu de la variable. Il suffit ensuite de répéter cette action en boucle.

Attention ! Les métadonnées seront la plupart du temps vides. En effet, pas besoin de renvoyer le titre de la chanson quand il n'a pas changé. Donc il sera envoyé uniquement au lancement du stream puis à chaque changement de chanson. Le reste du temps, les métadonnées indiqueront que leur taille est de zéro.

Et voilà la sortie de notre application :

$ python3 /tmp/icy.py 
End of metadata part, all key/value headers have been read
b"StreamTitle='SOPHIE ELLIS BEXTOR - MURDER ON THE DANCEFLOOR - 2002';\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
b"StreamTitle='QUINTINO - CAN'T BRING ME DOWN - 2019';\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"

Le code complet de l'exemple se trouve dans ce fichier icy.py.