Скрипт для закачки файла с поддержкой докачки

Зачастую бывает необходимость в том, чтобы твой сайт умел отдавать файлы кому-то с умом, т.е. не просто отдавать на скачку, а поддерживать при этом возможность скачки в несколько потоков и докачки файла в случае умышленного или неумышленного обрыва соединения (такими программами, как ReGet, FlashGet и т.п.). Также может быть желание встроить счетчик скачиваний файла и т.п. Сегодня мы рассмотрим пример скрипта, позволяющего нам воплотить все перечисленные выше желания в реальность.

Перерыв Интернет и подобрав несколько примеров таких скриптов я написал на основе их свой собственный рабочий скрипт (точнее, собственную функцию), поскольку ни один из найденных мной (не буду говорить про них) не работал стабильно для больших файлов (я говорю о скачивании файлов размером, примерно, в гигабайт).

В некоторых случаях у меня стабильно скачивались файлы размером примерно до 100 Мб. Если размер оказывался выше, то файл при скачке «бился», т.е. оригинал и скачанная копия не совпадали. Но обо всем по порядку.

Отдаем браузеру файл без поддержки докачки

Попробуем просто отдать файл браузеру:

header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename=file.txt');
echo file_get_contents('test.jpg');
exit;

В этом примере мы сформировали два заголовка для браузера, первый из которых сообщает ему о типе содержимого (в данном случае – поток каких-то байтов), которое будет отображено, второй заголовок заставляет его выдать нам окно с именем файла для его сохранения на локальном диске. В FireFox оно выглядит примерно так:

Пример окна закачки в браузере

Третьей строчкой представленного примера мы просто считываем все содержимое файла и выводим его браузеру, который в свою очередь сохраняет это содержимое на локальном диске пользователя в случае его согласия скачать файл. Потом нам обязательно нужно сделать exit, иначе к файлу приклеится информация, которая вовсе не содержалась в оригинале, т.е. любой вывод в браузер. Чтобы предотвратить какой-либо вывод мы и завершаем работу скрипта.

Этот пример годится для скачивания маленьких файлов (размером, ну, скажем, до 5-10 Мб). А те, кто сидит в Интернете по модемному соединению (такие еще есть?), уменьшат эту цифру до килобайт эдак 500. Понимаю этих людей, сам таким был когда-то :)

В этот пример можно встроить код для учета кол-ва скачиваний файла, например. Или ведения лога скачиваний. Что душе угодно или нужно в зависимости от ситуации.

Поддержка докачки

А как же быть с большими файлами? Пусть например 300-400 Мб. Бывает и на достаточно устойчивых линиях связи с Интернетом бывают обрывы (да и никто не застрахован, что случайно из компьютера кто-нибудь не выдернет сетевой шнур во время скачивания :) ). С большими файлами возникает сразу несколько проблем. Во-первых, мы не сможем считать такой объем информации разом с помощью любой php-функции для чтения файлов, поскольку всегда есть ограничение памяти, установленное с помощью memory_limit в php.ini. На большинстве хостингов этот объем обычно устанавливается в 32Мб, т.е. скрипт php не сможет скушать больше оперативной памяти сервера, чем установлено в этом ограничении. Во-вторых, как я уже сказал, есть проблема времени скачивания. Может, пользователю нужно уйти и выключить компьютер, приостановив при этом закачку, а потом – прийти, включить машину и продолжить. Если обрыв соединения – можно продолжить закачку с оборванного места.

Получается, нам нужен скрипт, который отдавал бы браузеру содержимое файла порциями. Привожу код функции, НЕ работающей устойчиво для довольно больших файлов (чтобы Вы знали, как НЕ следует делать):

function func_download_file($filepath, $mimetype = 'application/octet-stream') {
	$fsize = filesize($filepath);
	$ftime = date('D, d M Y H:i:s T', filemtime($filepath));

	$fd = @fopen($filepath, 'rb');

	if (isset($_SERVER['HTTP_RANGE'])) {
		$range = $_SERVER['HTTP_RANGE'];
		$range = str_replace('bytes=', '', $range);
		list($range, $end) = explode('-', $range);

		if (!empty($range)) {
			fseek($fd, $range);
		}
	} else {
		$range = 0;
	}

	if ($range) {
		header($_SERVER['SERVER_PROTOCOL'].' 206 Partial Content');
	} else {
		header($_SERVER['SERVER_PROTOCOL'].' 200 OK');
	}

	header('Content-Disposition: attachment; filename='.basename($filepath));
	header('Last-Modified: '.$ftime);
	header('Accept-Ranges: bytes');
	header('Content-Length: '.($fsize - $range));
	if ($range) {
		header("Content-Range: bytes $range-".($fsize - 1).'/'.$fsize);
	}
	header('Content-Type: '.$mimetype);

	$downloaded = 0;

	while (!feof($fd) && !connection_status() && ($downloaded < $fsize)) {
		echo fread($fd, 512000);
		$downloaded += 512000;
		flush();
	}

	fclose($fd);

	exit;
}

Суть этой функции – установка правильных заголовков для программы скачивания, которые сообщают этой программе, поддерживается ли докачка, и формируют информацию для дальнейшей отдачи нужного фрагмента файла.

Обратите внимание на цикл в конце функции. Я провел эксперименты по скачиванию больших файлов (как я уже писал выше, файлов, размером более 100 Мб) и могу сказать, что этот цикл почему-то «бьет» файлы при скачивании. Я не стал вникать, почему это происходит, и пошел другим путем – заменил цикл простым вызовом функции fpassthru – она отдает содержимое файла в браузер, отсчитывая от текущего положения указателя в файле (который устанавливается с помощью fseek). Снова проверив, обнаружил, что теперь функция работает нормально и для файлов размером в 1 Гб.

Итак, привожу правильный вариант этой функции (с комментариями):

// $filepath – путь к файлу, который мы хотим отдать
// $mimetype – тип отдаваемых данных (можно не менять)
function func_download_file($filepath, $mimetype = 'application/octet-stream') {
	$fsize = filesize($filepath); // берем размер файла
	$ftime = date('D, d M Y H:i:s T', filemtime($filepath)); // определяем дату его модификации

	$fd = @fopen($filepath, 'rb'); // открываем файл на чтение в бинарном режиме

	if (isset($_SERVER['HTTP_RANGE'])) { // поддерживается ли докачка?
		$range = $_SERVER['HTTP_RANGE']; // определяем, с какого байта скачивать файл
		$range = str_replace('bytes=', '', $range);
		list($range, $end) = explode('-', $range);

		if (!empty($range)) {
			fseek($fd, $range);
		}
	} else { // докачка не поддерживается
		$range = 0;
	}

	if ($range) {
		header($_SERVER['SERVER_PROTOCOL'].' 206 Partial Content'); // говорим браузеру, что это часть какого-то контента
	} else {
		header($_SERVER['SERVER_PROTOCOL'].' 200 OK'); // стандартный ответ браузеру
	}

	// прочие заголовки, необходимые для правильной работы
	header('Content-Disposition: attachment; filename='.basename($filepath));
	header('Last-Modified: '.$ftime);
	header('Accept-Ranges: bytes');
	header('Content-Length: '.($fsize - $range));
	if ($range) {
		header("Content-Range: bytes $range-".($fsize - 1).'/'.$fsize);
	}
	header('Content-Type: '.$mimetype);

	fpassthru($fd); // отдаем часть файла в браузер (программу докачки)
	fclose($fd);

	exit;
}

Вполне возможно, что я в чем-то здесь ошибаюсь, и с удовольствием приму ваши предложения и замечания в комментариях к этому посту :) , чтобы улучшить эту функцию и сделать ее более устойчивой к большим объемам данных.

До встречи!




Читайте также:



 #  #  #  #  #  #  #  #  #  #

35 Ответов на “Скрипт для закачки файла с поддержкой докачки”

  1. skiedr
    Январь 28th, 2009

    В cakephp уже есть View, которое называется Media.

  2. Igor
    Январь 28th, 2009

    Хорошая статья

  3. Chips
    Январь 28th, 2009

    Так а каким образом тогда можно посчитать количество скачиваний файла? В начале поста об этом упоминалось, а в функции я что-то этого не заметил. Опишите, хотя бы примерный алгоритм, как добавить в эту функцию еще и счетчик скачиваний (именно скачиваний, а не вызовов этой функции).

  4. novice
    Январь 28th, 2009

    Можно, например, вести таблицу в базе, в которой для определенного имени файла вести поле, например, downloads_count, и поле количества скачанных байт для этого файла, bytes_downloaded. Внутри приведенной функции скачивания добавить код после fclose, который будет считать количество скачанных байт всего с помощью базы данных (за все время существования ссылки на этот файл) и делить на размер файла. Получившееся вещественное число (а может быть и целое), округленное в меньшую сторону, покажет кол-во скачиваний. Если нужно учитывать кол-во уникальных скачиваний, добавляем еще одну таблицу, чтобы как-то идентифицировать каждого пользователя (IP-адрес, браузер, … – вариантов много). Этот алгоритм я сейчас написал сходу. Может есть варианты и лучше. Если есть – предлагайте ;)

  5. Chips
    Январь 29th, 2009

    Если использовать такой вариант, то погрешность, мне кажется, будет достаточно большой. т.к. не мало людей прерывает закачки или они у них срываются сами, после чего скачивают песню вновь.
    В зависимости от того, сколько процентов от объема файла было скачано, меняется и погрешность.
    Из 10 мб файла к примеру человек скачал 400 кб, а потом отключил. – это еще не страшная погрешность, а вот есть скачал 7-9 мб и по какой-то причине прервалась закачка, то это уже существенно влияет расчет скачанных файлов.
    В любом случае, спасибо. Это описание подтолкнуло на новые мысли.
    Если найду решение меня удовлетворяющее – поделюсь :)

  6. novice
    Январь 29th, 2009

    Спасибо! С удовольствием посмотрю это решение. Можно даже отдельный пост ему посвятить :)

  7. рома
    Январь 31st, 2009

    спасибо,
    хорошо что есть еще те, кто не ленится делится своими знаниями :)

  8. Makc
    Февраль 5th, 2009

    Так это не про закачку, а про скачивание. Я думал про upload с возможностью возобновления.
    А это все просто. К тому же, если отдавать файл порциями – можно регулировать скорость скачивания. Но это очень сильно будет жрать ресурсы системы.

  9. Nickolas
    Март 3rd, 2009

    Следующие конструкции в примерах

    $range = $_SERVER['HTTP_RANGE'];
    $range = str_replace(’bytes=’, ”, $range);
    $range = str_replace(’-', ”, $range);

    загоняют в $range границы диапазона в виде одного числа.
    Так, из ‘bytes=163091-317864′ получается $range=’163091317864′.
    Скорее всего, требовалось не это …

  10. novice
    Март 3rd, 2009

    Да, тут предполагается, что в $range содержится только нижняя граница диапазона, т.е. “bytes=163091-”. Поэтому, мы тут обрезаем bytes= и тире. По крайней мере FlashGet и ReGet дают HTTP_RANGE без верхнего диапазона.

  11. Автолюбитель
    Март 3rd, 2009

    А в виде готового модуля этот скрипт случайно не существует? для друпал или вордпресс

  12. novice
    Март 3rd, 2009

    Google в помощь ;)

  13. Nickolas
    Март 3rd, 2009

    Не отслеживал какой софтиной пользователь забирал файл (предполагается браузером), ситуция ‘bytes=163091-317864′ реально возникла. Пришлось допиливать.

  14. novice
    Март 3rd, 2009

    Хм, спасибо за замечание. Надо будет исправить.

  15. Amigo
    Апрель 19th, 2009

    Реально работает!
    Нашёл до этого скрипт, очень похожий, но без $range. Докачка не работала.
    Доработал по ЭТОМУ примеру – всё ОК!
    Благодарю!

  16. wert
    Июнь 12th, 2009

    что делать если выдается ошибка
    Internal Server Error

    The server encountered an internal error or misconfiguration and was unable to complete your request.

  17. novice
    Июнь 13th, 2009
  18. AXL
    Июль 10th, 2009

    искал просто заголовок браузеру? дающий команду на скачивание:

    header(’Content-Disposition: attachment; filename=file.txt’);

    Thanks!

  19. maxim
    Ноябрь 5th, 2009

    Спасибо, за толковый скрипт, а то есть аналогичные и не корректно работающие, например вместо $_SERVER пишут $HTTP_SERVER_VARS а из за этого HTTP_RANGE не читается и дозакачка не производится. Может $HTTP_SERVER_VARS["HTTP_RANGE"] это лишь для старых версий пхп… или наоборот..
    и прийму в рассмотрение fpassthru($fd);

  20. Force
    Ноябрь 5th, 2009

    Воспользовался вашим скриптом , на локальном серваке все работает как нужно , но стоило мне перенести на хостинг, как появилась проблема , имя файла не передаеться, те файл скачивается с именем donload.php , хотя в самом начале при отдаче файла , браузер распознает его как медиа файл, и предлагает прослушать.

  21. Александр
    Ноябрь 17th, 2009

    Советую допилить еще койчего. Заголовок “Content-Range” отдавать нужно лишь тогда, когда выдается ответ “206 Partial Content”. Сейчас он отдается и при ответе “200 OK”. Соответственно, строчке 32 добавить условие, и сделать ее таким образом:
    if ($range) header(”Content-Range: bytes $range-”.($fsize – 1).’/’.$fsize);

    Кроме этого полезно избавиться от заголовка “Content-transfer-encoding: binary”. Он не нужен в принципе здесь.

    Браузерам на эти недоработки пофиг, а вот яндекс после одного из апдейта полгода назад поругался на несоответствие заголовков протоколу HTTP и выбросил из индекса урлы отдаваемые этим скриптом (отдавались через этот скрипт PDF-ы, а они хорошо индексируются).

  22. novice
    Ноябрь 18th, 2009

    Спасибо, исправил

  23. Константин
    Декабрь 28th, 2009

    Единственное в MIMETYPE указал “audio/mpeg”.
    Чтобы совпадало по типу.
    Больше ничего не трогал.

  24. Константин
    Декабрь 28th, 2009

    Перепроверили, вроде всё ок уже, извините за беспокойство. Но после скачивания большого файла +1 к скачке почему-то не идёт. Маленькие плюсует отлично! Установили set_time_limit(0). Надеемся поможет. Как совсем решим проблему, сразу отпишусь.

  25. Константин
    Декабрь 29th, 2009

    А реально реализовать счётчик скачиваний с прямой отдачей файла? Не через PHP?

  26. Microname
    Январь 23rd, 2010

    $range = str_replace(”-”, “”, $range);

    заменить на

    $range0 = explode(”-”, $range);
    $range = $range0[0];

    или что-то в этом роде

  27. Mike
    Январь 27th, 2010

    Так скрипт, если не успеет отдать за 30 секунд всё содержимое (большого) файла, он (PHP) выдаст ошибку на ограничение в 30 секунд (по умолчанию). Или я не прав???Напишите пожалуйста ответ на useful-soft@yandex.ru

  28. Александр
    Январь 27th, 2010

    2Mike:

    Не так. Он (PHP) выдаст ошибку, если скрипт не успеет за 30 секунд ПОДГОТОВИТЬ данные перед отправкой. 30 секунд распространяются именно на эту стадию, а отдача данных может происходить и гораздо большее время в зависимости от размера данных и скорости канала.

  29. martin
    Июль 4th, 2010

    Попробовал и предложенную Вами функцию, столкнулся с такой
    проблемой:
    Файл “бьется” в случае загрузки менеджерами закачек. Размеры файлов
    скачанных браузером и DM(Download Master) совпадают, но в последнем
    ошибка “Неожиданный конец архива”.
    Тестировалось на различных размерах файлов (от 3Мб до 100Мб) – итог
    один в случае с браузером ОК, в случае с DM ERROR.
    Подскажите, в чем может быть причина битости?
    P.S. На Download Master грешить не стоит, т.к., файлы с других сайтов и по другим ссылкам он качает на УРА

  30. Nickolas
    Июль 4th, 2010

    “один в случае с браузером ОК, в случае с DM ERROR”.
    В случае с DM возникает еще одна проблема – DM получает ссылку не на файл для скачивания, а на php-скрипт …
    Приходится отключать интеграцию его в браузер.
    Ну, собственно, скрипт-то ориентирован именно на браузер.
    Но если DM перехватывает закачку, то реально – проблема. Не то качает!

  31. martin
    Июль 4th, 2010

    Nickolas
    А как решить проблему?
    Поясню:
    1. DM не перехватывает закачку. Я просто добавляю ссылку вручную в список закачек. (ссылка имеет вид download.php?id=)
    2. Если в функции отключить поддержку докачки, то всё работает отлично. Т.е., файл бьется в то время, когда DM делит файл на несколько потоков.
    Как побороть эту бяку? Уже спасибо.

  32. martin
    Июль 4th, 2010

    …и кстати отдаю скриптом удаленный (файл на другом сервере) файл… Вначале узнаю по его заголовкам размер файла и потом в данную функцию передаю этот размер..
    может как то это влияет?

  33. martin
    Июль 5th, 2010

    Думаю здесь нет решения. Во всяком случае с использованием fseek
    функция fseek не применима для работы с удаленными файлами, поскольку файл должен быть доступен через файловую систему сервера.
    Остается копировать удаленный к себе, а уж потом отдавать.
    Может кто то знает другой вариант решения задачи?

  34. Василий
    Июль 27th, 2010

    Здравствуйте!
    Спасибо за статью!
    У меня два вопроса:
    - Какие файлы и папки можно удалить за ненадобностью из скачаной мной с сайта библиотеки объёмом 11 мб?
    - Не повлияет ли на безопасность наличие в подключаемых файлах (htmlcolors.php, tcpdf.php, unicode_data.php, eng.php и т.п.) наличие объявленых глобальных переменных?

    С уважением, Василий

  35. Zvirja
    Август 9th, 2010

    Для того, чтобы работал первый вариант, причем на отлично необходимо в начале добавить строку set_time_limit(0) , чтобы не выбивало закачку через каждые сек 20-30.

    интересно, если в конце не добавить закрытие потока и выход, то на файле 1.4 гб Опера не докачивает где-то мб 8, а FlashGet берет откуда-то 5кб сверх размера файла ;) интересный и не понятный мне парадокс)

Оставить комментарий


© 2008 - 2010 i-novice.net | Все права защищены.