Safari не показывает длительность mp3, поданного с php, правильно

Оригинальный вопрос Я обслуживаю mp3 файл с помощью действия контроллера ZF2. Это прекрасно работает во всех браузерах, кроме Safari на OS X и iPhone/iPad. Звук воспроизводится, но продолжительность отображается только как NaN: NaN, тогда как в каждом другом браузере отображается правильная продолжительность.

Я просмотрел все потоки на SO, говоря о той же проблеме, и похоже, что это имеет какое-то отношение к заголовкам ответов и заголовкам 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: Note the NaN:NaN

Я также попытался интерпретировать заголовок запроса 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. Я даже пытался использовать основные функции PHP вместо объекта ZF2 Response для визуализации ответа, используя вызовы header() и readfile(), но это тоже не работает.

Любые идеи о том, как решить эту проблему, приветствуются.

Edit Как было предложено @MarcB, я сравнивал заголовки ответов двух запросов. Первый запрос - это действие PHP, служащее файлами данных mp3, а второе - когда я просматриваю один и тот же mp3 файл напрямую. Сначала заголовки были не совсем одинаковыми, но я изменил PHP script, чтобы соответствовать заголовкам прямой загрузки, см. Ниже скриншоты Firebug:

  • Заголовки ответов, обслуживаемые PHP: enter image description here

  • Заголовок заголовка ответов: enter image description here

Как вы можете видеть, они точно такие же, за исключением заголовка Date, но это потому, что между запросами было около полутора минут. Тем не менее Safari утверждает, что это прямая трансляция, когда я пытаюсь обслуживать файл с PHP script, и поэтому аудиоплеер все еще показывает NaN в течение общего времени, когда я загружаю его таким образом. Есть ли способ сказать Safari просто загрузить весь файл и просто доверять мне, когда я говорю, что это не прямая трансляция?

Также может быть, что Safari отправляет разные заголовки запросов, и, следовательно, заголовки ответов также различны? Я обычно делаю свою отладку в Firefox с помощью Firebug. Когда я открываю URL-адрес файла MP3 в Safari, например, я не могу открыть диалоговое окно Web Inspector. Есть ли другой способ просмотреть, какие заголовки отправляются и принимаются Safari?

Изменить 2 Теперь я использую простую функцию потока, реализующую запросы диапазона. Это, похоже, работает на моей машине dev даже в 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);

Я добавил несколько протоколов для целей отладки, и разница, похоже, находится в заголовках запросов. На моей локальной машине dev, где она работает, я получаю это в журнале:

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() в функции потока. Я не получаю это на своей локальной машине dev, которая также запускает Apache.

Любые идеи были бы очень признательны, действительно вытащили бы мои волосы здесь.

Ответ 1

Сегодня вечером я потратил некоторое время на аналогичную проблему - звуковые теги, которые отлично работают на большинстве браузеров, но не имеют должного прогресса в Safari. Я нашел решение, которое работает для меня, надеюсь, оно тоже работает для вас.

Я также читал другие SO-вопросы о подобных проблемах, и все они говорили о работе с заголовком Range. Есть несколько фрагментов, плавающих вокруг этой цели, чтобы справиться с заголовком Range. Я нашел короткую (ish) функцию на 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, которое работает на вашей машине dev, но не на вашей продукции машина, но может быть, мое другое решение будет отличаться на вашей машине VPS? стоит попробовать.

Ответ 2

как дополнение к информации, я считаю, что это может быть связано с этой ошибкой https://bugs.webkit.org/show_bug.cgi?id=82672

Было предложено несколько "обходных решений":

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

Надеюсь, это поможет вам или людям с аналогичными проблемами.

Ответ 3

Вы создаете экземпляр игрока самостоятельно через javascript? Просто попробуйте загрузить свой ресурс через

player.setSrc("http://www.xxx.de/controller/action");

и прослушайте "canplay" - событие, если вы хотите играть напрямую:

player.addEventListener("canplay", function(){
     this.play();
});