testube — справочник для фронтенда

Всё, что нужно, чтобы написать клиент: адреса, формы запросов и ответов, правила доступа и грабли, на которые здесь легко наступить.

Аутентификация

JWT в заголовке: Authorization: Bearer <access_token>. Access-токен живёт expires_in секунд (по умолчанию час), после чего меняется на новый через /auth/refresh — заново спрашивать пароль не нужно.

МетодПутьТелоОтвет
POST/auth/register{username, email, password}201 TokenResponse
POST/auth/login{email, password}200 TokenResponse
POST/auth/refresh{refresh_token}200 TokenResponse
POST/auth/logout{refresh_token}204
POST/auth/logout-all— (токен)204
GET/auth/me200 User
// TokenResponse
{
  "access_token": "eyJhbGciOi...",
  "refresh_token": "7TXspv4MCFitf_Z1MOvBNbF8xXlEsKE7UPJFKYkPaXs",
  "token_type": "Bearer",
  "expires_in": 3600,
  "user": {
    "id": "...",
    "username": "neo",
    "display_name": "Нео",
    "email": "neo@example.com",
    "avatar_url": null,
    "created_at": "2026-09-06T10:00:00Z"
  }
}

Refresh-токен одноразовый. Каждый обмен выдаёт новую пару и гасит предъявленный токен; храните только последний. Если предъявить уже потраченный, вся цепочка обновлений этой сессии гасится целиком и приходит 401 — это защита от повторного использования украденного токена, а не ошибка клиента. Практический вывод: не обновляйте токен из двух вкладок одновременно, иначе вторая уронит сессию обеим. Живёт refresh-токен 30 суток (JWT__REFRESH_TTL_SECS).

/auth/logout гасит одну сессию и не требует access-токена: выйти нужно уметь и с протухшим. /auth/logout-all гасит все сессии пользователя.

Ограничения: username — 3–32 символа латиницы, цифр, _ и -; пароль — 8–128 символов. Email приводится к нижнему регистру. username — это ещё и адрес канала (@username в ссылке), а display_name — показываемое имя: его правят через PATCH /me/channel, и ссылки при этом не ломаются.

/auth/login не уточняет, что именно неверно — email или пароль: иначе эндпоинт превращается в оракул для перебора зарегистрированных адресов. Показывайте пользователю общее «неверный email или пароль».

Идентификаторы

У видео короткий публичный идентификатор — 11 символов алфавита A-Za-z0-9_-, как в ссылках YouTube (dQw4w9WgXcQ). Именно он приходит в поле id и подставляется во все пути:

/videos/dQw4w9WgXcQ
/videos/dQw4w9WgXcQ/playback
/videos/dQw4w9WgXcQ/hls/master.m3u8

Внутренний UUID наружу не выходит вовсе. Идентификатор непрозрачен: не разбирайте его и не пытайтесь по нему что-то вычислить.

Остальные сущности — пользователи, каналы, комментарии, плейлисты, уведомления — адресуются UUID. Канал вдобавок доступен по имени: /channels/by-username/neo. Строка неверного формата в пути видео даёт 404, а не 400: API не подсказывает, какие идентификаторы бывают.

Ошибки

Тело всегда одинаковое: {"error": "человекочитаемый текст"}.

КодКогда
400не прошла валидация, битый курсор, подписка на себя
401нет токена, токен просрочен или испорчен
403доступ есть, но действие чужое (правка чужого видео)
404нет объекта — или он есть, но скрыт (см. ниже)
409состояние не позволяет: файл не загружен, видео не готово
422тело не разобралось (не тот тип поля, неизвестный enum)
429превышен лимит частоты, см. заголовок Retry-After
500внутренняя ошибка; подробности только в логах сервера

404 вместо 403 для приватного. Чужое приватное видео отвечает 404, как будто его не существует. Это намеренно: иначе по кодам ответов можно перебором узнать, какие идентификаторы заняты. Не показывайте пользователю «нет доступа» там, где пришёл 404.

Ограничение частоты

Общий лимит — 20 запросов в секунду с всплеском до 40, на /auth/* строже: 10 в минуту. При отказе приходит 429 и Retry-After в секундах.

Клиенту стоит уважать Retry-After и не ретраить вслепую: лимит считается по IP, и параллельные вкладки складываются в один счётчик.

Пагинация

Два разных механизма — не перепутайте.

Курсорная (/videos, /videos/my, /feed, /channels/{id}/videos, /me/history, /notifications, комментарии). Параметры ?limit= (1–100, по умолчанию 20) и ?cursor=.

{ "items": [/* ... */], "next_cursor": "dHwyMDI2LTA5LTA2VDEwOjAwOjAwWnw..." }

Следующая страница — тот же запрос с cursor=next_cursor. next_cursor: null означает, что данные кончились. Курсор непрозрачен: не разбирайте его и не конструируйте сами.

Внутри курсора спрятаны две разные формы пагинации. Хронологические сортировки листаются по ключу «время + id», ранжированные (sort=trending, sort=most_viewed, sort=top у комментариев) — по смещению: keyset-пагинация требует стабильного полного порядка, а ранг не уникален и меняется вместе с данными. Снаружи разницы нет, но курсор нельзя переносить между сортировками: смена sort без сброса cursor даёт 400. Меняете сортировку — начинайте список заново.

Offset-овая (/videos/search, /subscriptions, /playlists, /playlists/{id}/items, /me/reports). Параметры ?limit= и ?offset=, в ответе они же:

{ "items": [/* ... */], "limit": 20, "offset": 0 }

Модели

type VideoStatus = "uploading" | "processing" | "ready" | "failed";
type Visibility = "public" | "unlisted" | "private";
type Reaction = "like" | "dislike";
type FeedSort = "newest" | "oldest" | "trending" | "most_viewed";
type CommentSort = "newest" | "top";

interface ChannelSummary {
  // всё, что нужно строке под заголовком
  id: string; // UUID автора, он же id канала
  username: string;
  display_name: string;
  avatar_url: string | null;
  subscriber_count: number;
}

interface Video {
  id: string; // 11 символов, короткая ссылка
  channel: ChannelSummary; // канал едет вместе с видео, второй запрос не нужен
  title: string;
  description: string | null;
  status: VideoStatus;
  visibility: Visibility;
  duration_secs: number | null; // появляется после транскодинга
  thumbnail_url: string | null; // путь этого же API, не ссылка на S3
  hls_url: string | null; // null, пока видео не транскодировано
  view_count: number;
  like_count: number;
  dislike_count: number;
  comment_count: number;
  tags: string[]; // до 15 штук, нижний регистр
  published_at: string | null; // когда стало видно; null — не публиковалось
  created_at: string; // RFC 3339
  updated_at: string;
}

interface Comment {
  id: string;
  video_id: string;
  author_id: string;
  author_username: string;
  author_display_name: string;
  author_avatar_url: string | null;
  parent_id: string | null; // корень ветки; null — сам корень
  body: string;
  like_count: number;
  reply_count: number;
  liked_by_me: boolean | null; // null для анонима
  edited_at: string | null; // непусто → показать «изменено»
  created_at: string;
  updated_at: string;
}

interface ChannelLink {
  label: string;
  display: string;
  url: string;
}

interface Channel {
  id: string;
  username: string; // адрес канала: @username
  display_name: string;
  description: string; // короткая строка карточки
  about: string | null; // длинный текст вкладки «о канале»
  avatar_url: string | null;
  banner_url: string | null;
  country: string | null; // ISO 3166-1 alpha-2
  links: ChannelLink[];
  created_at: string;
  subscriber_count: number;
  video_count: number; // только публичные готовые видео
  view_count: number; // суммарно по всем публичным видео канала
  subscribed: boolean | null; // null для анонима
  notifications_enabled: boolean | null; // колокольчик; null, если подписки нет
}

interface Playlist {
  id: string;
  owner_id: string;
  owner_username: string;
  owner_display_name: string;
  owner_avatar_url: string | null;
  title: string;
  description: string | null;
  visibility: Visibility;
  video_count: number;
  cover_video_id: string | null; // первое видео плейлиста — обложка карточки
  created_at: string;
  updated_at: string;
}

interface WatchProgress {
  position_secs: number; // где остановились
  watched_secs: number; // суммарно просмотрено
  completed: boolean;
  last_watched_at: string;
}

type NotificationKind = "new_video" | "comment_reply" | "video_comment";

interface Notification {
  id: string;
  kind: NotificationKind;
  actor_id: string;
  actor_username: string;
  actor_display_name: string;
  actor_avatar_url: string | null;
  video_id: string | null; // короткий id, если событие про видео
  video_title: string | null;
  comment_id: string | null;
  read: boolean;
  created_at: string;
}

type ReportReason =
  "spam" | "sexual" | "violence" | "hate" | "harassment" | "misinformation" | "copyright" | "other";

interface Report {
  id: string;
  video_id: string;
  reason: ReportReason;
  details: string | null;
  status: "pending" | "reviewed" | "dismissed";
  created_at: string;
}

Формулировку уведомления («X выложил Y») собирает клиент: она зависит от языка, а API отдаёт только kind, автора и заголовок видео.

Жизненный цикл видео: uploading → (подтверждение загрузки) → processing → (воркер) → ready, либо failed, если транскодинг не удался. Пока статус не ready, hls_url и thumbnail_url пустые, а видео не попадает ни в ленту, ни в поиск.

Видео

МетодПутьДоступОписание
POST/videosтокенСоздать запись, получить ссылку для загрузки
POST/videos/{id}/completeвладелецПодтвердить загрузку
GET/videosвсеЛента: public + ready, курсор
GET/videos/search?q=всеПоиск, offset
GET/videos/myтокенСвои видео, все статусы, курсор
GET/videos/{id}по видимостиКарточка
PATCH/videos/{id}владелецПравка метаданных
DELETE/videos/{id}владелецУдалить запись и файлы
GET/videos/{id}/playbackпо видимостиКак воспроизводить
POST/videos/{id}/viewвсеЗасчитать просмотр
GET/videos/{id}/relatedпо видимостиПохожие видео (до 20)
GET/videos/{id}/thumbnailпо видимостиПревью (картинка)
GET/videos/{id}/hls/{path}по видимостиМанифест и сегменты

Лента: сортировка и категории

GET /videos?sort=trending&tag=rust&limit=20

sortnewest (по умолчанию), oldest, most_viewed, trending. «В тренде» — просмотры, поделённые на возраст: без деления подборка навсегда застревала бы на старых роликах с большим счётчиком.

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

Смена sort требует сбросить cursor (см. «Пагинация»).

Просмотры

POST /videos/{id}/view возвращает {"view_count": 123}.

Зовите его один раз на начало просмотра, а не по таймеру. Для авторизованного зрителя повторный заход в пределах получаса не засчитывается — дедупликация идёт по истории просмотров. Анонимов дедуплицировать нечем, их просмотры считаются всегда. Ответ приходит и в том случае, когда просмотр не зачли: в нём текущее значение счётчика, которое и надо нарисовать.

Загрузка: три шага

// 1. Создаём запись. visibility по умолчанию private.
const created = await post("/videos", {
  title: "Прогулка по городу",
  description: null,
  visibility: "public",
});
// { video: Video, upload_url: "https://s3...", expires_in: 900 }

// 2. Кладём файл прямо в хранилище. Этот запрос идёт мимо API.
await fetch(created.upload_url, { method: "PUT", body: file });

// 3. Подтверждаем: API сам проверит, что объект появился в хранилище.
await post(`/videos/${created.video.id}/complete`);
// Video со статусом processing

Замечания:

Теги

tags задаются при создании и заменяются целиком при правке (PATCH с полем tags). До 15 штук, каждый 1–30 символов. API обрезает пробелы, приводит к нижнему регистру и убирает повторы, так что ["Rust", "rust", " Axum "] превращается в ["rust", "axum"]. Теги участвуют в поиске с меньшим весом, чем заголовок и описание, и по ним же фильтруется лента.

Правка

PATCH /videos/{id} принимает любое подмножество полей title, description, visibility. Отсутствие поля и null — разные вещи:

{ "title": "Новый заголовок" }   // описание не тронуто
{ "description": null }          // описание очищено

Воспроизведение

GET /videos/{id}/playback возвращает один из двух вариантов:

{ "hls_url": "/videos/<id>/hls/master.m3u8" }
{ "source_url": "https://s3...", "expires_in": 3600 }

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

Манифест и сегменты идут через API, а не по presigned-ссылкам: пути внутри манифеста относительные, и подписать их по отдельности плеер не умеет. Поэтому каждый запрос сегмента проверяет видимость видео — и требует токен, если видео не публичное.

Как подключать плеер

// hls.js предпочтительнее нативного HLS везде, где поддерживается.
// Проверять canPlayType первым нельзя: Chromium отвечает "maybe",
// хотя HLS не играет, и вы уйдёте на путь без токена.
if (Hls.isSupported()) {
  const hls = new Hls({
    xhrSetup: (xhr) => xhr.setRequestHeader("Authorization", `Bearer ${token}`),
  });
  hls.loadSource(playback.hls_url);
  hls.attachMedia(video);
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
  video.src = playback.hls_url; // Safari; приватные видео так не откроются
}

Уровни качества берите из события MANIFEST_PARSED (data.levels), переключение — присваиванием hls.currentLevel (-1 — автовыбор).

Обработчик ERROR обязан считать попытки восстановления: и startLoad(), и recoverMediaError() заново тянут сегменты, а на битом файле ошибка повторяется мгновенно — без счётчика плеер уходит в бесконечный цикл запросов к серверу.

Превью

thumbnail_url — это путь API (/videos/{id}/thumbnail), а не ссылка на хранилище. Тег <img> не умеет отправлять Authorization, поэтому превью приватных видео в разметке не отобразится. Варианты: показывать заглушку или грузить через fetch с токеном в blob:-ссылку.

Реакции

МетодПутьТелоОтвет
PUT/videos/{id}/reaction{"reaction": "like"}счётчики
DELETE/videos/{id}/reactionсчётчики
GET/videos/{id}/reactionсчётчики и свой голос
{ "likes": 12, "dislikes": 1, "my_reaction": "like" }

Реакция у пользователя одна: PUT с другим значением переносит голос, а не добавляет второй. Все три ручки требуют токен. Счётчики в карточке видео (like_count, dislike_count) обновляются той же транзакцией, так что после ответа можно просто перерисовать число из него.

Комментарии

МетодПутьДоступ
GET/videos/{id}/commentsпо видимости, курсор
POST/videos/{id}/commentsтокен, тело {"body": "...", "parent_id": null}
GET/comments/{id}/repliesпо видимости, курсор
PATCH/comments/{id}только автор
DELETE/comments/{id}автор или владелец видео
PUT/comments/{id}/likeтокен
DELETE/comments/{id}/likeтокен

Длина текста — 1–10 000 символов после обрезки пробелов. Владелец видео может удалять чужие комментарии под своим роликом, но не править их. Правка проставляет edited_at — по нему и рисуется метка «изменено»; updated_at для этого не годится, его двигают и служебные пересчёты счётчиков.

Ответы одноуровневые. GET /videos/{id}/comments отдаёт только верхний уровень с reply_count у каждого; ветка разворачивается отдельным запросом /comments/{id}/replies (там старые сверху — ветку читают как разговор). parent_id должен указывать на корневой комментарий этого же видео: ответ на ответ — 400, чужой корень — 404. Удаление корня уносит ветку целиком.

?sort=top сортирует верхний уровень по лайкам вместо свежести. Лайк идемпотентен: повторное нажатие не удваивает счётчик. liked_by_me в ответе показывает собственный голос, для анонима он null.

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

Каналы и подписки

МетодПутьДоступОписание
GET/channels/{id}всеКарточка канала
GET/channels/by-username/{username}всеОна же по имени из ссылки
GET/channels/{id}/videosвсеПубличные видео, курсор
PATCH/me/channelтокенПравка своего профиля
PUT/channels/{id}/subscriptionтокенПодписаться / колокольчик
DELETE/channels/{id}/subscriptionтокенОтписаться
GET/subscriptionsтокенМои подписки, offset
GET/feedтокенЛента подписок, курсор

Идентификатор канала — это channel.id из карточки видео. Адресовать канал можно и по имени: /channels/by-username/neo или /channels/by-username/@neo — ведущая @ принимается, регистр не важен.

Подписка и отписка идемпотентны: повтор возвращает тот же 204. Подписка на себя — 400, на несуществующий канал — 404. Тело PUT необязательно; {"notifications_enabled": false} оставляет подписку, но выключает уведомления о новых видео. Тем же запросом колокольчик и переключают.

Правка профиля

PATCH /me/channel
{
  "display_name": "Нео",
  "description": "про матрицу",
  "about": "длинный текст вкладки «о канале»",
  "avatar_url": "https://...",
  "banner_url": null,
  "country": "UZ",
  "links": [{"label": "Сайт", "display": "example.com", "url": "https://example.com"}]
}

Все поля необязательны. Отсутствие поля означает «не трогать», явный null — «очистить»; строка из одних пробелов равносильна null. links заменяется целиком (до 10 штук). Адрес ссылки принимается только со схемой http://, https:// или mailto: — он уходит в href на клиенте, и javascript: там был бы XSS. username через этот запрос не меняется: он адресует канал.

Ответ — та же карточка канала, что и у GET /channels/{id}.

Поиск

GET /videos/search?q=прогулка+по+городу&limit=20&offset=0

Ищет по заголовку и описанию публичных готовых видео; заголовок весит больше. Запрос разбирается как в поисковике: кавычки задают точную фразу, -слово исключает. Пустой q400. Результаты отсортированы по релевантности, затем по свежести.

История просмотров

МетодПутьДоступОписание
PUT/videos/{id}/progressтокенСохранить позицию
GET/videos/{id}/progressтокенГде остановились
GET/me/historyтокенИстория, курсор
DELETE/me/history/{id}токенУбрать одно видео
DELETE/me/historyтокенОчистить целиком
PUT /videos/dQw4w9WgXcQ/progress
{"position_secs": 42.5, "watched_delta_secs": 10, "completed": false}

watched_delta_secsприращение с прошлого отчёта, а не «сколько всего»: суммарного времени клиент не знает, смотреть могли и с другого устройства. Отправляйте отчёт раз в 10 секунд воспроизведения, а не на каждый тик.

completed только взводится: досмотрев ролик и перемотав назад, зритель не делает его недосмотренным. Порог «досмотрено» решает клиент — он знает и длительность, и то, что для тридцатисекундного ролика и двухчасового фильма это разный запас.

GET /videos/{id}/progress отвечает 404, если это видео ещё не смотрели: «нечего возобновлять» — отсутствие записи, а не нулевой прогресс, который пришлось бы отличать от настоящего нуля.

Строка /me/history — это карточка видео плюс поле progress:

{
  "id": "dQw4w9WgXcQ",
  "title": "...",
  "channel": {},
  "progress": {
    "position_secs": 42.5,
    "watched_secs": 60,
    "completed": false,
    "last_watched_at": "2026-09-07T10:00:00Z"
  }
}

Удалённые и ставшие приватными чужие видео из истории выпадают сами.

Плейлисты

МетодПутьДоступОписание
POST/playlistsтокенСоздать
GET/playlists?owner_id=по видимостиБиблиотека, offset
GET/playlists/{id}по видимостиКарточка
PATCH/playlists/{id}владелецПравка
DELETE/playlists/{id}владелецУдалить
GET/playlists/{id}/itemsпо видимостиСодержимое по порядку, offset
POST/playlists/{id}/itemsвладелецДобавить видео в конец
PUT/playlists/{id}/items/{video_id}владелецПереставить
DELETE/playlists/{id}/items/{video_id}владелецУбрать
GET/videos/{id}/playlistsтокенВ каких моих плейлистах уже есть

GET /playlists без owner_id показывает свою библиотеку целиком; с чужим owner_id — только публичные плейлисты этого канала. Без токена и без owner_id401: чья библиотека, неизвестно.

Повторное добавление того же видео — 409, а не второй экземпляр. Чужое приватное видео в плейлист не кладётся (404): это был бы обход приватности. Наоборот тоже: приватные видео не отдаются из чужого публичного плейлиста.

Позиции плотные и считаются с нуля. PUT .../items/{video_id} с {"position": 0} двигает видео в начало, сдвигая остальных; позиция за пределами списка прижимается к краю, и ответ говорит, куда видео встало на самом деле. Удаление подтягивает позиции за собой, дыр не остаётся.

cover_video_id — короткий id первого видео плейлиста; обложку карточки рисуйте из /videos/{cover_video_id}/thumbnail.

GET /videos/{id}/playlists возвращает массив UUID — это галочки в меню «сохранить в плейлист».

Уведомления

МетодПутьОписание
GET/notifications?unread=trueЯщик, курсор
GET/notifications/unread-countЦифра на колокольчике
POST/notifications/{id}/readОтметить прочитанным
POST/notifications/readОтметить прочитанным весь ящик
DELETE/notifications/{id}Убрать из ящика

Все требуют токен. Уведомления создаются сами:

Чужое уведомление отвечает 404 и на отметку, и на удаление.

Жалобы

МетодПутьДоступ
POST/videos/{id}/reportтокен
GET/me/reportsтокен, offset
POST /videos/dQw4w9WgXcQ/report
{"reason": "spam", "details": "сплошная реклама"}

reason — из перечисления ReportReason; неизвестное значение даёт 422. Для other пояснение обязательно (400 без него), в остальных случаях — необязательно, до 1000 символов. Повторная жалоба того же человека на то же видео — 409: форму отправляют дважды случайно, и это не должно выглядеть как две жалобы.

Разбор жалоб живёт вне API: status меняет модератор, клиент видит его в /me/reports.

Служебное

МетодПутьОписание
GET/healthЖив ли процесс
GET/health/readyДоступны ли БД и хранилище (503, если нет)
GET/metricsЭкспозиция Prometheus
GET/docsЭтот документ страницей для браузера
GET/docs.mdОн же исходником в Markdown, для инструментов

Типовой сценарий

  1. POST /auth/register или /auth/login → сохранить обе половины пары; по истечении expires_inPOST /auth/refresh.
  2. GET /videos?sort=trending&limit=20 → лента; листать по next_cursor. Карточка канала уже внутри каждого элемента.
  3. GET /videos/{id} + GET /videos/{id}/playback → карточка и плеер, POST /videos/{id}/view один раз на старте, GET /videos/{id}/progress → предложить продолжить с сохранённой позиции.
  4. GET /videos/{id}/comments, GET /videos/{id}/reaction, GET /videos/{id}/related → обсуждение и колонка «дальше».
  5. Во время воспроизведения — PUT /videos/{id}/progress раз в 10 секунд.
  6. Загрузка: POST /videosPUT upload_urlPOST /complete, дальше опрашивать GET /videos/{id}, пока status не станет ready (транскодинг идёт в фоне, уведомлений о его ходе нет).

Чего в API нет

Об этом стоит знать заранее, чтобы не искать: