Magnétoscope minimaliste pour FreeBox
par , le lundi 4 juin 2018 à 00:00

Catégorie : Général
Mots clés : FreeBox PVR

Ayant une FreeBox depuis pas mal d'années mais pas de poste de télévision, je regarde parfois des émissions par le flux IP de la FreeBox sous VLC. J'ai développé un PVR (personnal video recorder) minimaliste, sous Linux, qui tient en quelques scripts Python et PHP.


Abonné à internet chez Free depuis la FreeBox 2, et n'ayant pas de poste de télévision, je regarde parfois des émissions grâce au flux IP TV sur le réseau (n'ayant pas de prise antenne dans mon logement, les chaînes du groupe TF1 et M6 ne sont pas utilisables; pas grave).

Par différentes étapes, j'ai développé un PVR minimaliste ne nécessitant pas d'installer un système complexe. Il tourne sous GNU-Debian-Linux avec une interface web très simple, ne nécessitant qu'un navigateur web et VLC coté poste client.

Flux TV

Accès en HTTP multiposte

De manière simple, VLC suffit pour voir les chaînes de la FreeBox. Il faut ouvrir un flux réseau avec l'adresse RTSP que l'on retrouve dans la playlist de la FreeBox TV; exemple pour France 5 en HD : rtsp://mafreebox.freebox.fr/fbxtv_pub/stream?namespace=1&service=203&flavour=hd.

Il existe même des plugins VLC pour simplifier l'accès à cette liste.

Pour commencer, j'ai adapté le script Pyhton d'AlexSoloex Puyb qui fait office de proxy RTSP/RTP/RTCP vers HTTP et permet donc d'avoir un multiposte accessible depuis tout lecteur vidéo supportant le streaming à travers HTTP comme, par exemple, VLC sous Linux, Windows et Android.

Ce script implémente un serveur HTTP qui doit recevoir l'URL de la FreeBox et lance la requête RTSP puis encapsule les trames RTP dans la réponse HTTP. Ce serveur gère le protocol RTCP pour maintenir la diffusion du flux TV. Il peut gérer 20 diffusions en parallèle (simple limite qui peut être augmentée dans le script).

/usr/local/bin/rtsp2http.py

#!/usr/bin/python
# -*- coding: utf-8 -*-
# Proxy RTSP vers HTTP v0.0.5
# (c) Puyb, AlexSoloex 2006, http://www.puyb.net
#
# Ce programme est distribue sous les termes la licence GPL v2 de la Free Software Fondation
# This software is distributed under the terms of the Free Software Fondation's  GPL v2 licence
# Vous pouvez obtenir une copie de la licence a l'adresse :
# You can find a copy of the licence at :
# http://www.puyb.net/download/LICENCE-GPL2.txt
#
# changelog :
# v0.0.5 - Ajout du Receiver Aknowledgment RTCP pour eviter la coupure du flux RTP, reduction des appels à gethostbyname, info de debuggage (detection des rupture de séquence RTP)
#          Par Antoine Emerit
# v0.0.4 - Meilleur gestion du protocoles RTSP... Envois du paquet de reponse pour eviter les deconnexions
#          Par AlexSolex
# v0.0.3 - reconnexion automatique du flux rtsp
# v0.0.2 - correction de la taille des paquets UDP lus
#        - suppression de l'entete de 12 octets avant envoi du paquet en HTTP
# v0.0.1 - version initiale

import socket
import sys
import thread
import time
import struct
import traceback
import random

_version="0.0.5"
_server_string="rtsp2http proxy %s" % _version

#-------------------------------------------------------------------------------------------------
# une petire classe de serveur qui me simplifie un petit peu le travail
class server_socket(socket.socket):
   def __init__(self, arg):
      if not type(arg)==tuple:
     self.socket=arg
      else:
     self.socket=socket.socket(socket.AF_INET, socket.SOCK_STREAM)
     self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
     self.socket.bind(arg)
     self.socket.listen(1)

   def accept(self):
      s, addr=self.socket.accept()
      return server_socket(s), addr

   def readline(self):
      line=""
      while 1:
     char = self.recv(1)
     if not char:
        if line=="":
           return False
        return line
     if char=="\n":
        return line
     if char!="\r":
        line += char

   def __getattr__(self, name):
      return getattr(self.socket, name)

# une petite classe de client.. la meme que pour un serveur, sauf l'initialisation
class client_socket(server_socket):
   def __init__(self, ip, port):
      self.socket=socket.socket(socket.AF_INET, socket.SOCK_STREAM)
      self.socket.connect((ip,port))

# une classe qui essaye de comprendre les reponses au requettes RTSP
class basic_rtsp_socket(client_socket):
   def __init__(self, ip, port):
      client_socket.__init__(self, ip, port)
      self.cseq=1
      self.RTCP_port=None

   def request(self, request):
      print '>>>\n', request % self.cseq
      self.send(request % self.cseq)
      self.cseq+=1
      print "<<<\n"
      l=self.readline()
      print l
      (proto, code)=l.split(" ")[0:2]
      if proto[0:4]!="RTSP": 
     raise Exception("Error: not a rtsp response", l)
      if code!="200": 
     raise Exception("Error: rtsp error", l)

      l=self.readline()
      print l
      header={}
      while l!="" and l!=None:
     l2=l.split(": ")
     if len(l2)!=2: 
        raise Exception("Incorrect response", l)
     #print "   %s : %s"%(l2[0],l2[1])
     header[l2[0]]=l2[1]
     if l2[0]=="Transport":
        prm_transport=l2[1].split(";")
        for prms in prm_transport:player
           try:
              #print prms
              key,val=prms.split("=")
              if key=="server_port":
                 self.RTCP_port=int(val.split("-")[1])
                 break
           except:
              pass

     l=self.readline()
     print l
      if l==None: raise Exception("rtsp connection closed by foreign host", None)
      data=None
      print "---"
      if "Content-length" in header.keys():
     data=read(int(header["Content-length"]))
      return (header, data)

def char2bits(txt):
   bits = []
   for char in txt:
      if type(char) == str:char = ord(char)
      for i in range(8):
     j = 2**(7-i)
     bits.append(int(char - j >= 0))
     char = char % j
   print len(bits)
   print bits

def bits2char(bits):
   char = 0
   for b in range(8):
      if bits[7-b] == 1:
     char += 2**b
   return char

#-------------------------------------------------------------------------------------------------
def httpthread(http_conn, addr):
   data = http_conn.readline()
   print '------------------'
   print 'HTTP request from client'
   print '<<< ', data
   if not data:
      raise Exception("no data on http line", None)
   # on prend la première ligne
   (request, url, proto)=data.split(" ")[0:3]
   if request!="GET":
      print "ATTENTION : le player n'a pas envoyé de GET"
   if proto[0:4]!="HTTP":
      raise Exception("Error : not a http request", data)
   # on attend la fin de la requette
   # soit une ligne vide
   # un peu barbare... mais je suis sur qu'il n'y a rien à dire d'interressant
   l=http_conn.readline()
   print "<<< %s" %l
   while l!="" and l!=None:
      l=http_conn.readline()
      print "<<< %s" % l
      # parle a ma main !!!
   if l==None: raise Exception("http connection closed", None)

   # on repond le header
   reponse="""HTTP/1.0 200 OK
Content-type: video/mp4
Cache-Control: no-cache

"""
   print '>>> ', reponse
   http_conn.send(reponse)player
   print '------------------'

   reconnect=True
   ####
   ####

   while reconnect:
      # on se prepare a recevoir les connexion UDP
      rtsp_data = socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
      rtsp_data.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 100*1024*1024) # buffer de 100Mo, on devrait etre tranquille y compris en HD
      # preparation du control de flux
      rtsp_control = socket.socket(socket.AF_INET,socket.SOCK_DGRAM)

      print 'Search for two free UDP port (RTP-RTCP)...'
      # on recherce un port libre entre 31330 et 31350 (port pair pour RTP et impair pour RTCP)player
      for rtp_port in range(31330, 31360, 2):
     try:
        print ("Try port %d" % rtp_port)
        rtsp_data.bind(("", rtp_port))
        rtsp_control.bind(("",rtp_port+1))
        print ("Bindind ports RTP-RTCP = %d-%d" % (rtp_port, rtp_port+1))
        break;
     except:
        e = sys.exc_info()[0]
        print e
        print traceback.format_exc()

        rtsp_data.settimeout(1)
      print '------------------'

      print "Starting RTSP connection at %f" % time.clock()
      # on etablie la connexion rtsp
      rtsp_session=basic_rtsp_socket("mafreebox.freebox.fr", 554)

      (header, data)=rtsp_session.request("""SETUP rtsp://212.27.38.253%s RTSP/1.0
CSeq: %s
Transport: RTP/AVP;unicast;client_port=%d-%d
User-Agent: %s

""" % (url, "%d", rtp_port, rtp_port+1,_version))

      session=header["Session"]
      rtsp_control.connect(("mafreebox.freebox.fr",rtsp_session.RTCP_port))

      (header, data)=rtsp_session.request("""PLAY rtsp://mafreebox.freebox.fr%s RTSP/1.0
CSeq: %s
Session: %s
Range: npt="now"
User-Agent: %s

""" % (url, "%d", session, _version))

      # on repete tout au client http
      # on met des datas en memoire
      ok=True
      t=time.time()
      sequence_old=-1
      cycle=0player
      sender_id=random.randint(0,16000000)
      source_id=0
      mafreebox_addr=socket.gethostbyname("mafreebox.freebox.fr")

      print "Start playing..."

      while ok:
     if time.time()-t>5:
        sheader = struct.pack("!BBHLLLHHLLL", 
                                  #       +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
        0b10000001,201,7,         #header |V=2|P|    RC   |   PT=SR=200   |             length            |
                                  #       +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
        sender_id,                #       |                         SSRC of sender                        |
                                  #       +=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+
        source_id,                #report |                 SSRC_1 (SSRC of first source)                 |
                                  #block  +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
        0x00ffffff,               #       | fraction lost |       cumulative number of packets lost       |
                                  #       +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
        cycle,sequence_old,       #       |           extended highest sequence number received           |
                                  #       +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
        0,                        #       |                      interarrival jitter                      |
                                  #       +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
        0,                        #       |                         last SR (LSR)                         |
                                  #       +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
        0                         #       |                   delay since last SR (DLSR)                  |
                                  #       +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
        )

        rtsp_control.send(sheader)
        t=time.time()
        # on lit 2k... Si le datagram est plus petit, on aura moins de donnees (FIXME : lire la MTU d'un paquet UDP)
     try:
        data, addr=rtsp_data.recvfrom(2048)
     except Exception, err:
        e = sys.exc_info()[0]
        print e
        print traceback.format_exc()
        ok=False
        print "RTSP TimeOut : retrying"

     if addr[0]==mafreebox_addr:
        try:
           sequence,timestamp,source_id = struct.unpack_from('!HLL', data[:12], 2)
           if sequence_old >=0 and sequence != sequence_old + 1:
              print("%d -> %d" % (sequence_old,sequence))
           if sequence_old >= 0 and sequence <= sequence_old:
              if sequence_old > 65000 and sequence < 10:
                 cycle += 1 
              else:
                 print("%d -> %d : time travel !" % (sequence_old,sequence))
           sequence_old = sequence
           http_conn.sendall(data[12:])
           # on envoi la paylaod... C'est a dire, le datagram moins les 12 octets d'entete
        except Exception, err:
           print traceback.format_exc()
           ok=False # une exception... on arrete tout !
           print "exception lors de l'envoi des données en http"
           reconnect=False
     else:
        print "not mafreebox.freebox.fr"

     # une des connexion a ete coupee, on ferme toutes les connexions...
      try:player
     print rtsp_session.request("""TEARDOWN rtsp://mafreebox.freebox.fr%s RTSP/1.0
CSeq: %s
Session: %s
User-Agent: rtsp2http v0.0.1

""" % (url, "%d", session))
     print "HEADER TEARDOWN: \n%s"%header
      except:
     print "Exception on TEARDOWN, skiping"
     print "%s"%url
     rtsp_session.close()
     rtsp_control.close()
     rtsp_data.close()

   http_listen.close()
   http_conn.close()
   print "%s disconnected" % (addr, )

#-------------------------------------------------------------------------------------------------
# Début du code

# on met en place un serveur http
print "================================================================="
print "Start server on port 8090"
print "================================================================="

http_listen=server_socket(("", 8090))
while True:
   http_conn, addr = http_listen.accept()

   print '---------------------------------------------------------'
   print 'Client connection from IP: ', addr
   print '---------------------------------------------------------'

   thread.start_new_thread(httpthread, (http_conn, addr))

Pour lancer ce serveur en tâche de fond, j'ai écrit un wrappeur d'auto-restart et un script de démarrage système utilisant la commande screen :

/usr/local/bin/rtsp2http.sh

#!/bin/sh

while true; do
   /usr/local/bin/rtsp2http.py
done 


/etc/init.d/tv-toto.sh 
#!/bin/sh

### BEGIN INIT INFO
# Provides:          tvtoto
# Required-Start:    $local_fs $remote_fs $network
# Required-Stop:     $local_fs $remote_fs $network
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# X-Interactive:     true
# Short-Description: toto TV
# Description:       Start the toto TV 
### END INIT INFO

if [ "$1" = "start" ]; then
    screen -d -m -S tv_toto /usr/local/bin/rtsp2http.sh
else
    pkill -f tv_toto
fi

En lançant ce serveur par /etc/init.d/tv-toto.sh vous pouvez regarder une chaîne de TV en ouvant une URL de la forme http://tv.toto.com:8090/fbxtv_pub/stream?namespace=1&service=203&flavour=hd sous VLC (Ouvrir flux réseau).

Serveur web

J'ai configuré un host virtuel sous Apache pour accèder au serveur à travers le port 80 standard du HTTP. Vous pouvez éventuellement rendre ce serveur accessible de l'extérieur mais attention à la sécurité.

/etc/apache2/sites-enabled/tv.toto.com.conf

#-----------------------------------------------------------------
# Host virtuel
#-----------------------------------------------------------------
<VirtualHost *:80>
    ServerName tv.toto.com
    ServerAdmin webmaster@toto.com

    DocumentRoot /home/public/www/tv.toto.com/htdocs

    <Directory /home/public/www/tv.toto.com/htdocs>
        Require all granted
        Order deny,allow
        Allow from all
    </Directory>

    ErrorLog /var/log/apache2/tv.toto.com-error.log
    CustomLog /var/log/apache2/tv.toto.com-access.log vhost_combined

    DirectoryIndex index.php tv.html

    ProxyPass /fbxtv_pub/ http://tv.toto.com:8090/fbxtv_pub/

    BrowserMatch "MSIE [2-6]" \
            nokeepalive ssl-unclean-shutdown \
            downgrade-1.0 force-response-1.0  
    BrowserMatch "MSIE [17-9]" ssl-unclean-shutdown   
</VirtualHost>

Le host tv.toto.com et le chemin /home/public/www/tv.toto.com/htdocs doivent être adaptés à votre serveur. Ne pas oublier d'activer ce virtualhost sous Apache ( a2ensite tv.toto.com et service apache2 restart ).

Ce serveur web vListe des chaînes et playlist pour lecteur vidéoa aussi servir pour la partie programmation (ci-dessous).

Tout le contenu du dossier htdocs du host virtuel Apache est disponible dans une archive (il y a aussi un fichier pour la crontab). Inutile donc de recopier les scripts qui suivent, il suffit de décompresser le contenu de l'archive à la racine du site web (retirer ou fusionner les dossiers htdocs).

Liste des chaînes et playlist pour lecteur vidéo

Sur ce serveur nous allons pouvoir mettre une petite page PHP qui va nous fournir la liste des chaînes avec génération de playlist pour Windows media Player et VLC (ou autre lecteur compatible .m3u/.asx) :

/home/public/www/tv.toto.com/htdocs/chaines.php

<?php 

$main_channels = array("France 2", "France 3", "France 4", "France 5", "France Ô", "Arte", "RMC Découverte", "TMC", "Numéro 23", "La Chaîne Parlementaire", "NRJ 12", "C8", "BFM TV", "franceinfo", "Chérie 25", "L'Equipe", "CNews", "CStar");

#-----------------------------------
if (!empty($_SERVER['SERVER_NAME']))
   $base_url = ($_SERVER['HTTPS'] == 'on' ? 'https://' : 'http://') . $_SERVER['SERVER_NAME'];

$base_dir = dirname(__FILE__);

$channels = file_get_contents("$base_dir/playlist.m3u");
preg_match_all('|^#EXTINF:[0-9]+,([0-9]+) - (.*)$.*^(rtsp://mafreebox.freebox.fr/(.*))$|smU', $channels, $match);

if (!isset($_REQUEST['channel'])) {
   header('Content-type: text/html; charset=UTF-8');

   print "<p><a href='index.php'>Menu</a> <a href='chaines.php'>Cha&icirc;nes principales</a> <a href='chaines.php?all'>Toutes les cha&icirc;nes</a></p>";

   if (!isset($_REQUEST['all'])) {
      $match[2] = array_filter($match[2], function($v) {global $main_channels; foreach ($main_channels as $c) {if (strpos($v, $c) !== FALSE) return true;} return false;});
   }

   foreach ($match[2] as $key => $chan) {
      $chan = trim($chan);
      $lien = $match[3][$key];
      $url = $match[4][$key];
      $canal = $match[1][$key];

      print "<a href='$url'><img src='images/download.gif' border=0></a>";
      print "<a href='$lien'><img src='images/vlc.gif' border=0></a>";
      print "&nbsp;$canal - <a href='chaines.php?channel=$key&type=.m3u'>$chan</a><br>\n";
   }
} else if (isset($_REQUEST['embeded'])) {
   $name = $match[2][$_REQUEST['channel']];
   $url = "$base_url/" . $match[4][$_REQUEST['channel']];

   header("Content-Type: video/x-mpegURL");
    echo <<< EOF
<?xml version="1.0" encoding="UTF-8"?>
<playlist version="1" xmlns="http://xspf.org/ns/0/">
    <trackList>
        <track>
            <title>&#1050;&#Liste des chaînes et playlist pour lecteur vidéo1072;&#1085;&#1072;&#1083; 1</title>
            <location>$base_url/fbxtv_pub/stream?namespace=1&service=203&flavour=sd</location>
        </track>
    </trackList>
</playlist>
EOF;

/*
   echo <<< EOF
<html>
   <head>
   <title>$name</title>
   <link href="http://vjs.zencdn.net/4.8/video-js.css" rel="stylesheet">
   <script src="http://vjs.zencdn.net/4.8/video.js"></script>
   </head>

   <body>

     <video id="MY_VIDEO" class="video-js vjs-default-skin" controls
     preload="no" width="640" height="264"
     data-setup="{}">
     <source src="$url" type='video/mp4'>
     <p class="vjs-no-js">To view this video please enable JavaScript, and consider upgrading to a web browser that <a href="http://videojs.com/html5-video-support/" target="_blank">supports HTML5 video</a></p>
      </video>

   </body>

   </html>
EOF;
*/

} else {
   header("Content-Type: audio/x-mpegurl");
   header("Content-Disposition: inline; filename=\"tv.m3u\"");

   print "#EXTM3U\n";
   print "#EXTINF:0," . $match[2][$_REQUEST[channel]] . "\n";
   print "$base_url/" . $match[4][$_REQUEST['channel']] . "\n";
}

?>

Le fichier contenant la playlist FreeBox /home/public/www/tv.toto.com/htdocs/playlist.m3u sera mis à jour une fois par jour grace à la crontab :

/home/public/www/www.toto.com/htdocs/blog/main/edit# cat /etc/cron.d/tv :

0 5 * * 0 root curl --silent --output /home/public/www/tv.toto.com/htdocs/playlist.m3u --location http://mafreebox.freebox.fr/freeboxtv/playlist.m3u && php /home/public/www/tv.toto.com/htdocs/chaines.php > /home/public/www/tv.toto.com/htdocs/chaines.html

Voilà, il n'y a plus qu'à ouvrir l'URL http://tv.toto.com/chaines.html pour avoir une liste clickable des flux TV :

Liste des chaîne TV

Si vous regardez bien le fichier chaines.php vous verrez qu'il y a une liste de chaînes principales, à vous de la personaliser, mais rappelez-vous que toutes les chaînes ne sont pas accessible par flux IP (groupe TF1, groupe M6, chaînes payantes...).

Enregisteur personnel

Guide des programmes

Pour simplifier la programmation, il nous faut un guide des programmes. Ça tombe bien, il existe la solution compléte XSLTVGrid avec interface HTML/Javascript/XSL et les données des programmes sont gracieusement founies par Telerama à travers XMLTV France.

Aprés quelques modifications, notament pour générer une URL spécifique à notre PVR et retirer les chaînes non accessible en flux IP, nous allons mettre en place le système de programmation.

  • décompressez la version modifiée de XSLTVGrid dans le dossier racine du host virtuel Apache ( dossier défini dans la configuration précédente /home/public/www/tv.toto.com/htdocs/ ). Cela doit créer un sous-dossier guide.
  • récupérer automatiquement la mise-à-jour des programmes par la crontab :

/etc/cron.d/tv

0 5 * * 0 root curl --silent --output /home/public/www/tv.toto.com/htdocs/guide/tv_source.xml http://xmltv.fr/guide/tvguide.xml && /home/public/www/tv.toto.com/htdocs/guide/clean_guide.sh
0 5 * * 0 root curl --silent --output /home/public/www/tv.toto.com/htdocs/playlist.m3u --location http://mafreebox.freebox.fr/freeboxtv/playlist.m3u && php /home/public/www/tv.toto.com/htdocs/chaines.php > /home/public/www/tv.toto.com/htdocs

On ne récupère les programmes qu'une fois par semaine (2 semaines d'avance). Lancez la mises-à-jour à la main puis accéder à la grille des programmes en ouvrant l'URL : http://tv.toto.com/guide/tv.html (note avec la configuration Apache précédente http://tv.toto.com/guide marche aussi).

Guide des programmes

Script de programmation

La programmation sera enregistrée en base de donnée qu'il faut créer au préalable sous MySQL :

--
-- Current Database: `pvr`
--

CREATE DATABASE /*!32312 IF NOT EXISTS*/ `pvr` /*!40100 DEFAULT CHARACTER SET utf8mb4 */;

USE `pvr`;

--
-- Table structure for table `records`
--

DROP TABLE IF EXISTS `records`;
/*!40101 SET @saved_cs_client     = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `records` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `channel` varchar(50) NOT NULL,
  `start` datetime NOT NULL,
  `stop` datetime NOT NULL,
  `title` varchar(255) DEFAULT NULL,
  `launched` tinyint(1) DEFAULT '0',
  PRIMARY KEY (`id`),
  UNIQUE KEY `program` (`channel`,`start`,`stop`),
  KEY `launched` (`launched`,`start`)
) ENGINE=InnoDB AUTO_INCREMENT=536 DEFAULT CHARSET=utf8mb4;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;

/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;


GRANT USAGE ON *.* TO 'pvr'@'localhost' IDENTIFIED BY 'pvr'
GRANT ALL PRIVILEGES ON `pvr`.* TO 'pvr'@'localhost'

Et maintenant, nous avons le script de programmation et affichage des émissions déjà en base :

/home/public/www/tv.toto.com/htdocs/pvr.php

<?php
header("Content-Type: text/html; charset=utf-8");

$base_dir = dirname(__FILE__);
$videos_dir = "$base_dir/_Enregistrements";
$videos_url = '_Enregistrements';

if (!empty($_SERVER['SERVER_NAME']))
   $base_url = ($_SERVER['HTTPS'] == 'on' ? 'https://' : 'http://') . $_SERVER['SERVER_NAME'];

$db = new mysqli('localhost', 'pvr', 'duconlajoie', 'pvr');

//========================================================================================
// Launch records
if (isset($argv) && $argv[1] == 'launch_records') {
   $base_url = $argv[2];

   if ($to_launch = $db->query('select id,channel,start,TIMEDIFF(stop,start) as delay, TIMESTAMPDIFF(SECOND,start,stop) + 600 as length,title,IF(launched=0,\'\',\'X\') as onair from records where launched = false and DATE_SUB(start, INTERVAL 5 MINUTE) <= now()')) {
      $channels_urls = file_get_contents("$base_dir/playlist.m3u"---------------------);
      $url = array();

      print "Records to launch :\n";

      while ($info = $to_launch->fetch_array()) {
     $file_name = escapeshellcmd(str_replace(array(':', '\'', '?', '(', ')', '*', '&'), array('-',' ', ' ', ' ', ' ', ' ', ' '), "Le $info[start] pendant $info[delay] sur $info[channel] - $info[title]"));
     if (preg_match("|^#EXTINF:[0-9]+,[0-9]+ - .*$info[channel].*rtsp://mafreebox.freebox.fr/(fbxtv_pub/stream\?namespace=[0-9]+&service=[0-9]+&flavour=.+)$|misU", $channels_urls, $url)) {
        $cmd = "nohup curl --silent --max-time $info[length] --output '$videos_dir/$file_name.mpg' '$base_url/$url[1]' > /dev/null &";
        print "Start record at $info[start] during $info[delay] on $info[channel] : $info[title]'\n$cmd\n";
        system($cmd);
        print "\n";

        if ($stmt = $db->prepare('update records set launched=true where id = ?'))
           if ($stmt->bind_param('i', $info['id']))
              if ($stmt->execute())
                 print "   Record '$info[id]' updated\n";
              else
                 print "Mysql error : (" . $stmt->errno . ")" . $stmt->error;
                 else
              print "Mysql error : (" . $stmt->errno . ") " . $stmt->error;
              else
           print "Mysql error : (" . $db->errno . ") " . $db->error;
     } else {
        print "No match at $info[start] during $info[length] on $info[channel] : $info[title]'\n";
     }
      }
   } else
      print "Mysql error : (" . $db->errno . ") " . $db->error;     
//========================================================================================
// Add a record in the database
} else if (isset($_REQUEST['cmd']) && $_REQUEST['cmd'] == 'add') {
   $db->set_charset('latin1');

   if ($stmt = $db->prepare("insert into records (channel, start, stop, title, launched) values (?, STR_TO_DATE(?,'%Y%m%d%H%i%s'), STR_TO_DATE(?,'%Y%m%d%H%i%s'), ?, 0)"))
      if ($stmt->bind_param('ssss', $_REQUEST['channel'], $_REQUEST['start'], $_REQUEST['stop'], $_REQUEST['title']))
     if ($stmt->execute())
        print "Record inserted\n";
     else 
       print "Mysql error : (" . $stmt->errno . ") " . $stmt->error;
      else ---------------------
     print "Mysql error : (" . $stmt->errno . ") " . $stmt->error;
   else 
      print "Mysql error : (" . $db->errno . ") " . $db->error;

   $db->set_charset('utf8');
//========================================================================================
// Remove a record from the database
} else if (isset($_REQUEST['cmd']) && $_REQUEST['cmd'] == 'del') {
   if (is_numeric($_REQUEST['id']) && ($record = $db->query("select id,channel,start,timediff(stop,start) as delay,title,if(launched=0,'','X') as onair from records where id = $_REQUEST[id]"))) {
      $info = $record->fetch_array();
      $file_name = str_replace(array(':', '\'', '?', '(', ')', '*', '&'), array('-', ' ', ' ', ' ', ' ', ' ', ' '), "Le $info[start] pendant $info[delay] sur $info[channel] - $info[title]");
   } else
      $file_name = '';

   if ($stmt = $db->prepare('delete from records where id = ?'))
      if ($stmt->bind_param('i', $_REQUEST['id']))
     if ($stmt->execute()) {
        if (!empty($file_name))
           unlink("$videos_dir/$file_name.mpg"); 

        header("Location: pvr.php");
        print "<!-- \n";
        print "Record '$_REQUEST[id]' removed\n";
        print "File '$videos_dir/$file_name.mpg' removed\n";
        print " -->\n";
     } else
        print "Mysql error : (" . $stmt->errno . ") " . $stmt->error;
      else
     print "Mysql error : (" . $stmt->errno . ") " . $stmt->error;
   else
      print "Mysql error : (" . $db->errno . ") " . $db->error;
//========================================================================================
// Video streaming
} else if (isset($_REQUEST['asx'])) {
   $film = "$base_url/$videos_url/" . rawurlencode($_REQUEST['asx']);
   header("Content-type: video/x-ms-asf");
   echo <<< EOF
<asx version="3.0">
   <title>$film</title>
   <entry>
     <title>$film</title>
     <ref href="$film"/>
   </entry>
 </asx>
EOF;

} else if (isset($_REQUEST['m3u'])) {
   $film = "$base_url/$videos_url/" . rawurlencode($_REQUEST['m3u']);
   header("Content-type: application/vnd.apple.mpegurl");
   echo <<< EOF
#EXTM3U
#EXTVLCOPT:freetype-rel-fontsize=16
$film
EOF;
//========================================================================================
// List all records in the database
} else {
   $records = $db->query('set lc_time_names=\'fr_FR\'');
   $records = $db->query('select id,channel,start,date_format(start, \'%W %e %M %Y %H:%i:%s\') as debut, timediff(stop,start) as delay,title,if(launched=0,\'\',\'X\') as onair from records order by start,channel');

   if (!isset($argv)) {
      print "<html>\n<body>\n";
      print "<p><a href='index.php'>Menu</a> <a href='pvr.php'>Rafraichir</a></p>\n";
      print "<table border=1>\n";
      print "<tr><th>D&eacute;but<th>Dur&eacute;e<th>Cha&icirc;ne<th>Titre<th>Enregistr&eacute;<br>(ou en cours)\n";

      while ($info = $records->fetch_array()) {
     $file_name = htmlspecialchars(str_replace(array(':', '\'', '?', '(', ')', '*', '&'), array('-',' ', ' ', ' ', ' ', ' ', ' '), "Le $info[start] pendant $info[delay] sur $info[channel] - $info[title].mpg"));
     print "<tr><td>$info[debut]<td>$info[delay]<td>$info[channel]<td><a href='$videos_url/$file_name'><img src='images/download.gif' border=0 title='T&eacute;l&eacute;charger'></a> <a href='pvr.php?m3u=$file_name'><img src='images/vlc.gif' border=0 title='Voir'></a><a href='pvr.php?m3u=$file_name'>$info[title]</a><td align=center>" . (!empty($info['onair']) ? "<img src='images/ok.png' border=0>" : "") . "<td>" . ( empty($info['onair']) ? "<a href='pvr.php?cmd=del&id=$info[id]'>" : "<a href='pvr.php?cmd=del&id=$info[id]' onclick=\"if (confirm('L\'enregistrement va être supprimé.')) return true; else return false;\">" ) . "<img src='images/delete.png' border=0 title='Supprimer programmation et enregistrement'></a>\n";
     }

      print "</table>\n";

      print "<pre>Enregistrements en cours :\n\n";
      system("pgrep --full --list-full curl"); 
      print "</pre>\n";
      print "</body></html>\n";
      } else {
      while ($info = $records->fetch_array()) {
     print "$info[channel]\t$info[start]\t$info[delay]\t$info[title]\t$info[onair]\n";
      }
      system("ps xwau | grep curl");
   }
}

?>

Ce script est utilisable en ligne de commande et en tant que page PHP. Sans paramètre il affiche les émissions déjà programmées en base avec un status 'enregistré/en cours' ou 'à venir'.

Maintenant que ce script est dans notre host virtuel, on peut accéder à la page web avec l'URL http://tv.toto.com/pvr.php :

Programmes-1

...

Programmes-1

Il est nécessaire de modifier les variables $videos_dir pour définir le dossier qui contiendra les enregistrements vidéos, ce dossier devra être modifiable par l'utilisateur du serveur Apache (www-data sous Debian).

Le bas de la page affiche aussi les processus d'enregistrements en cours.

L'URL d'enregistrement est de la forme http://tv.toto.com/pvr.php?cmd=add&channel=France%204&start=20180604202500%20%2B0200&stop=20180604205000%20%2B0200&title=Une%20saison%20au%20zoo, et elle est appelée depuis le guide des programmes.

Pour que les enregistrement se fassent réellement il faut appeler le script pvr.ph depuis la crontab (toutes les minutes) avec le paramètre launch_records :

/etc/cron.d/tv

* * * * * nobody /usr/bin/php /home/public/www/tv.kozodo.com/htdocs/pvr.php launch_records 'http://tv.toto.com' > /dev/null 2>&1

Page de garde

Enfin pour finir une petit page HTML avec des liens vers les différentes pages utiles :

index.php

<html>
<body>

<p><a href="/chaines.html">Cha&icirc;nes de TV</a></p>
<p><a href="/guide/tv.html">Guide des programmes TV</a></p>
<p><a href="/pvr.php">Programmation</a></p>

</body>
</html>

Améliorations possibles

Ce système est vraiment minimaliste, et il faudrait y appliquer un certain nombre d'évolutions :

  • sécuriser le code : anti SQL/PHP/commande injection ...
  • sécuriser l'accès : gestion d'utilisateur, filtrage par IP ? ...
  • guide des programmes : icônes des chaînes, images des émissions...
  • programmation : par dates saisie manuellement, gestion des séries, automatique par mot-clé/type d'émission/présentateur/..., suppression automatique (durée de rétention, après visualisation, émission équivalente...), alertes par email...
  • interface : aspect/interactivité, adapté au mobile/tablette...

Bonne programmation et bonne visualisation.

Antoine

Ecrire à l'auteur