-
-
Save zmike808/491239166e0946bd277d4e3b1058508a 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 …
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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(); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment