Skip to content

Instantly share code, notes, and snippets.

@kmark
Last active April 6, 2022 23:46
Show Gist options
  • Star 15 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save kmark/6028758 to your computer and use it in GitHub Desktop.
Save kmark/6028758 to your computer and use it in GitHub Desktop.
The Plex Universal Transcoder Downloader mimics the actions of the Plex/Web media flash player to download transcoded media. The differences begin when the downloader saves the streamed data and pieces it together. First a start.m3u8 playlist file is requested from the server with a query string that defines the transcoding options. Inside the …
<?php
/*******************************************************************************
* Plex Universal Transcoder Downloader v1.3 *
* See --help or --usage for more info *
*******************************************************************************
* Copyright 2013 Kevin Mark *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); *
* you may not use this file except in compliance with the License. *
* You may obtain a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
*******************************************************************************/
/*** CHANGELOG ***
* v1.0
* - First release
* v1.1
* - Correctly handles not-so-nice shutdowns
* v1.2
* - Improved option/getopt handling
* - Added option to force overwrite of output file
* - Added verbose debugging option
* - Added option to specify ffmpeg binary location
* - Added warning on low disk space relative to estimated output file size
* - Added GNU-style long options. If that's your thing.
* - Removed mediainfo call
* - Greatly improved the --help/--usage info
* v1.3
* - Removed file_get_contents errors on shutdown
* - Verbose option now outputs the complete ffmpeg command
*****************/
declare(ticks = 1);
error_reporting(E_ALL ^ E_NOTICE);
register_shutdown_function("shutdown");
pcntl_signal(SIGTERM, "shutdown");
pcntl_signal(SIGHUP, "shutdown");
pcntl_signal(SIGINT, "shutdown"); // Ctrl-C handling
define("SCRIPT_NAME", basename(__FILE__, '.php'));
define("SCRIPT_VERSION", "1.3");
define("SCRIPT_COPYRIGHT", "Copyright (c) 2013 Kevin Mark");
$options = getopt("h:m:q:r:b:o:v::y::f:",
array("host", "mediaId", "quality", "resolution", "bitrate", "verbose", "yes", "output", "ffmpeg", "help", "version", "usage"));
if(isset($options["version"])) {
echo SCRIPT_NAME . " " . SCRIPT_VERSION ." - The Plex Universal Transcoder Downloader\r\n";
echo SCRIPT_COPYRIGHT . "\r\n";
die();
}
if(isset($options["usage"]) || isset($options["help"])) {
echo "Usage: php " . $argv[0] . " [ACTUALLY OPTIONAL OPTIONS] OPTIONS\r\n";
echo <<<EOF
REQUIRED OPTIONS:
-h, --host Hostname/IP w/ port # of Plex/Web formatted like localhost:32400 / 127.0.0.1:32400
-m, --mediaId Last number found in the URL of the Plex/Web media details page
-r, --resolution Transcoded resolution. Eg: 1280x720
-b, --bitrate Maximum bitrate in kilobits per second. Eg: 1500 (that's 1.5 Mbps)
-o, --output Output file. Can be any container format your ffmpeg binary supports.
ACTUALLY OPTIONAL OPTIONS:
-q, --quality Transcoder quality. 0-100. Default 75.
-y, --yes Assume yes to all questions. Will overwrite output file if it already exists.
-v, --verbose Display additional (and sometimes useful) information and statistics.
-f, --ffmpeg Path to ffmpeg binary.
--usage Displays usage information.
--help Same as --usage
--version Displays version information.
EXAMPLE USAGES:
The bare minimum:
php plexDownload.php -h 127.0.0.1:32400 -m 342 -r 720x480 -b 800 -o Community.mp4
Hostname and custom Plex/Web port.
php plexDownload.php -h example.com:80 -m 342 -r 720x480 -b 800 -o Community.mp4
Higher bitrate, maximum quality, 1080p:
php plexDownload.php -h 127.0.0.1:32400 -m 342 -q 100 -r 1920x1080 -b 8000 -o Community.mp4
Verbose logging and force output overwrite:
php plexDownload.php -v -y -h 127.0.0.1:32400 -m 342 -r 720x480 -b 800 -o Community.mp4
Verbose logging and force output overwrite with long options:
php plexDownload.php --verbose --yes -h 127.0.0.1:32400 -m 342 -r 720x480 -b 800 -o Community.mp4
Using custom ffmpeg path:
php plexDownload.php -f /usr/local/bin/ffmpeg -h 127.0.0.1:32400 -m 342 -r 720x480 -b 800 -o Community.mp4
Using an output filename with spaces and other characters:
php plexDownload.php -h 127.0.0.1:32400 -m 342 -r 720x480 -b 800 -o "Community - Episode 1.mp4"
Using the MKV container format (if your ffmpeg supports it):
php plexDownload.php -h 127.0.0.1:32400 -m 342 -r 720x480 -b 800 -o Community.mkv
HOW THE HELL:
The Plex Universal Transcoder Downloader mimics the actions of the Plex/Web media flash player to download
transcoded media. The differences begin when the downloader saves the streamed data and pieces it together.
First a start.m3u8 playlist file is requested from the server with a query string that defines the
transcoding options. Inside the file is a reference to an index.m3u8 file and some extra statistical info
you'll see if you use the verbose option. The index playlist references, in sequential order, the virtual
pieces to the transcoded media. The pieces are transcoded on-the-fly by Plex and are usually 1 to 8 seconds
in length. Download speed is limited to not only the speed of the network, but how fast Plex's Universal
Transcoder can process the media. The script downloads the pieces in the same order they are provided.
These pieces are given to us in the MPEG-TS container format. The video is usually encoded with x264
(H.264/AVC) and audio is given in the MP3 or AAC format. The script pings Plex/Web after each file is
downloaded to let the server know it's still working. Once all the pieces have been downloaded, Plex/Web is
told to stop transcoding and ffmpeg is given a list of all the pieces in the proper order and merges them
together to form one big continuous media file in a container format of your choosing. You can choose a
container format by changing the extension of the output file. The script attempts to remove all temporary
files after a fatal error, the script finishes, or Ctrl-C.
For information about M3U(8) parsing check out the IETF HTTP Live Streaming draft at
http://tools.ietf.org/html/draft-pantos-http-live-streaming-11
LIMITATIONS:
It's only possible to change the subtitle settings, with the exception of subtitle size, from Plex/Web
itself. Subtitles are always hardcoded for maximum compatibility. The bitrate of the audio is horrifically
low. It's joint stereo and never seems to exceed 200 Kbps. MP3 is the default. AAC is an option within
Plex/Web but I have yet to see it actually work. Perhaps by manipulating the query string (making it
think we're an iDevice?) it is indeed possible. It also seems that there's no way to manually specify
x264 parameters other than setting a maximum bit rate and ambiguous quality setting. There's also no
way to force a resolution. The actual resolution often differs from your specified one in order to
maintain the original aspect ratio (OAR). The verbose option lets you know when this happens.
EOF;
exit();
}
$host = null;
$mediaId = null;
$qual = 75;
$res = null;
$bitrate = null;
$verbose = false;
$yes = false;
$output = null;
$ffmpeg = "ffmpeg";
// Assign options to variables
foreach($options as $k => $v) {
switch($k) {
case "h":
case "host":
$host = $v;
break;
case "m":
case "mediaId":
$mediaId = $v;
break;
case "q":
case "quality":
$qual = $v;
break;
case "r":
case "res":
$res = $v;
break;
case "b":
case "bitrate":
$bitrate = $v;
break;
case "v":
case "verbose":
$verbose = true;
break;
case "y":
case "yes":
$yes = true;
break;
case "o":
case "output":
$output = $v;
break;
case "f":
case "ffmpeg":
$ffmpeg = $v;
break;
}
}
// Verify hostname:port or ipv4:port
if(!checkHost($host)) {
echo "Error: Hostname option must be provided in the following format: host:port or ipv4:port\r\n";
exit(1);
}
function checkHost($host) {
if(!isset($host)) {
return false;
}
if(!preg_match("/^(?:(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])||(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*(?:[A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])):([0-9]{1,5})$/",
$host, $port)) {
return false;
}
$port = (int)$port[1];
if($port < 1 || $port > 65535) {
return false;
}
return true;
}
if(!isset($mediaId) || !is_numeric($mediaId)) {
echo "Error: The Media ID must be numeric.\r\n";
exit(1);
}
$mediaId = (int)$mediaId;
if(!isset($qual) || !is_numeric($qual) || ($qual = (int)$qual) > 100 || $qual < 0) {
echo "Error: Quality values range from 0 to 100.\r\n";
exit(1);
}
if(!isset($res) || !preg_match("/^[0-9]+x[0-9]+$/", $res)) {
echo "Error: Resolution is required and must be formatted like the following: 1280x720\r\n";
exit(1);
}
if(!isset($bitrate) || !is_numeric($bitrate)) {
echo "Error: Bitrate is required and must be numeric.\r\n";
exit(1);
}
$bitrate = (int)$bitrate;
if(!isset($output)) {
echo "Error: Output file required.\r\n";
exit(1);
}
if(!is_writable(dirname($output))) {
echo "Error: " . dirname($output) . " is not writable.\r\n";
exit(1);
}
if(file_exists($output)) {
echo "$output already exists.";
if($yes) {
echo " Overwriting.\r\n";
} else {
echo " Overwrite? [n]: ";
$input = trim(fgets(STDIN));
if($input !== "y" || $input !== "yes") {
exit(0);
}
}
if(!is_writable($output)) {
echo "Error: $output is not writable.\r\n";
exit(1);
}
}
if(!file_exists($ffmpeg)) {
echo "Error: $ffmpeg binary doesn't exist or is not in PHP's PATH.\r\n";
exit(1);
}
if($verbose) {
echo "Retrieving Plex/Web server information...\r\n";
$xml = @file_get_contents("http://$host/");
if($xml) {
$xml = new SimpleXMLElement($xml);
echo "Plex/Web {$xml["version"]}\r\n";
echo "{$xml["friendlyName"]}. {$xml["platform"]} {$xml["platformVersion"]}\r\n";
echo "Active Video Transcoder Sessions: {$xml["transcoderActiveVideoSessions"]}\r\n";
echo "Transcode Audio: " . ($xml["transcoderAudio"] == "1" ? "Yes" : "No" ) . ". ";
echo "Transcode Video: " . ($xml["transcoderVideo"] == "1" ? "Yes" : "No" ) . ".\r\n";
echo "Transcoder Video Bitrates: {$xml["transcoderVideoBitrates"]}\r\n";
echo "Transcoder Video Qualities: {$xml["transcoderVideoQualities"]}\r\n";
echo "Transcoder Video Resolutions: {$xml["transcoderVideoResolutions"]}\r\n";
} else {
echo "Failed to retrieve server info. Probably a 401 on a remote host. Don't worry about it.\r\n";
}
}
// First get the start.m3u8 file
if($verbose) { echo "Downloading start.m3u8...\r\n"; }
$start = file_get_contents("http://$host/video/:/transcode/universal/start.m3u8?path=http%3A%2F%2F127.0.0.1%3A32400%2Flibrary%2Fmetadata%2F$mediaId&mediaIndex=0&partIndex=0&protocol=hls&offset=0&fastSeek=1&directPlay=0&directStream=1&videoQuality=$qual&videoResolution=$res&maxVideoBitrate=$bitrate&subtitleSize=100&audioBoost=100&X-Plex-Platform=Chrome");
// Get the index.m3u8 file that's an index of .ts files
if(!preg_match('@BANDWIDTH=(?P<bandwidth>\d+).+RESOLUTION=(?P<resolution>\d+x\d+).+session/(?P<session>[A-Z,0-9,-]+)/base/index\.m3u8@is', $start, $startData)) {
echo "Error: Session regex failed...\r\n";
exit(1);
}
if($verbose) {
echo "Overall bitrate is estimated at ".filesize_format($startData["bandwidth"], array("", "K", "M", "G", "T", "P", "E", "Z", "Y"))."bps.\r\n";
echo "Actual resolution is {$startData["resolution"]}.\r\n";
}
if($verbose) { echo "Downloading index.m3u8...\r\n"; }
$index = file_get_contents("http://$host/video/:/transcode/universal/session/" . $startData["session"] . "/base/index.m3u8");
// Rough length in seconds of each piece
preg_match("/#EXTINF:(\d+)/", $index, $pieceLength);
$pieceLength = (int)$pieceLength[1];
$index = explode("\n", $index);
$indexes = array();
// 5 is a magic number here. So is 2... and the other 2.
for($i = 5; $i < (count($index) - 2); $i += 2) {
$indexes[] = $index[$i];
}
$estSize = ((int)$startData["bandwidth"]) * $pieceLength * count($indexes);
$estSize = $estSize * 0.125;
if($verbose) {
echo "There are " . count($indexes) . " pieces for this media at about $pieceLength second".($pieceLength===1?"":"s")." each.\r\n";
echo "The media is estimated to run for " . number_format($pieceLength * count($indexes) / 60, 2) . " minutes.\r\n";
echo "The estimated total file size is " . filesize_format($estSize) . ".\r\n";
}
$tmpDiskSpace = disk_free_space(sys_get_temp_dir());
$outDiskSpace = disk_free_space(dirname($output));
// Twice.5 the space to be safe(r)
if($tmpDiskSpace < ($estSize * 2.5)) {
echo "Warning: Only " . filesize_format($tmpDiskSpace) . " remaining in the system's tmp directory.\r\n";
}
if($outDiskSpace < ($estSize * 2.5)) {
echo "Warning: Only " . filesize_format($tmpDiskSpace) . " remaining in the output directory.\r\n";
}
$pieces = array();
$piecesFile = tempnam(sys_get_temp_dir(), "plexDown_");
if($verbose) { echo "Pieces file: $piecesFile\r\n"; }
// Download ALL THE PIECES to the tmp directory
$indexesCount = count($indexes); // I hate calling this forever and goddamn ever
// If you do not start at 0 then ffmpeg will not be able to concat the files as the first .ts file contains extra headers.
for($i = 0; $i < $indexesCount; $i++) {
echo "Downloading " . $indexes[$i] . "... ".($i+1)." of $indexesCount (".(int)(($i+1)/$indexesCount*100)."%)";
$pieces[$i] = tempnam(sys_get_temp_dir(), "plexDown_");
if($verbose) {
echo " {$pieces[$i]}";
}
echo "\r\n";
file_put_contents($pieces[$i], file_get_contents("http://$host/video/:/transcode/universal/session/{$startData["session"]}/base/{$indexes[$i]}"));
file_put_contents($piecesFile, "file '{$pieces[$i]}'\r\n", FILE_APPEND);
// Ping the session to avoid a timeout
file_get_contents("http://$host/video/:/transcode/segmented/ping?session={$startData["session"]}");
}
if($verbose) { echo "Sending stop transcode command to server...\r\n"; }
file_get_contents("http://$host/video/:/transcode/universal/stop?session={$startData["session"]}");
$stopSent = true;
// Concat ALL THE PIECES
$cmd = "$ffmpeg -y -f concat -i $piecesFile -c copy $output";
$exitCode = 0;
if($verbose) {
echo "$cmd\r\n";
passthru($cmd, $exitCode);
} else {
$execOutput = "";
exec($cmd, $execOutput, $exitCode);
}
if($exitCode === 0) {
echo "Download complete.\r\n";
} else {
echo "Error: ffmpeg failed. Exit code $exitCode.";
exit(1);
}
function filesize_format($size, $sizes = array('bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')) {
if ($size == 0) return('n/a');
return (round($size/pow(1000, ($i = floor(log($size, 1000)))), 2) . ' ' . $sizes[$i]);
}
// Called automagically on nearly every possible shutdown situation to clean up
function shutdown() {
if(isset($GLOBALS["pieces"])) {
if($GLOBALS["verbose"]) { echo "Deleting piece files...\r\n"; }
// Delete ALL THE PIECES
foreach($GLOBALS["pieces"] as $p) {
unlink($p);
}
unset($GLOBALS["pieces"]);
}
if(isset($GLOBALS["piecesFile"])) {
if($GLOBALS["verbose"]) { echo "Deleting pieces index file...\r\n"; }
unlink($GLOBALS["piecesFile"]);
unset($GLOBALS["piecesFile"]);
}
if(isset($GLOBALS["startData"]) && !isset($GLOBALS["stopSent"])) {
if($GLOBALS["verbose"]) { echo "Sending stop transcode command to server...\r\n"; }
@file_get_contents("http://{$GLOBALS["host"]}/video/:/transcode/universal/stop?session={$GLOBALS["startData"]["session"]}");
unset($GLOBALS["startData"]);
}
exit();
}
@sevencityseven
Copy link

Hi Kmark

I was able able to get this running locally although I have an issue with ffmpeg i need to look at. However when I try a remote host I get the following error Thoughts?

failed to open stream: HTTP request failed! HTTP/1.1 401 Unauthorized in C:\PHP\plexDownload.php on line 279
Error: Session regex failed...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment