ESPN DeportesBetts abre juego con jonrón ante San Franciscoוואלהגבר כבן 30 נפצע קשה באירוע אלימות בבת יםThe Jerusalem PostEngolement: Once you find your inner golem, life in the shtetl will never be the same - reviewInquirer2 ex-drug convicts, pal allegedly yield P592,000 worth of shabu in LucenaInquirer EntertainmentJopay Paguia brings up 2025 remark dismissing ‘rivalry’ with Rochelle Pangilinan中国新闻网共享3.0机遇,中国开放红利持续释放SportstarWhat is the new suit Keely Hodgkinson wore while winning Athlos 800m?Wirtualna PolskaDziało się w nocy. Drony nad lotniskiem, wstrzymano loty w LuksemburguNew Straits TimesContractor loses nearly RM150,000 in bogus cryptocurrency investment scamGlobal NewsB.C. family rescues exhausted baby bear swimming in Okanagan Lakeالنهارترتيبات أمنية لساحل ليبيا الغربي... من يملك قرار التنفيذ؟Lenta.ruАрабский военкор рассказал об изменении уровня жизни в Донбассе
The Daily Newsstand · Free, Always
Saturday, September 19, 2026

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

Translate

Неделю назад я писал, почему видео из аниме-плеера нельзя просто вставить в <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 тут не поможет — они остались на сайте.

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

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

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

Ссылки

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.