Идея была простая: смотреть аниме с друзьями так, чтобы пауза у одного означала паузу у всех. Не 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, который делает то, что вы думаете» — не одно и то же. Пока не посмотрите в лог упавшего шага, не узнаете, что образ-то на самом деле опубликован.

Итого

Что я вынес из этой истории:

  1. «Просто вставить ссылку» почти никогда не просто. Referer, привязка к IP, срок жизни — три независимых причины, каждой достаточно, чтобы уронить наивный план.

  2. Молчащая библиотека хуже кричащей. hls.js не выдал ни одной ошибки, стоя в IDLE. Спасло только чтение внутреннего состояния.

  3. Живой прогон ловит то, что не ловят тесты. Три из четырёх багов выше не воспроизводились на заглушках в принципе.

  4. Синхронизация — это про время, а не про события. Как только сервер начал хранить позицию вместе с моментом, всё остальное сложилось само.

Проект написан на FastAPI + SQLAlchemy 2 + Jinja2 и ванильном JS без сборщика. Фронтенд намеренно без фреймворка: страниц шесть, интерактива немного, а отсутствие npm-этапа сильно упрощает и Docker, и CI.

Если будете делать похожее — начните с прокси. Это самая недооценённая часть задачи и единственная, без которой ничего не поедет.

Исходники: github.com/ialakey/anime-watch-together

Буду рад вопросам в комментариях — особенно если у кого-то есть красивое решение для шаринга состояния комнат между воркерами без притаскивания Redis.