Сервер был нужен ради одного заголовка: порт на Dart, future, который ждёт сам себя, и загрузчик без ffmpeg

Неделю назад я писал, почему видео из аниме-плеера нельзя просто вставить в <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 тут не поможет — они остались на сайте.
Про ответственность
Приложение ничего не хранит и не раздаёт. Оно читает ровно то, что сторонние плееры уже опубликовали, — так же, как это делает браузер, открывший их страницу. Своего видеохостинга, зеркал и перезаливок здесь нет и не планируется.
Что с этим делать дальше — на совести владельца телефона.
Ссылки
Репозиторий: https://github.com/ialakey/anipocket — Flutter, MIT, APK в релизах
Библиотека-предшественник: https://github.com/ialakey/anime-dl-core
Сайт, с которого всё началось: https://github.com/ialakey/anime-watch-together
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.