Php – How to create dynamic m3u8 by pasting the URL in browser

m3u8php

I want to create a dynamic m3u8 when a PHP script is called. I don't want to save the result m3u8 on server, instead I want to push it to browser so it is downloadable. Could anyone show me how I can achieve this task?

Example of PHP script to be called:

http://www.asite.com/makeM3u8.php?videoId=1234

Downloadable dynamic m3u8 structure:

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=900000
http://someserver/channelNameBandwith900000.m3u8?session=3495732948523984eriuwehiurweirew

Best Solution

You have to decide a number of things before getting to the script:

1.- Where are the .ts and .aac files stored, what is their protection scheme and is PHP able to create a URL that is able to access them?

2.- Where you store the raw m3u8 information (target-duration, extinf and name for each segment). Database is faster than parsing existing files in this case.

3.- If dealing with multibitrate, you need a script that also generates the master m3u8 which points to all the others.

That being said, here is the solution I came up with and have been using for a while without problems. Two things though, I use AWS S3 for storage and convert existing video files with ffmpeg. It is a rather long process - maybe overkill for what you want - but it works.

Part 1.- Encoding the files The system receives MP4 videos and converts them to the requisite formats.

function ffConvert($origin,$basedir,$res) {
switch($res) {
    // SET THE VARIABLES
    case "240p": $size = "426x240"; $vbit = "360k"; $level = "3.0"; $abit = "80k"; break;
    case "480p": $size = "854x480"; $vbit = "784k"; $level = "3.1"; $abit = "128k"; break;
    case "720p": $size = "1280x720"; $vbit = "1648k"; $level = "3.1"; $abit = "192k"; break;
}
// CONVERT THE FILES
exec('/usr/local/bin/ffmpeg -y -async 1 -vsync -1 -analyzeduration 999999999 -i "'.$origin.'" -force_key_frames "expr:gte(t,n_forced*10)" -pix_fmt yuv420p -s '.$size.' -r:v 30 -b:v '.$vbit.' -c:v libx264 -profile:v baseline -level '.$level.' -c:a libfaac -ac 2 -ar 48000 -b:a '.$abit.' -g 90 '.$base.$res.'.mp4');
// VERIFY AND RETURN
if(file_exists($basedir.$res.'.mp4')) {
    return TRUE;
} else {
    return FALSE;
}
}

Part 2.- Segmenting the files The system takes the converted MP4s and segments them.

function ffSegment($basedir,$res) {
// CREATE THE SEGMENTS
mkdir($basedir.$res, 0775);
exec('/usr/local/bin/ffmpeg -y -analyzeduration 999999999 -i "'.$basedir.$res.'.mp4" -codec copy -map 0 -f segment -segment_list "'.$basedir.$res.'/stream.m3u8" -segment_time 10 -segment_list_type m3u8 -bsf:v h264_mp4toannexb "'.$basedir.$res.'/segment%05d.ts"');
if(file_exists($basedir.$res.'/stream.m3u8')) {
    return TRUE;
} else {
    return FALSE;
}
}

Part 3.- Storing the data The system parses the generated m3u8s and stores the relevant information in a database.

Table:

CREATE TABLE IF NOT EXISTS `data_contenido_archivos` (
    `id` bigint(20) unsigned NOT NULL,
    `240p` mediumtext NOT NULL,
    `480p` mediumtext NOT NULL,
    `720p` mediumtext NOT NULL,
    `aac` mediumtext NOT NULL,
    UNIQUE KEY `id` (`id`)
) ENGINE=TokuDB DEFAULT CHARSET=utf8;

Parse function:

function parseHLS($file) {
$return = array();
$i = 0;
$handle = fopen($file, "r");
if($handle) {
    while(($line = fgets($handle)) !== FALSE) {
        if(strpos($line,"#EXTINF") !== FALSE) {
            $return['data'][$i]['inf'] = str_replace(array("#EXTINF:",",","\r","\n"),array("","","",""),$line);
        }
        if(strpos($line,".ts") !== FALSE) {
            $return['data'][$i]['ts'] = str_replace(array(".ts","segment","\r","\n"),array("","","",""),$line);
            $i++;
        }
        if(strpos($line,"TARGETDURATION") !== FALSE) {
            $return['duration'] = str_replace(array("#EXT-X-TARGETDURATION:","\r","\n"),array("","","",""),$line);
        }
    }
    fclose($handle);
} else {
    $retorno = FALSE;
}
return $return;
}

The results from each file are stored in the relative columns for each video size as a json-encoded string, note that I am storing the minimum information possible in order to minimize read times and make the dB as small as possible. In this step speed doesn't really matter since this is done before serving the file.

Part 4.- Serving the file The system reads the database and serves the correct file for each size.

For this I have an m3u8.domain.com set up which sends all *.m3u8 files to the PHP interpreter so I don't bother with renaming, this serves only the following files:

  • crossdomain.xml
  • 240p.m3u8: script for 240p resolution
  • 480p.m3u8: script for 480p resolution
  • 720p.m3u8: script for 720p resolution
  • aac.m3u8: script for audio-only
  • master.m3u8: script for master m3u8

Each one is its own file because some players had problems if the different bandwidth m3u8s all had the same name.

The master.m3u8 does this:

if($sesion !== FALSE) {
$hls = sql("SELECT A.240p,A.480p,A.720p,A.aac,B.duracion FROM data_contenido_archivos A, data_contenido B WHERE A.id = '".limpia($_GET['i'])."' AND B.id = '".limpia($_GET['i'])."'");
if($hls['status'] == "OK") {
    $return = "#EXTM3U\n";
    if($hls['data'][0]['240p'] != "{}") {
        $return .= "#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=440000, RESOLUTION=426x240\n";
        $return .= $domains['m3u8']."/240p.m3u8?i=".$id."&s=".$sesion."\n";
    }
    if($hls['data'][0]['480p'] != "{}") {
        $return .= "#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=912000, RESOLUTION=854x480\n";
        $return .= $domains['m3u8']."/480p.m3u8?i=".$id."&s=".$sesion."\n";
    }
    if($hls['data'][0]['720p'] != "{}") {
        $return .= "#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=1840000, RESOLUTION=1280x720\n";
        $return .= $domains['m3u8']."/720p.m3u8?i=".$id."&s=".$sesion."\n";
    }
    if($hls['data'][0]['aac'] != "{}") {
        $retorno .= "#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=64000\n";
        $retorno .= $domains['m3u8']."/aac.m3u8?i=".$id."&s=".$sesion."\n";
    }
}
}
header("Content-type: application/x-mpegURL");
echo $return;

It, queries the database for all the different resolutions and echos the correct url for each one. If it doesn't exist (set to {} in my scheme), it doesn't get echoed.

Then each res.m3u8 does the following (when called):

if($sesion) {
$hls = sql("SELECT A.240p,B.duracion FROM data_contenido_archivos A, data_contenido B WHERE A.id = '".limpia($_GET['i'])."' AND B.id = '".limpia($_GET['i'])."'");
if($hls['estado'] == "OK") {
    $data = json_decode($hls['data'][0]['240p'],TRUE);
    // INICIAMOS EL ARCHIVO
    $retorno = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-ALLOW-CACHE:YES\n#EXT-X-TARGETDURATION:".$data['duration']."\n";
    // CALCULAMOS EL VENCIMIENTO (1.5x DURACION DEL VIDEO)
    $vence = "+".floor($hls['data'][0]['duracion'] / 60)." minutes";
    foreach($data['data'] as $k=>$v) {
        $retorno .= "#EXTINF:".$v['inf'].",\n";
        $retorno .= S3URL("<BUCKET>",$_GET['i']."/240p/segment".$v['ts'].".ts",$vence)."\n";
    }
    $retorno .= "#EXT-X-ENDLIST\n";
}
}
header("Content-type: application/x-mpegURL");
echo $retorno;

There's a few things happening here so let me explain:

a.- First the script checks for a valid session, if one doesn't exist, it serves up an m3u8 for a small 10 second video saying "you don't have permission to view this".

b.- If the session checks out, it queries the database and gets all the requisite JSONs. It also queries another table where I store the duration of the file in order to populate the TARGETDURATION string and also to calculate the life of the secure S3 URL to generate. I set the lifetime of the URL to 1.5x the length of the video, it works for me, your experience may be different.

c.- It then iterates through each group from the database, echos the EXTINF and generates a secure URL for each one.