Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Laravel 5 HTTP Response, M4V Files, and iOS MPMoviePlayerViewController

I have a situation that has me stumped so I'm looking for any help I can get.

I have a iOS App that uses MPMoviePlayerViewController to play M4V Video Files managed by a Laravel 5 site.

The video files play perfectly fine (in iOS) if they are directly downloaded from the Laravel 5 /public folder. However, I'm normally storing and serving the Video Files from Laravel 5's Storage Facade as I'm eventually going to use S3 and elastic transcoder.

This works in FireFox with the QuickTime browser plugin, VLC, and other streaming video clients, but not our iOS App.

As far as I can tell the MPMoviePlayerViewController is being picky about how the HTTP Response is being formatted. I have tried StreamedResponse, but that does not seem to help.

So for example the following URL that pulls the file directly from the /public folder works fine from iOS:

http://172.16.160.1/video_ae9a7da0efa211e4b115f73708c37d67.m4v

But if I use Laravel 5 to pull the file from Storage with this URL iOS will not play it.

http://172.16.160.1/api/getfile/f444b190ef5411e4b7068d1890d109e8/video_ae9a7da0efa211e4b115f73708c37d67.m4v

Note iOS does not provide any meaningful errors, to help debug this, but I'm positive its how my HTTP Response is being made by Laravel 5.

Here is my Route:

Route::get('myapi/getfile/{filename?}', 'APIController@getfile')->where('filename', '(.*)');

Here is my Controller:

    public function getfile($filename)
{
    return $api = API::getfile($filename);
}

Here is my Model:

public static function getfile($filename) {
$file = Storage::disk('local')->get('Files/'.$filename);
return (new Response($file, 200))->header('Content-Type', 'video/mp4');
}

If I left out any supporting info please let me know and I'll post it. My next step may be to setup Wireshark testbed and see what the handshake looks like.

Thanks in advance for the help. :-)

like image 794
JungleGenius Avatar asked May 01 '15 16:05

JungleGenius


2 Answers

It looks like I have the answer to my own question. The underlying cause was that Laravel 5 does not natively support HTTP byte-range requests when serving files.

This post located here got me on the right track:

MPMoviePlayerPlaybackDidFinishNotification is called immediately

I then found two posts on doing this Laravel 5:

http://laravel.io/forum/09-23-2014-how-to-support-http-byte-serving-in-file-streams

https://gist.github.com/m4tthumphrey/b0369c7bd5e2c795f6d5

The only draw back is I can't use the Storage Facade to directly access the files as streams. So this solution can only be used for files located on the local filesystem.

public static function getfile($filename) {

$size = Storage::disk('local')->size('files/'.$filename);
$file = Storage::disk('local')->get('files/'.$filename);
$stream = fopen($storage_home_dir.'files/'.$filename, "r");

$type = 'video/mp4';
$start = 0;
$length = $size;
$status = 200;

$headers = ['Content-Type' => $type, 'Content-Length' => $size, 'Accept-Ranges' => 'bytes'];

if (false !== $range = Request::server('HTTP_RANGE', false)) {
    list($param, $range) = explode('=', $range);
    if (strtolower(trim($param)) !== 'bytes') {
    header('HTTP/1.1 400 Invalid Request');
    exit;
    }
    list($from, $to) = explode('-', $range);
    if ($from === '') {
    $end = $size - 1;
    $start = $end - intval($from);
    } elseif ($to === '') {
    $start = intval($from);
    $end = $size - 1;
    } else {
    $start = intval($from);
    $end = intval($to);
    }
    $length = $end - $start + 1;
    $status = 206;
    $headers['Content-Range'] = sprintf('bytes %d-%d/%d', $start, $end, $size);
}

return Response::stream(function() use ($stream, $start, $length) {
    fseek($stream, $start, SEEK_SET);
    echo fread($stream, $length);
    fclose($stream);
    }, $status, $headers);
}
like image 90
JungleGenius Avatar answered Oct 03 '22 14:10

JungleGenius


I know this is an old post, but I ended up needing to stream a video in Laravel from S3 to a player that required HTTP_RANGE support. I put this together (after reading many threads). It should support all disks() you define in Laravel.

I used the class below, placed at App/Http/Responses. To use this class, create a method that does this (this would be the content of your getFile method):

$filestream = new \App\Http\Responses\S3FileStream('file_path_and_name_within_bucket', 'disk_bucket_name');
return $filestream->output();

I just pointed my video player's src at a route for that method and success!

S3FileStream.php:

namespace Http\Responses;

use Illuminate\Http\Request;
use Storage;

class S3FileStream
{
    /**
     * @var \League\Flysystem\AwsS3v3\AwsS3Adapter
     */
    private $adapter;

    /**
     * @var \Aws\S3\S3Client
     */
    private $client;

    /**
     * @var file end byte
     */
    private $end;

    /**
     * @var string
     */
    private $filePath;

    /**
     * @var bool storing if request is a range (or a full file)
     */
    private $isRange = false;

    /**
     * @var length of bytes requested
     */
    private $length;

    /**
     * @var
     */
    private $return_headers = [];

    /**
     * @var file size
     */
    private $size;

    /**
     * @var start byte
     */
    private $start;

    /**
     * S3FileStream constructor.
     * @param string $filePath
     * @param string $adapter
     */
    public function __construct(string $filePath, string $adapter = 's3')
    {
        $this->filePath   = $filePath;
        $this->filesystem = Storage::disk($adapter)->getDriver();
        $this->adapter    = Storage::disk($adapter)->getAdapter();
        $this->client     = $this->adapter->getClient();
    }

    /**
     * Output file to client.
     */
    public function output()
    {
        return $this->setHeaders()->stream();
    }

    /**
     * Output headers to client.
     * @return $this
     */
    protected function setHeaders()
    {
        $object = $this->client->headObject([
            'Bucket' => $this->adapter->getBucket(),
            'Key'    => $this->filePath,
        ]);

        $this->start = 0;
        $this->size  = $object['ContentLength'];
        $this->end   = $this->size - 1;
        //Set headers
        $this->return_headers                        = [];
        $this->return_headers['Last-Modified']       = $object['LastModified'];
        $this->return_headers['Accept-Ranges']       = 'bytes';
        $this->return_headers['Content-Type']        = $object['ContentType'];
        $this->return_headers['Content-Disposition'] = 'inline; filename=' . basename($this->filePath);

        if (!is_null(request()->server('HTTP_RANGE'))) {
            $c_start = $this->start;
            $c_end   = $this->end;

            [$_, $range] = explode('=', request()->server('HTTP_RANGE'), 2);
            if (strpos($range, ',') !== false) {
                headers('Content-Range: bytes ' . $this->start . '-' . $this->end . '/' . $this->size);

                return response('416 Requested Range Not Satisfiable', 416);
            }
            if ($range == '-') {
                $c_start = $this->size - substr($range, 1);
            } else {
                $range   = explode('-', $range);
                $c_start = $range[0];

                $c_end = (isset($range[1]) && is_numeric($range[1])) ? $range[1] : $c_end;
            }
            $c_end = ($c_end > $this->end) ? $this->end : $c_end;
            if ($c_start > $c_end || $c_start > $this->size - 1 || $c_end >= $this->size) {
                headers('Content-Range: bytes ' . $this->start . '-' . $this->end . '/' . $this->size);

                return response('416 Requested Range Not Satisfiable', 416);
            }
            $this->start                            = $c_start;
            $this->end                              = $c_end;
            $this->length                           = $this->end - $this->start + 1;
            $this->return_headers['Content-Length'] = $this->length;
            $this->return_headers['Content-Range']  = 'bytes ' . $this->start . '-' . $this->end . '/' . $this->size;
            $this->isRange                          = true;
        } else {
            $this->length                           = $this->size;
            $this->return_headers['Content-Length'] = $this->length;
            unset($this->return_headers['Content-Range']);
            $this->isRange = false;
        }

        return $this;
    }

    /**
     * Stream file to client.
     * @throws \Exception
     */
    protected function stream()
    {
        $this->client->registerStreamWrapper();
        // Create a stream context to allow seeking
        $context = stream_context_create([
            's3' => [
                'seekable' => true,
            ],
        ]);
        // Open a stream in read-only mode
        if (!($stream = fopen("s3://{$this->adapter->getBucket()}/{$this->filePath}", 'rb', false, $context))) {
            throw new \Exception('Could not open stream for reading export [' . $this->filePath . ']');
        }
        if (isset($this->start)) {
            fseek($stream, $this->start, SEEK_SET);
        }

        $remaining_bytes = $this->length ?? $this->size;
        $chunk_size      = 1024;

        $video = response()->stream(
            function () use ($stream, $remaining_bytes, $chunk_size) {
                while (!feof($stream) && $remaining_bytes > 0) {
                    echo fread($stream, $chunk_size);
                    $remaining_bytes -= $chunk_size;
                    flush();
                }
                fclose($stream);
            },
            ($this->isRange ? 206 : 200),
            $this->return_headers
        );

        return $video;
    }
}
like image 42
Jmorko Avatar answered Oct 03 '22 15:10

Jmorko