Safari无法正确显示php提供的mp3持续时间 - php

原始问题
我正在从ZF2控制器操作中提供mp3文件。在OS X和iPhone / iPad上的Safari上,此功能在所有浏览器中均能正常运行。
会播放音频,但持续时间只是显示为NaN:NaN,而在其他所有浏览器中,则显示的是正确的持续时间。

我遍历了关于同一问题的所有线程,似乎它与响应头特别是Content-Range和Accept-Ranges头有关。我尝试了所有不同的组合,但仍然无济于事-Safari仍然拒绝正确显示持续时间。

相关代码段如下所示:

$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;

播放器(我正在使用mediaelementjs)在Safari中如下所示:

我还尝试根据另一个示例解释HTTP_RANGE请求标头,如下所示:

        $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);

在Safari中,这仍然给我相同的结果。我什至尝试使用标头()调用和readfile()而不是ZF2 Response对象来使用核心PHP函数来呈现响应,但这也不起作用。

任何有关如何解决此问题的想法都值得欢迎。

编辑
正如@MarcB所建议的,我比较了两个请求的响应头。第一个请求是对提供mp3文件数据的PHP操作的请求,第二个请求是当我直接浏览到相同的mp3文件时。最初的标题并不完全相同,但是我修改了PHP脚本以匹配直接下载的标题,请参见下面的Firebug屏幕截图:

PHP提供的响应头:

响应头直接下载:

如您所见,除了Date标头外,它们完全相同,但这是因为请求之间存在大约一分半钟。当我尝试通过PHP脚本提供文件时,Safari仍然声称它是实时广播,因此当我以这种方式加载文件时,音频播放器仍显示总时间的NaN。有什么方法可以告诉Safari下载整个文件并在我说这不是现场直播时信任我吗?

也可能是Safari发送不同的请求标头,因此响应标头也不同吗?我通常使用Firefox在Firefox中进行调试。例如,当我在Safari中打开mp3文件URL时,无法打开Web Inspector对话框。还有其他方法可以查看Safari发送和接收的标头吗?

编辑2
我现在使用一个简单的流函数来实现范围请求。即使在Safari中,这似乎也可以在我的开发机上使用,但在运行站点的实时VPS服务器上却无法使用。

我现在使用的功能(由另一个SO-er提供,不记得确切的链接了):

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();
}

然后在控制器动作中调用它:

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

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

我添加了一些日志记录用于调试目的,区别似乎在请求标头中。在我本地的开发机器上,在工作的地方,我在日志中得到了这个:

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

在VPS上,如果它不起作用,我会得到以下信息:

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
)

在实时服务器上,不知何故,只有前两个字节的初始请求被Safari用来确定服务器是否支持范围请求(两次),但是对实际数据的范围请求却从未完成。相反,我得到了流函数中apache_request_headers()调用返回的一堆奇怪的请求标头。我在本地运行Apache的dev机器上没有得到这个。

任何想法都将不胜感激,真的可以把我的头发拉出来。

参考方案

今晚,我花了一段时间解决类似的问题-音频标签在大多数浏览器上都可以正常工作,但在Safari上无法正常处理进度。我找到了一个对我有用的解决方案,希望它也对您有用。

我还阅读了有关类似问题的其他SO问题,他们都谈到了如何处理Range标头。有一些摘要旨在解决Range标头。我在github上发现了一个简短的函数,对我有用。 https://github.com/pomle/php-serveFilePartial

我确实必须对文件进行一次更改。在第38行:

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

我做了一个小修改(删除了-1)

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

我刚刚在github上发布了一个问题,解释了为什么进行此更改。我发现此问题的方式很有趣:当Safari表示可以提供部分内容时,它似乎不信任服务器:Safari(或技术上我认为是Quicktime的)请求具有这样的范围标头的文件的字节0-1 :

Range:  bytes=0-1

作为对该文件的第一个请求。如果服务器返回整个文件-它将文件视为“流”,没有开始或结束。如果服务器使用该文件中的单个字节和正确的标头进行响应,则它将请求该文件的几个不同范围(它们以一种非常不合适的方式完全重叠)。我发现您已经注意到了这一点,并且已经体验到Safari / Quicktime仅发出第一个远程(0-1)请求,而没有后续的“真实”远程请求。从我的调查中看来,发生这种情况是因为您的服务器没有提供“令人满意的”远程答复,因此它放弃了整个远程请求的想法。在使用链接的serverFilePartial函数进行调整之前,我遇到了这个问题。但是,在“修复”该行之后,Safari / Quicktime似乎对第一个响应感到满意,并继续发出后续的远程请求,并且进度条和所有内容均显示出来,我们都很好。

因此,长话短说,试一下链接库,看看它对您是否像对我一样有用:)我知道您已经找到了一个可在您的开发机上而不是在您的生产机上工作的php解决方案,但是也许我的不同解决方案在您的VPS计算机上会有所不同?值得一试。

PHP:对数组排序 - php

请如何排序以下数组Array ( 'ben' => 1.0, 'ken' => 2.0, 'sam' => 1.5 ) 至Array ( 'ken' => 2.0, 'sam' => 1.5, 'ben' =&…

PHP PDO组按列名称查询结果 - php

以下PDO查询返回以下结果:$db = new PDO('....'); $sth = $db->prepare('SELECT ...'); 结果如下: name curso ABC stack CDE stack FGH stack IJK stack LMN overflow OPQ overflow RS…

将输入类型复选框关联到输入类型文本 - php

我有一个问题,我需要将输入类型复选框与输入类型文本关联。情况如下:从数据库中提取数据。 PK数据是复选框的值。当复选框选择输入类型的文本时,您可以在其中输入特定数字。现在的问题是,选中所有类型的复选框输入文本都会被激活。我希望通过选择复选框输入,仅启用与复选框相关联的输入。我的HTML代码(此代码创建一个输入复选框,并为数据库中的每个记录输入文本,而我要激活…

PHP-全局变量的性能和内存问题 - php

假设情况:我在php中运行一个复杂的站点,并且我使用了很多全局变量。我可以将变量存储在现有的全局范围内,例如$_REQUEST['userInfo'],$_REQUEST['foo']和$_REQUEST['bar']等,然后将许多不同的内容放入请求范围内(这将是适当的用法,因为这些数据指的是要求自…

PHP strtotime困境 - php

有人可以解释为什么这在我的服务器上输出为true吗?date_default_timezone_set('Europe/Bucharest'); var_dump( strtotime('29.03.2015 03:00', time()) === strtotime('29.03.2015 04:00�…