Идея была простая: смотреть аниме с друзьями так, чтобы пауза у одного означала паузу у всех. Не Discord Go Live с его артефактами и мылом, не «на счёт три жмём пробел», а честный общий плеер: кто угодно нажал — у всех перемоталось.
Я думал, это вечер работы. Получилось четыре нетривиальных проблемы, три из которых я не нашёл описанными нигде, и одна ночь отладки того, почему hls.js молча ничего не делает.
Под катом: почему прямая ссылка на видео бесполезна в браузере, как выглядит прокси с подписанными токенами и переписыванием HLS-плейлистов на лету, как синхронизировать плеер без вечного эха событий, и почему preload="none" ломает Media Source Extensions.
Что в итоге получилось
FastAPI-приложение: комнаты с общим плеером, вход через Discord с проверкой членства в гильдии, чат и автоматический трекинг просмотренных серий.

Ссылки на видео достаёт библиотека anime-dl-core — она умеет вытаскивать прямые ссылки из AniBoom, Kodik, CVH, Sibnet, Animedia, AniLibria, VK Video и SovetRomantica. Всё, что ниже — про то, что происходит между «библиотека вернула ссылку» и «видео идёт синхронно у пяти человек».
Код целиком лежит здесь: github.com/ialakey/anime-watch-together — MIT, разворачивается одной командой docker compose up.
Схема целиком:
Браузер ──WebSocket──► FastAPI ──► RoomManager (состояние комнат в памяти) │ │ │ ├──► Catalog ──► anime-dl-core ──► AnimeGO / Animedia │ │ └► AniBoom / Kodik / … │ │ └──HTTP(S) видео──────►└──► StreamProxy ──► CDN плеера
Проблема №1: прямая ссылка на видео бесполезна
Наивный план выглядел так: дёргаем библиотеку, получаем https://cdn.example/…/720.mp4:hls:manifest.m3u8, кладём в <video src>, расходимся.
Не работает. Причин три, и каждой достаточно:
CDN требует правильный Referer. Kodik, Aniboom и Sibnet отвечают 403 на запрос без него. Браузер не даст вам подставить произвольный Referer — это запрещённый заголовок, fetch его молча выбросит.
Ссылка привязана к IP, который её получил. Запрос за ссылкой сделал ваш сервер — значит, ссылка живёт для IP сервера. Браузер пользователя приходит с другого адреса и получает от ворот поворот.
Ссылки короткоживущие. Час-два, дальше протухают. Даже если бы первые две проблемы решились, пользователь, открывший вкладку утром, к вечеру получил бы битый плеер.
Вывод неприятный, но однозначный: видео придётся гнать через свой сервер. Что автоматически рождает следующий вопрос — как не превратить его в открытый релей, через который посторонние будут качать что попало.
Подписанные токены
Решение: на каждую внешнюю ссылку сервер выписывает токен — HMAC-SHA256 поверх URL, заголовков и срока годности.
def sign(self, url: str, headers: dict[str, str], *, is_playlist: bool) -> str: payload = { "u": url, "h": headers or {}, "p": 1 if is_playlist else 0, "e": int(time.time()) + self.settings.stream_token_ttl, } body = _b64encode(json.dumps(payload, separators=(",", ":")).encode("utf-8")) signature = hmac.new(self._secret, body.encode("ascii"), hashlib.sha256).digest() return f"{body}.{_b64encode(signature)}"
Проверка симметричная: сверяем подпись через hmac.compare_digest, смотрим срок, отсеиваем всё, что не http/https (иначе получите file:///etc/passwd в первый же день).
Клиент видит только /api/stream/segment?t=<токен>. Подделать токен без ключа нельзя, а значит выписать ссылку может только наш сервер. Открытым релеем прокси не становится.
Переписывание HLS на лету
С mp4 всё просто: проксируем байты, пробрасываем Range, готово. С HLS сложнее — плейлист это текстовый файл, внутри которого ещё ссылки. И их там много: варианты качества, сегменты, ключи шифрования AES-128, инициализационные сегменты в EXT-X-MAP.
Каждую надо развернуть в абсолютную (они бывают относительными) и заменить на ссылку того же прокси:
def rewrite_playlist(self, content: str, playlist_url: str, headers: dict[str, str]) -> str: lines: list[str] = [] for raw_line in content.splitlines(): line = raw_line.strip() if not line: lines.append(raw_line) elif line.startswith("#"): lines.append(self._rewrite_tag(line, playlist_url, headers)) else: absolute = urljoin(playlist_url, line) lines.append( self.proxy_url(absolute, headers, is_playlist=_looks_like_playlist(absolute)) ) return "\n".join(lines) + "\n"
Отдельная тонкость — теги, где URI спрятан в атрибуте:
#EXT-X-KEY:METHOD=AES-128,URI="key.bin",IV=0x0
Если не переписать ключ шифрования, плеер пойдёт за ним напрямую и получит свои законные 403. Ловится это не сразу: манифест парсится, сегменты качаются, а картинки нет.
На реальном эпизоде «Наруто» из Kodik получилось 252 переписанные ссылки в одном плейлисте. Работает.
Чего это стоит
Честно: прокси — не бесплатно. Каждый сегмент HLS это отдельный запрос через ваш сервер, и на серии из 252 сегментов это 252 проксированных запроса на каждого зрителя. Трафик идёт транзитом дважды.
Смягчить можно: отдавать mp4-дорожку вместо HLS, где она есть (один запрос с Range вместо сотен), и не ставить потолок качества выше нужного — у меня это STREAM_MAX_QUALITY=1080. Но принципиально это плата за то, что чужой CDN не хочет разговаривать с браузером напрямую.
Проблема №2: синхронизация без эха
Наивная реализация синхронного плеера выглядит так: на play шлём всем «play», по приходу «play» вызываем video.play().
И тут же ловим бесконечный цикл: вызов video.play() порождает событие play, которое шлёт сообщение, которое у соседа вызывает video.play(), которое… Ну вы поняли.
Плюс вторая беда: у кого-то интернет медленнее, он буферизует дольше, и через десять минут просмотра люди расходятся на полминуты.
Что я сделал:
Источник правды — сервер. Клиент не командует другими клиентами, он сообщает серверу факт: «я нажал play на 12.5 секунде». Сервер запоминает позицию вместе с моментом времени:
@dataclass class Playback: playing: bool = False position: float = 0.0 updated_at: float = field(default_factory=time.time) def current(self, *, at: float | None = None) -> float: """Позиция «сейчас» с поправкой на прошедшее время.""" if not self.playing: return self.position now = at if at is not None else time.time() return max(0.0, self.position + (now - self.updated_at))
Благодаря этому опоздавший зритель, зашедший в комнату посреди серии, встаёт на правильную секунду сам — сервер посчитает её на момент запроса.
Глушилка событий. Перед тем как применить чужое действие, клиент ставит временное окно, в котором собственные события игнорируются:
const suppress = (ms = 600) => { state.suppressUntil = performance.now() + ms; }; const suppressed = () => performance.now() < state.suppressUntil; video.addEventListener('play', () => { if (suppressed()) return; send({ type: 'play', position: video.currentTime }); });
Эхо гасится, цикл не заводится. Инициатору сервер шлёт не общий broadcast, а короткий ack — ему-то применять нечего.
Лечение дрейфа. Раз в 8 секунд сервер рассылает опорное время. Клиент подтягивается, только если разошёлся больше чем на ROOM_SYNC_TOLERANCE (по умолчанию 1.5 секунды) — иначе плеер дёргался бы постоянно.
Смещение часов. Клиентские часы врут. Оцениваю сдвиг через ping/pong по половине round-trip:
function applyPong(message) { const now = Date.now() / 1000; const roundTrip = now - Number(message.client_time); state.clockOffset = Number(message.server_time) + roundTrip / 2 - now; }
Проверка на двух вкладках в реальном браузере: 253.2 и 253.3 секунды. Расхождение 0.1 — в пределах того, что человек не заметит.
Проблема №3: hls.js молча ничего не делает
Вот эта отладка стоила мне больше всего нервов, поэтому опишу подробно — вдруг сэкономлю кому-то вечер.
Симптом: страница открывается, плеер на месте, постер показывается, ошибок в консоли ноль. Видео не грузится. video.readyState === 0, буфер пустой.
Иду вглубь. hls.js подключён, manifest распарсен:
[["manifest", 1], ["level", 252, false]]
252 фрагмента, VOD. То есть плейлист прочитан, всё понято. Но FRAG_LOADED не приходит ни разу, и в логах сервера — ни одного запроса за сегментом.
Смотрю внутреннее состояние:
{"state": "IDLE", "started": true, "media": true}
Стоит в IDLE. Ручной hls.startLoad(0) не помогает. Ошибок по-прежнему нет.
Дальше — в буфер-контроллер:
{"ms": "closed", "sbKeys": [], "mediaReady": 0}
Вот оно. MediaSource в состоянии closed. Он никогда не открывался, поэтому создавать SourceBuffer не во что, поэтому качать сегменты незачем, поэтому IDLE. И всё это — без единой ошибки, потому что с точки зрения hls.js ничего страшного не произошло: он просто ждёт.
Причины оказалось две, и обе выглядят безобидно:
1. preload="none" на <video>. Я поставил его, чтобы не тянуть лишнего до нажатия play. Но с MSE это фатально: Chrome при preload="none" вообще не начинает загружать источник, а значит blob-URL от MediaSource не открывается. Лечится заменой на preload="metadata".
2. Порядок вызовов. У меня было так:
hls.loadSource(stream.url); hls.attachMedia(video);
Документация hls.js допускает оба порядка. На практике же — вот сравнение двух инстансов на одном и том же плейлисте в одной и той же вкладке:
// loadSource → attachMedia {"log": [["manifest",1], ["level",252,false]], "ms": "closed", "ready": 0} // attachMedia → loadSource {"log": [["attached"], ["frag",1], ["bufcreated"], ["frag",2], ["frag",3]], "ms": "open", "ready": 4}
Разница только в порядке двух строк. Итоговый вариант:
// сначала attachMedia, потом loadSource: при обратном порядке Chrome // не успевает открыть MediaSource и hls.js молча стоит в IDLE hls.attachMedia(video); hls.loadSource(stream.url);
Мораль: если hls.js «ничего не делает» и при этом не ругается — лезьте в hls.bufferController.mediaSource.readyState. closed означает, что виноват не hls.js, а элемент <video>, который не начал загрузку.
Проблема №4: мелкие грабли, которые стоили времени
Три коротких истории, каждая по своему поучительна.
display побеждает атрибут hidden
Оверлей над плеером был размечен так:
<div class="player-overlay" data-overlay hidden>
И стилизован так:
.player-overlay { position: absolute; inset: 0; display: grid; }
Оверлей висел поверх видео всегда. Потому что hidden — это всего лишь display: none из user-agent-стилей, и любой ваш display его перебивает. Атрибут при этом честно стоит в DOM, el.hidden === true, и глазами баг не находится.
Лечится одной строкой в начале стилей:
[hidden] { display: none !important; }
lstrip("/") съедает абсолютный путь
Контейнер падал на старте:
PermissionError: [Errno 13] Permission denied: 'data'
Виновата вот эта функция:
path = urlsplit(database_url).path.lstrip("/")
В URL SQLAlchemy ровно один слэш отделяет схему от пути:
sqlite:///./data/x.db→ путь/./data/x.db→ относительныйsqlite:////data/x.db→ путь//data/x.db→ абсолютный/data/x.db
lstrip("/") срезает все ведущие слэши и превращает абсолютный путь в относительный. В контейнере это означало попытку создать каталог data внутри /app, куда пользователь без рута писать не может. Правильно — срезать ровно один символ:
return Path(raw[1:])
/anime/103 — это не аниме №103
Идентификаторы у AnimeGO выглядят как naruto-uragannye-hroniki-103. Я решил, что хвостового числа достаточно, и стал ходить по /anime/103.
Сайт честно отвечал 200. И отдавал 103-ю страницу каталога. В результате комната открывалась с названием «Список Аниме смотреть онлайн. Лучшие мультики и аниме мультфильмы в хорошем качестве бесплатно — AnimeGO — страница 103».
Забавно, что тесты этого не ловили: у заглушки каталога был свой идентификатор, и она возвращала правильное название. Поймалось только на живом прогоне.
Вывод банальный, но регулярно забываемый: если сервис не отвечает 404 на бессмысленный запрос — это не значит, что запрос осмысленный.
Авторизация через Discord с проверкой гильдии
Раз уж сайт для своих, вход сделан через Discord: пускаем только тех, кто состоит в конкретной гильдии.
Тут есть неочевидный момент со scope. Большинство примеров в интернете предлагают запросить guilds и проверить, есть ли нужный сервер в списке. Работает, но список гильдий — это довольно много лишних данных о человеке, и роли оттуда не видны.
Лучше guilds.members.read и точечный запрос:
response = await self._client.get( f"{self.settings.discord_api_base}/users/@me/guilds/{guild_id}/member", headers={"Authorization": f"Bearer {access_token}"}, )
Одним запросом получаем и факт членства, и список ролей, и ник на сервере. 404 — не состоит. 401/403 — пользователь не выдал scope, и тогда есть смысл откатиться на список гильдий.
Третий режим — проверка ботом, который сам состоит в гильдии. От пользователя тогда нужен только identify, а роли видны всегда. Полезно, если хотите пускать не всех участников, а обладателей конкретной роли.
Все три режима переключаются одной настройкой, и вся авторизация целиком выключается ещё одной — тогда сайт работает в гостевом режиме, где достаточно ввести имя. Это оказалось удобно: локальная разработка не требует вообще никакой возни с OAuth.
Токен после проверки отзывается — своя сессия у сайта уже есть, чужой хранить незачем:
finally: if access_token: await discord.revoke(access_token)
Что ещё оказалось важным
Комнаты живут в памяти. Сознательное решение: состояние комнаты — это плейбек, зрители и чат, всё эфемерное. Плата за это — приложение работает строго в один воркер. Два воркера означали бы, что зрители попадают в разные копии одной комнаты. Горизонтально масштабируется липкими сессиями по коду комнаты; полноценный шаринг через Redis pub/sub я не делал.
Библиотека синхронная. anime-dl-core построена на requests, поэтому все её вызовы уезжают в тредпул через anyio.to_thread.run_sync, а результаты кладутся в TTL-кэш с защитой от «стада» — чтобы пять человек, одновременно открывших одну страницу, не устроили пять одинаковых запросов к источнику.
Кэш ссылок должен быть коротким. Списки серий кэшируются на 10 минут, прямые ссылки — на 4. Соблазн увеличить второе большой, но ссылки протухают, и пользователь получит 403 вместо видео.
Трекинг сам себя наполняет. Серия отмечается просмотренной после 85%, тайтл добавляется в список при первом же просмотре. Ничего нажимать не нужно — а это ровно то, из-за чего трекеры обычно забрасывают.
Инфраструктура
Многостадийный Dockerfile, non-root пользователь, healthcheck, 361 МБ итогового образа. Три compose-файла: SQLite по умолчанию, оверлей с PostgreSQL, оверлей для разработки с автоперезагрузкой. Entrypoint дожидается готовности БД и накатывает миграции.
CI: ruff, mypy, pytest на Python 3.11, 3.12 и 3.13, alembic check против настоящего PostgreSQL (ловит расхождение моделей и миграций), сборка образа со smoke-тестом /healthz в поднятом контейнере. 90 тестов, ни один не ходит в сеть — источники подменены заглушками, база in-memory.
Одна деталь для тех, кто разрабатывает под Windows и деплоит в Linux: .gitattributes с *.sh text eol=lf обязателен. Иначе checkout превратит entrypoint в CRLF, и контейнер встретит вас загадочным bad interpreter.
И маленький сюрприз напоследок. Пока репозиторий был приватным, workflow публикации образа падал на шаге аттестации происхождения:
Failed to persist attestation: Feature not available for user-owned private repositories.
Образ при этом собирался и пушился нормально — красным горел только последний шаг. Оказалось, artifact attestations недоступны для приватных репозиториев личного аккаунта: нужен публичный репозиторий либо организация.
Чинится условием, после которого workflow ведёт себя правильно в обоих состояниях — молчит на приватном и подписывает на публичном:
- name: Аттестация происхождения if: github.event.repository.visibility == 'public' uses: actions/attest-build-provenance@v2
Мелочь, но показательная: «зелёный CI» и «CI, который делает то, что вы думаете» — не одно и то же. Пока не посмотрите в лог упавшего шага, не узнаете, что образ-то на самом деле опубликован.
Итого
Что я вынес из этой истории:
«Просто вставить ссылку» почти никогда не просто. Referer, привязка к IP, срок жизни — три независимых причины, каждой достаточно, чтобы уронить наивный план.
Молчащая библиотека хуже кричащей. hls.js не выдал ни одной ошибки, стоя в IDLE. Спасло только чтение внутреннего состояния.
Живой прогон ловит то, что не ловят тесты. Три из четырёх багов выше не воспроизводились на заглушках в принципе.
Синхронизация — это про время, а не про события. Как только сервер начал хранить позицию вместе с моментом, всё остальное сложилось само.
Проект написан на FastAPI + SQLAlchemy 2 + Jinja2 и ванильном JS без сборщика. Фронтенд намеренно без фреймворка: страниц шесть, интерактива немного, а отсутствие npm-этапа сильно упрощает и Docker, и CI.
Если будете делать похожее — начните с прокси. Это самая недооценённая часть задачи и единственная, без которой ничего не поедет.
Исходники: github.com/ialakey/anime-watch-together
Буду рад вопросам в комментариях — особенно если у кого-то есть красивое решение для шаринга состояния комнат между воркерами без притаскивания Redis.

