Неделю назад я писал, почему видео из аниме-плеера нельзя просто вставить в <video>. Вывод был такой: нужен серверный видеопрокси, и никуда вы от него не денетесь.

Потом я перенёс тот же проект на телефон, и прокси перестал быть нужен. Не потому, что я придумал обход, а потому, что оба ограничения, из-за которых он существовал, оказались браузерными, а не сетевыми.

Под катом: что именно делал сервер, почему на Android его нет, и что оказалось не переносом библиотеки с Python на Dart, а её переписыванием — вместе с багом, который жил в оригинале, стрелочной функцией в Dart, из-за которой whenComplete начинает ждать сам себя, и релизом, который подписывает не человек.

Что делал сервер

Ровно две вещи.

Подставлял Referer. CDN, с которых плееры раздают видео, отдают файл только если в запросе есть заголовок с их доменом. Без него — 403. Браузер такой заголовок из <video src="..."> не пошлёт: он подставляет туда свой, а переопределить его из JS нельзя — Referer входит в forbidden header names.

Был тем, кто получил ссылку. Подписанная ссылка от плеера привязана к IP запросившего. Получил её сервер, играет браузер пользователя — CDN отвечает отказом.

Отсюда и схема: сервер идёт к плееру, получает ссылку на себя, качает видео с нужным заголовком и переливает клиенту. Со всеми вытекающими — трафик через вас, счёт за трафик тоже ваш, и вы же узкое место.

Почему на телефоне этого нет

video_player во Flutter — обёртка над ExoPlayer, и он принимает произвольные заголовки:

VideoPlayerController.networkUrl(
  Uri.parse(stream.url),
  httpHeaders: stream.headers,   // {'Referer': 'https://kodikplayer.com/'}
)

Первое ограничение — не про сеть, а про песочницу браузера. За её пределами это обычный GET с обычным заголовком.

Со вторым ещё проще: телефон, который сходил за ссылкой, — тот же телефон, который её играет. IP совпадает по построению.

Дальше всё складывается само. Те же заголовки уезжают и в загрузчик, поэтому скачивание работает ровно тем же способом, что и просмотр, — а на сайте это была отдельная боль.

Порт, который оказался переписыванием

Библиотека, превращающая ссылку на плеер в прямые ссылки на видео, у меня уже была — восемь плееров рунета я разбирал отдельно. Логика вся есть, бери и переноси. «Переноси».

whenComplete, который ждёт сам себя

Начну не с парсеров, а с кэша: на нём быстрее всего видно, почему «перенести» не получилось.

Ссылки на видео живут минуты, списки серий — часами. Нужен кэш с TTL и заодно дедупликация: если два экрана просят одно и то же одновременно, запрос должен уйти один.

Получилось так:

final future = build().then((value) {
  _entries[key] = _Entry(value, DateTime.now().add(ttl));
  return value;
}).whenComplete(() {
  // тело блоком обязательно: `=> _inFlight.remove(key)` вернуло бы сам этот
  // future, и whenComplete стал бы ждать его завершения — то есть себя
  _inFlight.remove(key);
});

Комментарий там не для красоты. Напрашивается ведь короткая форма: .whenComplete(() => _inFlight.remove(key)). Только Map.remove возвращает удалённое значение, а удаляем мы отсюда как раз этот самый future. whenComplete видит на выходе future и честно начинает его ждать — то есть ждать самого себя. Он не завершится никогда, ключ останется в _inFlight навсегда, и все последующие запросы этого ключа будут молча висеть на нём.

Ни исключения, ни варнинга: стрелочная функция в Dart возвращает значение выражения, даже когда вы этого не просили, а whenComplete принимает FutureOr<void> и потому такой возврат совершенно легален.

Баг, который жил в оригинале

Эта ловушка хотя бы своя и свежая. Следующая приехала вместе с логикой из Python.

У Kodik адрес эндпоинта в коде страницы не лежит открытым текстом — он собирается в рантайме: url: atob("L2Z0b3I="), то есть /ftor. Ищется он регуляркой по скрипту плеера.

Скриптов этих два. Для ссылок вида /seria/... адрес лежит в app.player.*.js, а для /serial/... — в app.serial.*.js.

Python-версия брала первый попавшийся app.player. На фильмах и одиночных сериях всё работало, на сериалах — молча ломалось. Я это заметил только когда переписывал: в Dart я сначала честно перенёс ту же логику, и тесты на сохранённом ответе сериала легли.

Теперь скрипты перебираются в порядке, который зависит от типа ссылки, а сработавший запоминается:

if (path.contains('app.serial')) return isSerial ? 3 : 1;
if (path.contains('app.player')) return isSerial ? 2 : 3;

Шифр на ответе (base64 со сдвигом Цезаря) я подробно разбирал в прошлой статье, здесь только добавлю деталь, которой там не было: сдвиг перебирается все 26 раз, а сработавший переиспользуется для следующих серий того же тайтла. Подписи в запросе одноразовые и живут недолго, так что загрузка страницы и POST идут подряд, а полученные ссылки надо тратить сразу.

Aniboom: смотреть можно, скачать нельзя

Aniboom отдаёт fMP4, где звук лежит отдельной дорожкой. ExoPlayer с этим справляется сам — он ровно для такого и сделан. А собрать это в один файл без перекодирования нельзя, и тащить ffmpeg в мобильное приложение ради одного плеера мне не хотелось.

Поэтому дорожка знает про себя, можно ли её сохранить:

/// Можно ли сохранить дорожку в один файл без перекодирования.
bool get isDownloadable => kind != StreamKind.dash && audioUrl == null;

А каталог, когда его просят скачать серию, выбирает не ту дорожку, которую включил бы плеер, а лучшую из сохраняемых. На практике это значит, что серию с Aniboom приложение тихо докачивает с Kodik, CVH или Sibnet — рядом почти всегда есть хотя бы один.

Этот кусок мне нравится больше остального: ограничение никуда не делось, но пользователь его не видит.

Загрузчик без ffmpeg

Два случая.

mp4 — потоковая загрузка с Range, поэтому оборванный файл докачивается с того места, где встал.

HLS — плейлист разбирается на сегменты, они качаются подряд и дописываются в один файл. Склейка TS даёт рабочий .ts; для fMP4 первым пишется инициализирующий сегмент из #EXT-X-MAP.

Чего загрузчик намеренно не делает — не берётся за зашифрованные плейлисты (#EXT-X-KEY) и за варианты с раздельным звуком. Ни то, ни другое без перекодирования не собрать, и честный отказ тут лучше файла, который не откроется.

Задание на скачивание не хранит ссылку

Мелочь, но важная. Прямые ссылки протухают за минуты, а очередь загрузок переживает перезапуск приложения. Поэтому задание хранит не URL, а что качаем: тайтл, номер серии, озвучку. Ссылка берётся заново перед каждым стартом и каждой докачкой.

Побочный эффект приятный: «продолжить» после суток простоя работает ровно так же, как через минуту.

53 теста и ни одного запроса в сеть

Парсеры живут на регулярках по чужой вёрстке. Значит, они сломаются — вопрос только когда.

Поэтому тесты гоняются на сохранённых настоящих ответах: в test/fixtures/ лежат реальные ответы Aniboom, Kodik, CVH, Sibnet, AniLibria, VK и Animedia, включая зашифрованный ответ Kodik. HTTP-клиент подменяется, сеть не трогается вообще. Тест — это фикстура плюс ожидание, так что добавить случай для отвалившегося сайта стоит пять минут.

Отдельно проверен загрузчик: докачка mp4, склейка HLS, инициализация fMP4 и оба отказа — на зашифрованных сегментах и на раздельном звуке.

Плюс живая проверка, которая в CI не входит именно потому, что ходит в настоящие сайты:

dart run tool/smoke.dart "Магическая битва"
dart run tool/smoke.dart "Боруто" --source animedia

Она проходит всю цепочку и дёргает первые байты у CDN. Когда что-то отваливается, сразу видно где: у источника или у плеера.

Сборку подписывает не человек

Раз APK раздаётся ссылкой с гитхаба, а не из стора, возникает вопрос: как читатель отличит мою сборку от чужой?

Релиз собирает GitHub Actions по тегу: гоняет тесты, собирает APK под каждую ABI, подписывает релизным ключом, отдельным шагом проверяет через apksigner, что подпись именно релизная, а не отладочная запасная, и прикладывает provenance-аттестацию Sigstore.

Последнее и есть ответ. Аттестация привязывает каждый файл к конкретному коммиту, файлу workflow и раннеру, и подписана не человеком:

gh attestation verify anipocket-1.0.1-arm64-v8a.apk -R ialakey/anipocket
sha256sum -c SHA256SUMS.txt --ignore-missing

Подложить в релиз собранный руками APK так, чтобы проверка прошла, не выйдет ни у кого — включая меня. Для проекта, который ставят мимо стора, это минимальная вежливость.

Проверка на debug-подпись там, кстати, не для красоты. Ключ подтягивается из key.properties, путь в нём абсолютный, и на Windows он записался в MSYS-виде (/d/...), которого Gradle не понимает. Keystore «не нашёлся», сборка молча ушла на отладочный ключ и успешно собралась. Узнал я об этом от apksigner, а не от Gradle.

Что получилось

Поиск по двум каталогам, страница тайтла с выбором озвучки и плеера, локальный список с оценками и прогрессом — всё это есть, и подробно пересказывать я его не буду: экраны как экраны.

Показать стоит ровно два, оба про то, ради чего написана половина статьи.

Очередь: диапазон серий, прогресс по каждой, пауза и докачка. Ни одного вызова ffmpeg за этим экраном нет — HLS собирается сегмент за сегментом в один файл, mp4 докачивается через Range. Очередь переживает перезапуск приложения, потому что задание хранит не ссылку, а тайтл с номером серии.

Плеер на весь экран, и «скачано» в подзаголовке: серия играется из файла. Скриншот снят с выключенными Wi-Fi и мобильными данными — перемотка, смена качества и озвучки на лету работают ровно до того момента, пока не потребуется сходить в сеть за другой дорожкой.

Список, оценки и прогресс не лежат нигде, кроме sqlite на самом телефоне. Аккаунта нет, потому что его негде завести.

Чего оно не умеет

Честный список — каждый пункт осознанный размен:

  • Aniboom не скачивается. Разобрано выше, приложение молча берёт серию у другого плеера.

  • Зашифрованный HLS смотрится, но не сохраняется.

  • Загрузки идут, пока приложение открыто. Фоновой службы нет: очередь сохраняется и продолжается при следующем запуске.

  • Файлы лежат во внутренней папке приложения. Разрешений не надо, но при удалении приложения уйдут и серии.

  • Прокси только HTTP — socks пакет dio на Android не умеет.

  • Сайты меняют вёрстку. Когда поиск или плеер отвалятся, ошибка будет конкретной («не удалось найти…»), а чинить надо регулярку в соответствующем файле.

  • Комнаты совместного просмотра не переехали. Вот им сервер нужен по-настоящему, и никакой ExoPlayer тут не поможет — они остались на сайте.

Про ответственность

Приложение ничего не хранит и не раздаёт. Оно читает ровно то, что сторонние плееры уже опубликовали, — так же, как это делает браузер, открывший их страницу. Своего видеохостинга, зеркал и перезаливок здесь нет и не планируется.

Что с этим делать дальше — на совести владельца телефона.

Ссылки

Комментарии (0)