7

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. :-)

JungleGenius
  • 321
  • 2
  • 12

2 Answers2

11

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);
}
Community
  • 1
  • 1
JungleGenius
  • 321
  • 2
  • 12
  • thanks for this share, maybe the headers should be extended with 'Content-Transfer-Encoding' => 'binary','Content-Disposition' => 'attachment; filename="' . $filename . '"' – simon Sep 27 '15 at 14:01
  • Thanks a lot for this. Saved me a bunch of time after already spending 2+ hours trying to get an iPhone video displaying in Safari (desktop) from Laravel. – AndyDunn Oct 12 '16 at 10:41
  • +1. Wow, thank you so much. This helped me with the problem I'd been having for *days*: https://stackoverflow.com/q/54738875/470749 – Ryan Feb 20 '19 at 22:02
  • 1
    If you work with this code in Safari then it's not working properly. the reason was $headers = [ 'Content-Length' => $size]; because you fixed that size the total file size. so it's confusing for the second response. If you have the same issue with iPhone (Safari) then remove $size. – Kaushik shrimali Aug 26 '19 at 14:30
3

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:

<?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;
    }
}

If you're using a more modern version of Laravel (8+), I have a more advanced version of this class which leverages the Str and Arr helpers and provides better naming handling and support for both streaming and downloading files of any type S3FileStream.php (Laravel 8+): <?php

namespace Http\Responses;

use Exception;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Http\Response;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;

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

    /**
     * Name of adapter.
     *
     * @var string
     */
    private $adapterName;

    /**
     * Storage disk.
     *
     * @var FilesystemAdapter
     */
    private $disk;

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

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

    /**
     * Human-known filename.
     *
     * @var string|null
     */
    private $humanName;

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

    /**
     * @var int|null length of bytes requested
     */
    private $length = null;

    /**
     * @var array
     */
    private $returnHeaders = [];

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

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

    /**
     * S3FileStream constructor.
     * @param string $filePath
     * @param string $adapter
     * @param string $humanName
     */
    public function __construct(string $filePath, string $adapter = 's3', ?string $humanName = null)
    {
        $this->filePath    = $filePath;
        $this->adapterName = $adapter;
        $this->disk        = Storage::disk($this->adapterName);
        $this->adapter     = $this->disk->getAdapter();
        $this->humanName   = $humanName;
        //Set to zero until setHeadersAndStream is called
        $this->start = 0;
        $this->size  = 0;
        $this->end   = 0;
    }

    /**
     * Get the filename extension (with a leading .).
     *
     * @param  string $filename
     * @return string
     */
    private static function getExtension(string $filename): string
    {
        $filenameArray = explode('.', $filename);

        return Str::start(Arr::last($filenameArray), '.');
    }

    /**
     * Sanitize a filename to be sure it will cause no errors on download.
     *
     * @param  string $str
     * @param  bool   $trimSpaces (default false)
     * @return string (sanitized)
     */
    private static function sanitizeFilename(string $str, bool $trimSpaces = false): string
    {
        $str = strip_tags($str);
        $str = str_replace('/', '-', $str);
        $str = str_replace('–', '_', $str); //en dash
        $str = str_replace(':', ' - ', $str);
        $str = str_replace('&', ' and ', $str);
        $str = preg_replace('/[\r\n\t ]+/', ' ', $str);
        $str = preg_replace('/[\"\*\/\:\<\>\?\'\|]+/', ' ', $str);
        $str = html_entity_decode($str, ENT_QUOTES, 'utf-8');
        $str = htmlentities($str, ENT_QUOTES, 'utf-8');
        $str = preg_replace('/(&)([a-z])([a-z]+;)/i', '$2', $str);
        if ($trimSpaces) {
            $str = str_replace(' ', '_', $str);
            $str = rawurlencode($str);
        } else {
            $str = rawurlencode($str);
            $str = str_replace('%20', ' ', $str);
        }
        $str = str_replace('%', '-', $str);

        //Truncate at 255 length
        if (strlen($str) >= 255) {
            $extension = static::getExtension($str);
            $str       = substr(Str::before($str, $extension), 0, 254 - strlen($extension)) . $extension;
        }

        return $str;
    }

    /**
     * Output file to client as a file download.
     * @return Response|StreamedResponse
     */
    public function download()
    {
        return $this->setHeadersAndStream('attachment');
    }

    /**
     * Output file to client as an inline video stream.
     * @return Response|StreamedResponse
     */
    public function output()
    {
        return $this->setHeadersAndStream('inline');
    }

    /**
     * Output headers to client.
     * @param  string                    $responseMode
     * @return Response|StreamedResponse
     */
    protected function setHeadersAndStream(string $responseMode)
    {
        if (!$this->disk->exists($this->filePath)) {
            report(new Exception('S3 File Not Found in S3FileStream - ' . $this->adapterName . ' - ' . $this->disk->path($this->filePath)));

            return response('File Not Found', 404);
        }

        $this->start   = 0;
        $this->size    = $this->disk->size($this->filePath);
        $this->end     = $this->size - 1;
        $this->length  = $this->size;
        $this->isRange = false;

        $downloadFileName = Str::finish($this->humanName ?? basename($this->filePath), '.' . Arr::last(explode('.', $this->filePath)));
        $downloadFileName = static::sanitizeFilename($downloadFileName);

        //Set headers
        $this->returnHeaders = [
            'Last-Modified'       => $this->disk->lastModified($this->filePath),
            'Accept-Ranges'       => 'bytes',
            'Content-Type'        => $this->disk->mimeType($this->filePath),
            'Content-Disposition' => $responseMode . '; filename=' . $downloadFileName,
            'Content-Length'      => $this->length,
        ];

        //Handle ranges here
        if (!is_null(request()->server('HTTP_RANGE'))) {
            $cStart = $this->start;
            $cEnd   = $this->end;

            $range = Str::after(request()->server('HTTP_RANGE'), '=');
            if (strpos($range, ',') !== false) {
                return response('416 Requested Range Not Satisfiable', 416, [
                    'Content-Range' => 'bytes */' . $this->size,
                ]);
            }
            if (substr($range, 0, 1) == '-') {
                $cStart = $this->size - intval(substr($range, 1)) - 1;
            } else {
                $range  = explode('-', $range);
                $cStart = intval($range[0]);

                $cEnd = (isset($range[1]) && is_numeric($range[1])) ? intval($range[1]) : $cEnd;
            }

            $cEnd = min($cEnd, $this->size - 1);
            if ($cStart > $cEnd || $cStart > $this->size - 1) {
                return response('416 Requested Range Not Satisfiable', 416, [
                    'Content-Range' => 'bytes */' . $this->size,
                ]);
            }

            $this->start                           = intval($cStart);
            $this->end                             = intval($cEnd);
            $this->length                          = min($this->end - $this->start + 1, $this->size);
            $this->returnHeaders['Content-Length'] = $this->length;
            $this->returnHeaders['Content-Range']  = 'bytes ' . $this->start . '-' . $this->end . '/' . $this->size;
            $this->isRange                         = true;
        }

        return $this->stream();
    }

    /**
     * Stream file to client.
     * @throws \Exception
     * @return StreamedResponse
     */
    protected function stream(): StreamedResponse
    {
        $this->adapter->getClient()->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) && $this->start > 0) {
            fseek($stream, $this->start, SEEK_SET);
        }

        $remainingBytes = $this->length ?? $this->size;
        $chunkSize      = 100;

        $video = response()->stream(
            function () use ($stream, $remainingBytes, $chunkSize) {
                while (!feof($stream) && $remainingBytes > 0) {
                    $toGrab = min($chunkSize, $remainingBytes);
                    echo fread($stream, $toGrab);
                    $remainingBytes -= $toGrab;
                    flush();
                }
                fclose($stream);
            },
            ($this->isRange ? 206 : 200),
            $this->returnHeaders
        );

        return $video;
    }
}
Jmorko
  • 382
  • 3
  • 16
  • I would love to understand whether this can work in Laravel 9 since I have had trouble getting this to work. Are you using this in version 9 now? – Scorekaj22 May 31 '22 at 08:43
  • Hi, ScoreKaj22 - this should still be totally working in Laravel 9. I'm currently using it in Laravel 8 and there should be no breaking changes to this code in Laravel 9. That being said, I just updated my answer above to include the latest code I'm using with Laravel 8 that has enhanced functionality and stability in case that's helpful to you! – Jmorko Jun 02 '22 at 01:33