Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Safari doesn't show duration of mp3 served from php correctly

Original question I'm serving an mp3 file from a ZF2 controller action. This works fine in all browsers except for Safari on OS X and iPhone/iPad. The audio plays, but the duration is just displayed as NaN:NaN, whereas in every other browser the correct duration is being displayed.

I went over all the threads on SO talking about the same problem and it seems like it has something to do with the response headers and the Content-Range and Accept-Ranges headers in particular. I've tried all the different combinations but still to no avail - Safari still refuses to display the duration correctly.

The relevant code snippet looks like this:

$path = $teaserAudioPath . DIRECTORY_SEPARATOR . $teaserFile;
$fp = fopen($path, 'r');
$etag = md5(serialize(fstat($fp)));
fclose($fp);
$fsize = filesize($path);
$shortlen = $fsize - 1;

$response->setStatusCode(Response::STATUS_CODE_200);
$response->getHeaders()
            ->addHeaderLine('Pragma', 'public')
            ->addHeaderLine('Expires', -1)
            ->addHeaderLine('Content-Type', 'audio/mpeg, audio/x-mpeg, audio/x-mpeg-3, audio/mpeg3')
            ->addHeaderLine('Content-Length', $fsize)
            ->addHeaderLine('Content-Disposition', 'attachment; filename="teaser.mp3"')
            ->addHeaderLine('Content-Transfer-Encoding', 'binary')
            ->addHeaderLine('Content-Range', 'bytes 0-' . $shortlen . '/' . $fsize)
            ->addHeaderLine('Accept-Ranges', 'bytes')
            ->addHeaderLine('X-Pad', 'avoid browser bug')
            ->addHeaderLine('Cache-Control', 'no-cache')
            ->addHeaderLine('Etag', $etag);

$response->setContent(file_get_contents($path));

return $response;

The player (I'm using mediaelementjs) looks like this in Safari: Note the NaN:NaN

I've also tried interpreting the HTTP_RANGE request header based on another example, like so:

        $fileSize = filesize($path);
        $fileTime = date('r', filemtime($path));
        $fileHandle = fopen($path, 'r');

        $rangeFrom = 0;
        $rangeTo = $fileSize - 1;
        $etag = md5(serialize(fstat($fileHandle)));
        $cacheExpires = new \DateTime();

        if (isset($_SERVER['HTTP_RANGE']))
        {
            if (!preg_match('/^bytes=\d*-\d*(,\d*-\d*)*$/i', $_SERVER['HTTP_RANGE']))
            {
                $statusCode = 416;
            }
            else
            {
                $ranges = explode(',', substr($_SERVER['HTTP_RANGE'], 6));
                foreach ($ranges as $range)
                {
                    $parts = explode('-', $range);

                    $rangeFrom = intval($parts[0]); // If this is empty, this should be 0.
                    $rangeTo = intval($parts[1]); // If this is empty or greater than than filelength - 1, this should be filelength - 1.

                    if (empty($rangeTo)) $rangeTo = $fileSize - 1;

                    if (($rangeFrom > $rangeTo) || ($rangeTo > $fileSize - 1))
                    {
                        $statusCode = 416;
                    }
                    else
                    {
                        $statusCode = 206;
                    }
                }
            }
        }
        else
        {
            $statusCode = 200;
        }

        if ($statusCode == 416)
        {
            $response = $this->getResponse();

            $response->setStatusCode(416);  // HTTP/1.1 416 Requested Range Not Satisfiable
            $response->addHeaderLine('Content-Range', "bytes */{$fileSize}");  // Required in 416.
        }
        else
        {
            fseek($fileHandle, $rangeFrom);

            set_time_limit(0); // try to disable time limit

            $response = new Stream();
            $response->setStream($fileHandle);
            $response->setStatusCode($statusCode);
            $response->setStreamName(basename($path));

            $headers = new Headers();
            $headers->addHeaders(array(
                    'Pragma' => 'public',
                    'Expires' => $cacheExpires->format('Y/m/d H:i:s'),
                    'Cache-Control' => 'no-cache',
                    'Accept-Ranges' => 'bytes',
                    'Content-Description' => 'File Transfer',
                    'Content-Transfer-Encoding' => 'binary',
                    'Content-Disposition' => 'attachment; filename="' . basename($path) .'"',
                    'Content-Type' => 'audio/mpeg',  // $media->getFileType(),
                    'Content-Length' => $fileSize,
                    'Last-Modified' => $fileTime,
                    'Etag' => $etag,
                    'X-Pad' => 'avoid browser bug',
            ));

            if ($statusCode == 206) 
            {
                $headers->addHeaderLine('Content-Range', "bytes {$rangeFrom}-{$rangeTo}/{$fileSize}");
            }

            $response->setHeaders($headers);
        }

        fclose($fileHandle);

This still gives me the same result in Safari. I even tried using core PHP functions instead of the ZF2 Response object to render a response, using header() calls and readfile(), but that doesn't work either.

Any ideas on how to solve this are welcome.

Edit As suggested by @MarcB I compared the response headers of the two requests. The first request is to the PHP action serving the mp3 file data and the second is when I browse to the same mp3 file directly. At first the headers weren't completely the same, but I modified the PHP script to match the headers of the direct download, see Firebug screenshots below:

  1. Response headers served by PHP: enter image description here

  2. Response headers direct download: enter image description here

As you can see they are exactly the same except for the Date header, but that's because there was about a minute and a half in between the requests. Still Safari is claiming it is a live broadcast when I try to serve the file from the PHP script and so the audioplayer still shows NaN for the total time when I load it that way. Is there any way to tell Safari to just download the whole file and just trust me when I say this is not a live broadcast?

Also could it be that Safari sends different request headers and thus the response headers are also different? I usually do my debugging in Firefox with Firebug. When I open the mp3 file URL in Safari for instance I cannot open the Web Inspector dialog. Is there any other way to view what headers are being sent and received by Safari?

Edit 2 I'm now using a simple stream function implementing the range requests. This seems to work on my dev machine even in Safari, but not on the live VPS server where the site is running.

The function I use now (courtesy of another SO-er, don't remember the exact link):

private function stream($file, $content_type = 'application/octet-stream', $logger)
{
    // Make sure the files exists, otherwise we are wasting our time
    if (!file_exists($file))
    {
        $logger->debug('File not found');

        header("HTTP/1.1 404 Not Found");
        exit();
    }

    // Get file size
    $filesize = sprintf("%u", filesize($file));

    // Handle 'Range' header
    if (isset($_SERVER['HTTP_RANGE']))
    {
        $range = $_SERVER['HTTP_RANGE'];
        $logger->debug('Got Range: ' . $range);
    }
    elseif ($apache = apache_request_headers())
    {
        $logger->debug('Got Apache headers: ' . print_r($apache, 1));

        $headers = array();
        foreach ($apache as $header => $val)
        {
            $headers[strtolower($header)] = $val;
        }
        if (isset($headers['range']))
        {
            $range = $headers['range'];
        }
        else
            $range = FALSE;
    }
    else
        $range = FALSE;

    // Is range
    if ($range)
    {
        $partial = true;
        list ($param, $range) = explode('=', $range);
        // Bad request - range unit is not 'bytes'
        if (strtolower(trim($param)) != 'bytes')
        {
            header("HTTP/1.1 400 Invalid Request");
            exit();
        }
        // Get range values
        $range = explode(',', $range);
        $range = explode('-', $range[0]);
        // Deal with range values
        if ($range[0] === '')
        {
            $end = $filesize - 1;
            $start = $end - intval($range[0]);
        }
        else
            if ($range[1] === '')
            {
                $start = intval($range[0]);
                $end = $filesize - 1;
            }
            else
            {
                // Both numbers present, return specific range
                $start = intval($range[0]);
                $end = intval($range[1]);
                if ($end >= $filesize || (! $start && (! $end || $end == ($filesize - 1))))
                    $partial = false; // Invalid range/whole file specified, return whole file
            }
        $length = $end - $start + 1;
    }
    // No range requested
    else
        $partial = false;

    // Send standard headers
    header("Content-Type: $content_type");
    header("Content-Length: $filesize");
    header('X-Pad: avoid browser bug');
    header('Accept-Ranges: bytes');
    header('Connection: Keep-Alive"');

    // send extra headers for range handling...
    if ($partial)
    {
        header('HTTP/1.1 206 Partial Content');
        header("Content-Range: bytes $start-$end/$filesize");
        if (! $fp = fopen($file, 'rb'))
        {
            header("HTTP/1.1 500 Internal Server Error");
            exit();
        }
        if ($start)
            fseek($fp, $start);
        while ($length)
        {
            set_time_limit(0);
            $read = ($length > 8192) ? 8192 : $length;
            $length -= $read;
            print(fread($fp, $read));
        }
        fclose($fp);
    }
    // just send the whole file
    else
        readfile($file);
    exit();
}

This is then called in the controller action:

        $path = $teaserAudioPath . DIRECTORY_SEPARATOR . $teaserFile;
        $fsize = filesize($path);

        $this->stream($path, 'audio/mpeg', $logger);

I added some logging for debugging purposes and the difference seems to be in the request headers. On my local dev machine, where it works I get this in the log:

2014-03-09T18:01:17-07:00 DEBUG (7): Got Range: bytes=0-1
2014-03-09T18:01:18-07:00 DEBUG (7): Got Range: bytes=0-502423
2014-03-09T18:01:18-07:00 DEBUG (7): Got Range: bytes=131072-502423

On the VPS, where it doesn't work I get this:

2014-03-09T18:02:25-07:00 DEBUG (7): Got Range: bytes=0-1
2014-03-09T18:02:29-07:00 DEBUG (7): Got Range: bytes=0-1
2014-03-09T18:02:35-07:00 DEBUG (7): Got Apache headers: Array
(
    [Accept] => */*
    [Accept-Encoding] => identity
    [Connection] => close
    [Cookie] => __utma=71101845.663885222.1368064857.1368814780.1368818927.55; _nsz9=385E69DA4D1C04EEB22937B75731EFEF7F2445091454C0AEA12658A483606D07; PHPSESSID=c6745c6c8f61460747409fdd9643804c; _ga=GA1.2.663885222.1368064857
    [Host] => <edited out>
    [Icy-Metadata] => 1
    [Referer] => <edited out>
    [User-Agent] => Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_1) AppleWebKit/537.73.11 (KHTML, like Gecko) Version/7.0.1 Safari/537.73.11
    [X-Playback-Session-Id] => 04E79834-DEB5-47F6-AF22-CFDA0B45B99F
)

Somehow on the live server only the initial request for the first two bytes, which Safari uses to determine if a server supports range requests comes in (twice), but the range request for the actual data is never done. Instead I'm getting a bunch of strange request headers as returned by the apache_request_headers() call in the stream function. I'm not getting that on my local dev machine, which also runs Apache.

Any ideas would be greatly appreciated, really pulling my hair out here.

like image 321
Ruben Avatar asked Mar 06 '14 01:03

Ruben


2 Answers

Tonight I spent a while on a similar problem - audio tags that work fine on most browsers, but don't deal with progress properly on Safari. I have found a solution that works for me, hopefully it works for you too.

I also read the other SO questions about similar issues, and they all spoke about dealing with the Range header. There are a few snippets floating around that aim to deal with the Range header. I found a short(ish) function on github that has been working for me. https://github.com/pomle/php-serveFilePartial

I did have to make one change to the file though. On line 38:

header(sprintf('Content-Range: bytes %d-%d/%d', $byteOffset, $byteLength - 1, $fileSize));

I made a small modification (removed a -1)

header(sprintf('Content-Range: bytes %d-%d/%d', $byteOffset, $byteLength, $fileSize));

I have posted an issue to the github just now explaining why I made this change. The way I found this issue is interesting: It appears that Safari doesn't trust a server when it says it can provide partial content: Safari (or technically Quicktime I think) requests bytes 0-1 of a file with a range header like this:

Range:  bytes=0-1

as its first request to the file. If the server returns the whole file - it treats the file as a 'stream', which has no beginning or end. If the server responds with a single byte from that file, and the correct headers, it will then ask for a few different ranges of that file (which grossly overlap in what seems like a very inappropriate way). I see that you have already noticed this, and that you have experienced that Safari/Quicktime only makes the first ranged (0-1) request, and no subsequent 'real' ranged requests. It appears from my poking-around that this is happening because your server did not serve a 'satisfactory' ranged reply, so it gave up on the whole ranged request idea. I was experiencing this problem when I used the linked serverFilePartial function, before making my adjustment to it. However, after 'fixing' that line, Safari/Quicktime seems to be happy with the first response, and continues to make subsequent ranged requests, and the progress bar and everything appears, and we are all good.

So, long story short, give the linked library a go, and see if it works for you like it did for me :) I know you have already found a php solution that works on your dev machine but not on your production machine, but maybe my different solution will be different on your VPS machine? worth a try.

like image 120
user73917 Avatar answered Oct 01 '22 22:10

user73917


just as a complement of information, I believe this could be related to this bug https://bugs.webkit.org/show_bug.cgi?id=82672

There have been a few "workarounds" proposed like :

xhr.setRequestHeader("If-None-Match", "webkit-no-cache");

Hope this can help you or people with similar problems.

like image 41
PEM Avatar answered Oct 01 '22 23:10

PEM