Высокая60 мин Кинопоиск, YouTube, RuTube

Видеоплатформа

Стриминг и рекомендации как Кинопоиск

  • Стриминг
  • Adaptive Bitrate
  • Рекомендации
  • Кэширование

Описание задачи

Вам предлагают спроектировать видеоплатформу уровня Кинопоиск, IVI или YouTube — сервис с каталогом видеоконтента, адаптивным стримингом, рекомендациями и пользовательскими взаимодействиями.

Видеоплатформа — одна из самых технически сложных задач на фронтенд System Design. Здесь пересекаются: тяжёлый медиа-пайплайн (HLS/DASH, адаптивный битрейт, DRM), сложный каталог (SEO, рекомендации), персонализация (история просмотра, предпочтения) и мультиплатформенность (web, Smart TV, мобильные).

Контекст задачи

Кинопоиск обслуживает миллионы пользователей: каталог из десятков тысяч фильмов и сериалов, каждый доступен в нескольких качествах (от 480p до 4K). Контент защищён DRM (Widevine, FairPlay). Пользователь может начать смотреть на телефоне в метро и продолжить на Smart TV дома — с того же момента.

На интервью уточните: «Это платформа с подпиской (SVOD) или с рекламой (AVOD)?» Это влияет на архитектуру — AVOD требует интеграции ad-insertion в видеопоток. Также спросите про географию — CDN-стратегия для России отличается от глобальной.

В этом разборе мы сфокусируемся на веб-версии, но обсудим подходы к мультиплатформенности.

Requirements Requirements — Сбор требований

Функциональные требования

  • Каталог фильмов и сериалов — главная страница с подборками, страница жанра, поиск. Каждый фильм: постер, название, год, рейтинг, описание.
  • Видеоплеер — воспроизведение с адаптивным битрейтом, управление (play/pause, seek, громкость, fullscreen), переключение качества, субтитры, аудиодорожки.
  • Поиск — полнотекстовый поиск по названиям, актёрам, режиссёрам. Автокомплит с постерами.
  • Рекомендации — «Похожие фильмы», «Вам понравится», «Продолжить просмотр», «Популярное».
  • Рейтинги и комментарии — оценка фильма (1–10), текстовые рецензии, сортировка по полезности.
  • Плейлисты и избранное — пользователь создаёт списки, добавляет фильмы, шарит ссылкой.

Нефункциональные требования

  • Быстрый старт видео — время от клика Play до первого кадра < 3 секунд. Это ключевая метрика UX для видеоплатформ.
  • Adaptive Bitrate Streaming (ABR) — плеер переключает качество в зависимости от скорости сети. Без буферизации и подвисаний.
  • 4K + HDR — поддержка высокого качества на совместимых устройствах.
  • DRM — защита контента. Widevine (Chrome, Android), FairPlay (Safari, iOS), PlayReady (Edge, Smart TV).
  • Offline-просмотр — скачивание фильмов для просмотра без сети (PWA + Service Worker).
  • Мультиплатформенность — web, мобильный web, Smart TV (Tizen, webOS). Общая бизнес-логика, адаптивный UI.
Совет: на интервью упомяните метрику «time to first frame» — это аналог LCP для видеоплатформ. Netflix и YouTube оптимизируют её до 1–2 секунд через preloading первых сегментов.

Architecture Architecture — Архитектура

Компонентное дерево

App
├── CatalogPage
│   ├── HeroCarousel           // промо-баннер с трейлером
│   ├── ContentRow[]           // горизонтальная карусель
│   │   └── ContentCard[]      // постер + hover-превью
│   └── GenreGrid              // плитка по жанрам
├── VideoPlayerPage
│   ├── Player                 // видеоплеер (изолированный модуль)
│   │   ├── VideoControls      // play, seek, volume, quality
│   │   ├── SubtitleOverlay    // субтитры
│   │   └── BufferIndicator    // индикатор загрузки
│   ├── VideoInfo              // название, год, рейтинг, описание
│   ├── EpisodeSelector        // для сериалов — сезоны и серии
│   ├── Comments               // комментарии и рецензии
│   └── Recommendations        // «Похожие фильмы»
├── SearchPage
│   ├── SearchInput            // с автокомплитом
│   └── SearchResults          // сетка результатов
└── ProfilePage
    ├── WatchHistory           // история просмотра
    └── Playlists              // плейлисты пользователя

Изоляция видеоплеера

Плеер — самый тяжёлый модуль (~200KB gzipped: HLS.js + DRM + UI). Его нужно полностью изолировать:

// Lazy load плеера только на странице видео
const Player = dynamic(() => import("@/modules/player/Player"), {
  ssr: false,
  loading: () => <PlayerSkeleton />,
});

Плеер не загружается на каталоге, поиске, профиле. Это сокращает initial bundle каталога на ~200KB.

Стратегия рендеринга

  • Каталог — SSR/ISR: SEO критичен, контент обновляется нечасто (новые фильмы — раз в неделю, рейтинги — реже). ISR с revalidate 300s.
  • Плеер — CSR: серверный рендеринг плеера бессмыслен. Lazy load + client-only.
  • Страница видео — гибрид: metadata (SEO) рендерится на сервере, плеер и комментарии — клиентские.

Video Pipeline

Клиентская часть видео-пайплайна:

User Click → Fetch Manifest (HLS/DASH)
  → DRM License Request
  → ABR: Select Initial Quality (based on bandwidth)
  → Download First Segments
  → Decode & Render First Frame
  → Continue Streaming (adaptive quality)

Библиотека hls.js обрабатывает HLS-стриминг в браузерах без нативной поддержки (Chrome, Firefox). Safari использует нативный HLS. Для DASH — dash.js или shaka-player (от Google, с встроенной DRM).

Data Model Data Model — Модель данных

Основные сущности

interface Video {
  id: string;
  title: string;
  description: string;
  posterUrl: string;          // вертикальный постер
  backdropUrl: string;        // горизонтальный фон
  trailerUrl?: string;        // URL превью-трейлера
  duration: number;           // в секундах
  releaseYear: number;
  rating: number;             // 0–10 (Кинопоиск-стиль)
  genres: Genre[];
  cast: CastMember[];         // актёры и роли
  director: string;
  ageRating: string;          // "16+", "18+"
  type: "movie" | "series";
  seasons?: Season[];         // только для сериалов
}

interface Season {
  number: number;
  episodes: Episode[];
}

interface Episode {
  id: string;
  number: number;
  title: string;
  duration: number;
  thumbnailUrl: string;
}

interface StreamManifest {
  videoId: string;
  qualities: Quality[];
  defaultQuality: string;     // "auto" или конкретное разрешение
  drmLicenseUrl: string;
  subtitles: Subtitle[];
  audioTracks: AudioTrack[];
}

interface Quality {
  resolution: string;         // "480p", "720p", "1080p", "4K"
  bitrate: number;            // kbps
  url: string;                // URL манифеста для этого качества
}

interface Subtitle {
  language: string;
  label: string;              // "Русские", "English"
  url: string;                // URL .vtt файла
}

Состояние просмотра

interface WatchProgress {
  videoId: string;
  currentTime: number;        // в секундах
  duration: number;
  percentage: number;         // 0–100 для progress bar
  lastWatched: string;        // ISO-8601
  completed: boolean;
}

interface UserPreferences {
  preferredQuality: "auto" | "480p" | "720p" | "1080p" | "4K";
  subtitlesLang: string | null;
  audioLang: string;
  autoplay: boolean;          // автозапуск следующей серии
  skipIntro: boolean;         // пропуск заставки
}

Где хранить состояние

  • Server state (React Query) — каталог, информация о фильме, рекомендации, комментарии. Кэшируется с staleTime: 5 * 60 * 1000.
  • Player state (локальный) — currentTime, isPlaying, volume, selectedQuality. Живёт только в компоненте Player, не в глобальном store.
  • Watch progress (IndexedDB + API) — сохраняется локально каждые 10 секунд, синхронизируется с сервером каждые 30 секунд. Это обеспечивает seamless переключение между устройствами.
  • User preferences (Cookie + API) — доступны на сервере (для SSR) и на клиенте.
Важный момент: watch progress — это данные, которые генерируются на клиенте с высокой частотой (каждые 10 секунд). Не отправляйте каждое обновление на сервер — батчите запросы и используйте IndexedDB как буфер.

Interface Interface — API и контракты

REST API Endpoints

// Каталог (главная страница с подборками)
GET /api/catalog?genre={genre}&year={year}&sort={sort}&page={page}
→ {
    collections: Collection[],  // подборки: "Новинки", "Популярное"
    totalCount: number
  }

// Информация о фильме
GET /api/videos/{id}
→ {
    video: Video,
    similarVideos: Video[],    // для блока рекомендаций
    userProgress?: WatchProgress
  }

// Манифест стриминга (требует авторизации)
GET /api/videos/{id}/stream
→ {
    manifestUrl: string,       // URL HLS-манифеста
    drmConfig: {
      server: string,          // URL лицензионного сервера
      headers: Record<string, string>
    },
    qualities: Quality[],
    subtitles: Subtitle[],
    audioTracks: AudioTrack[]
  }

// Рекомендации (персонализированные)
GET /api/videos/{id}/recommendations?limit=12
→ { videos: Video[] }

// Сохранение прогресса просмотра
POST /api/videos/{id}/progress
Body: { currentTime: number, duration: number }
→ { saved: true }

// Поиск
GET /api/search?q={query}&type={movie|series}&page={page}
→ { results: Video[], totalCount: number }

// Автокомплит поиска
GET /api/search/suggest?q={query}
→ { videos: Video[], actors: CastMember[] }

Контракт видеоплеера

Плеер — изолированный модуль с чётким API. Он ничего не знает о приложении вокруг:

interface PlayerProps {
  manifestUrl: string;
  drmConfig?: DRMConfig;
  startTime?: number;           // для «продолжить просмотр»
  subtitles?: Subtitle[];
  audioTracks?: AudioTrack[];
  autoplay?: boolean;
  onTimeUpdate?: (time: number) => void;
  onEnded?: () => void;
  onError?: (error: PlayerError) => void;
  onQualityChange?: (quality: string) => void;
}

Callback-based API вместо двустороннего binding: родительский компонент управляет плеером через props, плеер сообщает о событиях через callbacks. Это упрощает тестирование и переиспользование.

Обработка ошибок

  • DRM error — «Воспроизведение недоступно на этом устройстве», fallback на низкое качество без DRM (если лицензия позволяет).
  • Network error — retry с exponential backoff; после 3 попыток — «Проверьте подключение к интернету».
  • Geo-restriction — «Контент недоступен в вашем регионе» с рекомендациями альтернатив.

Optimizations Optimizations — Оптимизации

Adaptive Bitrate Streaming (ABR)

Ключевая технология видеоплатформ. Видео разбито на сегменты по 2–6 секунд, каждый доступен в нескольких качествах. Плеер измеряет bandwidth и переключает качество в реальном времени:

// Настройка hls.js с ABR
const hls = new Hls({
  startLevel: -1,              // -1 = авто, определяет quality по bandwidth
  capLevelToPlayerSize: true,  // не грузить 4K на маленьком экране
  maxBufferLength: 30,         // буфер 30 секунд вперёд
  maxMaxBufferLength: 60,
  abrEwmaDefaultEstimate: 500000,  // начальная оценка bandwidth (bps)
});

hls.loadSource(manifestUrl);
hls.attachMedia(videoElement);

capLevelToPlayerSize: true — важная оптимизация: если плеер 360px шириной (мобильный), нет смысла загружать 1080p. Экономит трафик и ускоряет загрузку.

Preload стратегия

Несколько уровней preloading для ускорения старта видео:

  • Hover preload — при наведении на карточку фильма загружаем манифест и первый сегмент видео (~500KB). К моменту клика первый кадр готов.
  • Метаданные — при открытии страницы фильма сразу запрашиваем /stream endpoint, не дожидаясь клика Play.
  • Next Episode — за 30 секунд до конца текущей серии начинаем загружать манифест следующей.

Skeleton UI и быстрый FCP

Каталог использует скелетоны вместо спиннеров. Скелетоны повторяют layout контента — пользователь воспринимает загрузку быстрее. Для карусели постеров — placeholder с blur-up эффектом:

// ContentCard с blur-up placeholder
<div className="relative aspect-[2/3] overflow-hidden rounded-lg">
  <img
    src={video.posterUrl}
    alt={video.title}
    loading="lazy"
    decoding="async"
    className="h-full w-full object-cover transition-opacity"
    style={{ backgroundImage: `url(${video.lqipUrl})`,
             backgroundSize: "cover" }}
  />
</div>

Code splitting

  • Плеер (~200KB) — загружается только на странице видео через dynamic import.
  • Комментарии — lazy load при скролле ниже плеера.
  • Модуль поиска — загружается при фокусе на поисковую строку.

Кэширование

  • Service Worker — кэширует каталог (HTML, постеры) для мгновенного открытия. Стратегия: stale-while-revalidate для каталога, cache-first для постеров.
  • IndexedDB — watch progress, предпочтения пользователя. Синхронизация с сервером в фоне.
  • Video segments — не кэшируем (DRM-сегменты одноразовые), но буферизируем 30–60 секунд вперёд.

Prefetch рекомендаций

Пока пользователь смотрит видео, в фоне загружаем рекомендации — они понадобятся после окончания фильма. Используем requestIdleCallback, чтобы не конкурировать с видеопотоком за bandwidth.

Итоги и ключевые trade-offs

Ключевые архитектурные решения

  • Изолированный плеер как отдельный модуль — 200KB не загружаются на страницах каталога. Trade-off: дополнительная задержка при первом открытии видео (~300ms на загрузку модуля). Митигация: prefetch модуля при навигации на страницу видео.
  • HLS.js вместо нативного video — нативный HTML5 video не поддерживает ABR и DRM в большинстве браузеров. HLS.js даёт контроль над качеством, буферизацией и аналитикой. Trade-off: +60KB к bundle, необходимость обработки edge cases (Safari использует нативный HLS).
  • Watch progress в IndexedDB + периодическая синхронизация — не отправляем каждые 10 секунд на сервер (слишком много запросов), но и не теряем прогресс при закрытии вкладки. beforeunload + navigator.sendBeacon для финального сохранения.
  • SSR для каталога, CSR для плеера — каталог индексируется поисковиками, плеер не нужен для SEO. На странице фильма metadata рендерится на сервере (JSON-LD для rich snippets), плеер — клиентский.

Что запомнить для интервью

  • Упомяните конкретные технологии: hls.js, shaka-player, Widevine, FairPlay. Это показывает реальный опыт работы с видео.
  • Метрика «time to first frame» — главный KPI видеоплатформы. Объясните, как оптимизируете её: preload manifest + первый сегмент, ABR starting level.
  • DRM — обязательная тема. Скажите: «Widevine для Chrome/Android, FairPlay для Safari/iOS, PlayReady для Edge. Shaka-player абстрагирует эти различия».
  • Не забудьте про мультиплатформенность: общая бизнес-логика (API-клиент, state management), адаптивный UI. Для Smart TV — упрощённый интерфейс с навигацией d-pad.
Частый вопрос интервьюера: «Как обеспечить бесшовное переключение между устройствами?» Ответ: «Watch progress сохраняется на сервере каждые 30 секунд. При открытии на новом устройстве запрашиваем /api/videos/{id} — он возвращает userProgress.currentTime. Плеер стартует с этой позиции через prop startTime».