Тема
ТЗ — Видеохостинг (cms/video-hosting)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Встраивание видео с внешних хостингов (VK Video, YouTube, Rutube) в контент сайта: блок «видео» с lazy-load плеера, oEmbed-резолв метаданных и превью в медиатеке без загрузки самого плеера до клика пользователя.
- Блок «видео» (BlockRegistry) — вставка по ссылке на VK Video/YouTube/Rutube
- Lazy-load: до клика пользователя грузится только превью-картинка, не iframe-плеер
- oEmbed-резолв метаданных (заголовок, превью, длительность) при добавлении ссылки
- Локальное превью в медиатеке — картинка сохраняется в
cms/storage-s3, не тянется с хостинга каждый раз - Кеш метаданных oEmbed — повторное добавление той же ссылки не делает новый запрос
- Фоновая проверка битых видео (удалённых/недоступных на хостинге) с отчётом редактору
- Декларация доменов провайдеров для CSP (
frame-src) через канонический фильтр FilterBussecurity.csp— модуль не расширяет CSP сам
Зависимости и выключение
requires: ядро (cms/core-contracts, BlockRegistry), cms/integrations-bus, cms/storage-s3 · suggests: cms/cookie-consent (выбор cookie-домена YouTube по состоянию согласия)
Поведение при выключении: ранее вставленные блоки видео на страницах перестают резолвить новые ссылки (существующие превью показываются из кеша) — редакторы не могут добавлять новые видео-блоки до включения модуля. Kill-switch резолва (см. «Настройки») даёт более мягкую степень деградации, чем полное выключение модуля: превью и уже резолвленные видео продолжают отдаваться, останавливается только обращение к внешним API.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_video_hosting_embeds | id, provider, external_id, url, title, preview_path, duration_seconds, width, height, resolve_status, resolve_error, last_checked_at | резолвленные видео с локальным превью |
Индексы: provider — PHP Enum; уникальный индекс на provider+external_id; индекс на resolve_status (выборка битых видео для отчёта); last_checked_at — под батч-джобу периодической проверки. width/height — из oEmbed, нужны фронтенду для aspect-ratio placeholder (см. «Производительность и кеш»).
ПДн-паспорт: модуль ПДн не хранит — ссылка на видео, заголовок, превью и метаданные хостинга не являются персональными данными; хуки «выгрузить/забыть по субъекту» не применимы (декларация, не реализация).
Входные и выходные данные
Всё, что не перечислено ниже, модуль отвергает (whitelist-принцип §11 стандарта).
Входы:
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
| Редактор страниц (вставка блока «видео») | url | FormRequest: формат URL провайдера из enabled_providers (regex на домен/id по каждому провайдеру), провайдер вне whitelist — отклонено |
oEmbed-ответ провайдера (через cms/integrations-bus) | title, thumbnail_url, html, width, height, duration | схема ответа провайдера валидируется перед сохранением; thumbnail_url — домен из whitelist провайдера перед скачиванием в cms/storage-s3 |
| Filament: настройки модуля | enabled_providers[], lazy_load, oembed_cache_ttl_days, max_videos_per_page, oembed_resolve_enabled, youtube_nocookie, embed_timeout_seconds | FormRequest, whitelist значений enabled_providers |
Legacy-импорт (cms:video-hosting:import-legacy) | старые <iframe>-вставки страниц донора | парсинг src по whitelist доменов провайдеров, извлечение external_id регэкспом провайдера (ReDoS-safe, ядро) |
Событие MediaDeleted (ядро, слушает) | media_id | если media_id совпадает с preview_path записи — запись помечается «превью отсутствует» |
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
| Рендер блока «видео» на странице | превью-картинка (fixed aspect-ratio по width/height) + play-иконка; после клика — iframe с whitelisted src | HTML, публичный, page-cache-friendly (блок не персонализирован) |
| Галерея/related-контент (внутри страницы) | title, duration_seconds, preview_path | JSON внутри данных блока |
Событие VideoEmbedResolved / VideoEmbedResolveFailed | provider, external_id[, url, error] | канал 1, для наблюдаемости/health |
Отчёт cms:video-hosting:check-broken --json | список страниц/блоков с недоступными видео | JSON/CSV, скачиваемый в Filament |
Канонический фильтр security.csp (ядро) | список доменов frame-src для активных enabled_providers | подписка на FilterBus-фильтр в boot(), не HTTP (ревизия ядра №2, п. 5) |
Настройки (группа video-hosting)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
video-hosting.enabled_providers | array | ["youtube","vk","rutube"] | нет | Разрешённые хостинги для блока видео |
video-hosting.lazy_load | bool | true | да | Загружать плеер только по клику |
video-hosting.oembed_cache_ttl_days | int | 30 | нет | Срок жизни кеша метаданных oEmbed |
video-hosting.max_videos_per_page | int | 20 | да | Лимит числа видео-блоков на одной странице (защита от злоупотребления и раздувания веса страницы) |
video-hosting.oembed_resolve_enabled | bool | true | нет | Kill-switch: отключить резолв новых oEmbed-ссылок без выключения модуля; уже резолвленные видео продолжают отдаваться из кеша |
video-hosting.youtube_nocookie | bool | true | да | Встраивать YouTube через youtube-nocookie.com (без трекинг-cookie до согласия) |
video-hosting.embed_timeout_seconds | int | 3 | нет | Таймаут синхронного резолва oEmbed при вставке ссылки в редакторе; при превышении — graceful fallback, ссылка сохраняется, метаданные подтягиваются фоновой джобой |
Секретов у модуля нет — публичные oEmbed-эндпоинты провайдеров не требуют авторизации.
API
Отдельного публичного API нет — админ-CRUD через Filament (блок видео настраивается в редакторе страниц ядра). Статус резолва конкретной вставки виден редактору прямо в форме блока (см. «UX-требования»); отчёт битых видео и остаток при исчерпании провайдерских лимитов (если включён YouTube Data API вместо чистого oEmbed) — в Filament-ресурсе модуля.
Компоненты
- Блоки: «видео» — схема полей
url(+ автоопределяемыйprovider), версия_v1, demo-props (по одному демо-видео на провайдера для галереи/_gallery); fallback при выключении модуля — существующее превью из кеша, без резолва новых ссылок. - Виджеты: нет — модуль работает только через блок контента.
- Filament: ресурс «Видео» (список резолвленных
cms_video_hosting_embeds, статус резолва, кнопка «обновить метаданные»), вкладка «Битые видео» с отчётом (check-broken) и списком затронутых страниц, скачиваемым CSV. - Команды:
cms:video-hosting:refresh-metadata --json(обновление устаревших метаданных),cms:video-hosting:check-broken --json(батч-проверка доступности, формирует отчёт),cms:video-hosting:import-legacy --source=<профиль> --dry-run(легаси-импорт, см. «Донорский код»). - Фронтенд-бюджет: до клика на странице — только
<img>превью (webp/avif) и инлайновая SVG play-иконка, JS модуля — лёгкий наблюдатель клика/видимости (IntersectionObserver), без загрузки SDK провайдера; после клика подгружаетсяiframeпровайдера — его вес вне бюджета модуля, но домен обязан быть в CSPframe-src(см. «Безопасность»). - A11y: play-кнопка — семантический
<button>сaria-label="Воспроизвести видео: {title}", доступна с клавиатуры (Tab+Enter/Spaceзапускает загрузку плеера, не только клик мышью). - Демо-контент: сидер с демо-видео по одному на провайдера — галерея
/_galleryи playground показывают блок без ручного ввода ссылок.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
VideoEmbedResolved | ссылка на видео успешно резолвлена через oEmbed | provider, external_id |
VideoEmbedResolveFailed | резолв метаданных завершился ошибкой | provider, url, error |
Резолв oEmbed и загрузка превью в cms/storage-s3 — через cms/integrations-bus.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
| BlockRegistry (ядро) | регистрация в реестре (bootstrap) | модуль → ядро | блок «видео» регистрируется, рендерится ядром внутри контента страницы |
cms/storage-s3 | прямой сервис-вызов (requires, канал 4) | модуль → storage-s3 | загрузка и хранение локального превью-картинки |
cms/integrations-bus | прямой сервис-вызов (requires, канал 4) | модуль → integrations-bus | вызов oEmbed API провайдера — единственный путь внешнего HTTP |
cms/health | health-чек (реестр манифеста) | модуль → health | доступность oEmbed по каждому провайдеру отдельно, отставание очереди video-hosting |
MediaDeleted (ядро, ревизия 14.07.2026) | событие, канал 1, слушает | ядро → модуль | превью удалено из медиатеки вручную → запись помечается «превью отсутствует», следующий refresh-metadata перезаливает |
security.csp (канонический фильтр FilterBus, ядро) | 2 — FilterBus, подписка при boot() | модуль → ядро | модуль дописывает frame-src-домены для активных enabled_providers в фильтр; ядро собирает итоговый CSP-заголовок (ревизия ядра №2, п. 5) |
cms/cookie-consent (suggests, если установлен) | provides-контракт / событие CookieConsentGiven | модуль ← cookie-consent | выбор cookie-домена YouTube (youtube.com vs youtube-nocookie.com) может учитывать состояние согласия; при отсутствии модуля — всегда youtube-nocookie.com по умолчанию |
Фоновая работа
Обновление устаревших метаданных oEmbed — фоновая команда cms:video-hosting:refresh-metadata по расписанию, именованная очередь video-hosting. Батч-проверка битых видео (check-broken) — отдельное периодическое задание той же очереди, чанками по last_checked_at (не сканирует всю таблицу разом). Резолв при добавлении ссылки в редакторе — синхронный вызов с таймаутом embed_timeout_seconds и graceful fallback (заглушка при недоступном/удалённом хостингом ролике, страница не падает); при таймауте блок сохраняется с resolve_status=pending, метаданные дорезолвливаются фоновой джобой. Массовое добавление видео (импорт страниц с десятками ссылок) — резолв метаданных батчем через очередь, не блокирует сохранение страницы ожиданием всех oEmbed-ответов.
Производительность и кеш
- Горячий путь — рендер блока видео на странице: при попадании в page-cache — 0 запросов (готовый HTML с превью); при промахе page-cache блок читает метаданные из тега
video-hosting:oembed:{provider}:{external_id}(0 запросов к БД на горячем пути), поход в БД — только на холодный кеш. - Тег
video-hosting:oembed— кеш метаданных oEmbed (TTLoembed_cache_ttl_days), предотвращает повторный внешний запрос для той же ссылки; инвалидируется событиямиVideoEmbedResolvedи вручную по кнопке «обновить метаданные» в Filament. - Lazy-load, вес страницы и CLS: до клика на странице грузится только превью (лёгкая картинка) — вес плеера провайдера (SDK, iframe) не входит в начальную загрузку страницы; обязателен fixed aspect-ratio placeholder на основе
width/heightиз метаданных oEmbed — без него вставка блока вызывает layout shift (CLS) при подгрузке превью, что бьёт по Core Web Vitals. - Ожидаемые объёмы: от единиц до низких тысяч записей
cms_video_hosting_embedsна сайт;max_videos_per_pageограничивает нагрузку одной страницы. - Индексы:
provider+external_id(уникальный, резолв по ссылке),resolve_status(выборка битых видео под отчёт),last_checked_at(чанкинг батч-проверки).
Безопасность
Плеер не загружается до клика пользователя (lazy-load) — снижает поверхность для стороннего кода хостинга на странице.
Недоступный/удалённый хостингом ролик не ломает страницу — отображается заглушка.
CSP
frame-src: встраиваниеiframeпровайдера требует явного whitelist доменов (youtube.com/youtube-nocookie.com,vk.com/vkvideo.ru,rutube.ru) вContent-Security-Policyстраницы — модуль дописывает нужные домены каноническим фильтром FilterBussecurity.csp(ревизия ядра №2, п. 5; см. «События и обмен»); самодельное расширениеframe-src *в коде модуля запрещено.Скан загрузок превью (ревизия ядра №2, п. 4): локальное превью попадает в медиатеку через
cms/storage-s3тем же путём, что и обычная загрузка файла, — на него распространяется контрактupload-scanner: при подключённой реализации превью проходит карантинpendingдо вердикта наравне с любым другим файлом; 0 реализаций — скан пропускается (штатная деградация ядра, модуль ничего не делает дополнительно).Режим без cookie (GDPR/152-ФЗ): YouTube по умолчанию встраивается через
youtube-nocookie.com(youtube-hosting.youtube_nocookie=true) — без трекинг-cookie до явного согласия; переключение наyoutube.comвозможно при интеграции сcms/cookie-consent. Для VK/Rutube аналогичного no-cookie домена нет — ограничение документируется явно; риск частично снят тем, что плеер (и его cookie) не грузится до клика пользователя.Ссылка вне
enabled_providersили некорректного формата отклоняется на входе с понятной ошибкой редактору — мусорный блок не сохраняется.Матрица ролей:
Permission админ менеджер редактор studio video-hosting.view(список видео, статусы резолва, отчёт битых видео)✓ ✓ ✓ ✓ video-hosting.manage(вставка блока, ручной пересчёт метаданных, запускcheck-broken)✓ — ✓ ✓ Изменение настроек модуля ( enabled_providers, CSP-влияющие параметры)✓ — — ✓ В логи и события не попадают ПДн (их у модуля нет) и секреты; идентификаторы —
provider+external_id, не сырой URL пользователя, где это уместно.
UX-требования
Админ:
- редактор видит статус резолва прямо при вставке ссылки в блок (успех — превью и заголовок подтянулись; ошибка — понятное сообщение «не удалось получить метаданные, видео сохранено, попробуем ещё раз в фоне»);
- список «Битые видео» в Filament — пустое состояние «битых видео не найдено», при наличии — таблица со страницами и кнопкой перехода к редактированию блока; подтверждение не требуется (просмотр, не деструктивная операция);
- массовое действие «обновить метаданные» для выбранных строк списка видео.
Посетитель:
- превью с play-иконкой видно сразу (без layout shift), плеер грузится только по клику или по видимости (если
lazy_loadдопускает предзагрузку по intersection — настройка явная, не поведение по умолчанию без документации); - при удалённом/недоступном видео — заглушка «видео больше недоступно» вместо битого iframe с ошибкой хостинга;
- play-кнопка доступна с клавиатуры (см. a11y в «Компоненты»).
Крайние случаи и типовые баги
- Видео удалено/недоступно на хостинге (автор удалил ролик на YouTube/VK/Rutube) → блок на странице показывает заглушку «видео больше недоступно», не битый iframe; периодическая батч-джоба
check-brokenформирует отчёт битых видео со списком затронутых страниц для редактора. - Embed-код и CSP → iframe встраивания требует явного whitelist доменов провайдера в
frame-src; модуль только дописывает нужные домены каноническим фильтромsecurity.csp, самодельногоframe-src *в коде модуля быть не должно. - GDPR-режим / встраивание без cookie → YouTube по умолчанию через
youtube-nocookie.comдо согласия (либо переключение по состояниюcms/cookie-consent); для VK/Rutube аналога нет — задокументированное ограничение, частично снятое тем, что cookie ставится только после клика по плееру. - Lazy-load без резерва места → отсутствие fixed aspect-ratio placeholder вызывает CLS при подгрузке превью — обязателен резерв по
width/heightиз метаданных oEmbed. - oEmbed-резолв меняет формат ответа / провайдер меняет API (например, VK меняет домен
vk.com→vkvideo.ru) → изолированный сбой резолва конкретного провайдера, не всего модуля; остальные провайдеры продолжают резолвиться штатно, алерт разработчику черезcms/health. - Ссылка некорректного формата/чужого провайдера (не из
enabled_providers) → валидация на входе отклоняет с понятной ошибкой редактору (422), мусорный блок не сохраняется. - Массовое добавление видео (импорт страниц с десятками видео-ссылок) → резолв метаданных батчем через очередь, не блокирует сохранение страницы ожиданием всех oEmbed-ответов; статус каждой ссылки —
pendingдо фоновой обработки. - Синхронный резолв превышает
embed_timeout_seconds→ блок сохраняется сresolve_status=pendingи graceful fallback вместо зависшего сохранения страницы, фоновая джоба дорезолвливает. - Достигнут
max_videos_per_page→ вставка следующего видео-блока отклоняется понятной ошибкой редактору («лимит видео на странице исчерпан»), не тихим обрезанием списка блоков. - Kill-switch
oembed_resolve_enabled=false→ новые ссылки не резолвятся (ошибка редактору «резолв временно отключён»), уже резолвленные видео продолжают отображаться из кеша без деградации публичной части сайта.
Донорский код
Донор: — (новая разработка).
Легаси-импорт (§16 стандарта): если у проекта есть донор со страницами, где видео уже встроено как сырой <iframe> (не через модуль), команда cms:video-hosting:import-legacy --source=<профиль> --dry-run парсит старые iframe-вставки по whitelist доменов провайдеров, извлекает external_id, создаёт блок «видео» на месте старой вставки и запись в cms_video_hosting_embeds; идемпотентна — повторный прогон обновляет по ключу provider+external_id, не дублирует. Отчёт: сколько iframe-вставок распознано/создано/пропущено (нераспознанный домен — пропуск с причиной в отчёте, не тихий проглот).
Тесты и приёмка
- [ ] Мок oEmbed-API: тест резолва метаданных для каждого поддерживаемого хостинга
- [ ] Плеер не загружается до клика пользователя (тест lazy-load — только превью в DOM)
- [ ] Кеш метаданных предотвращает повторный запрос oEmbed для той же ссылки
- [ ] Недоступный/удалённый хостингом ролик не ломает страницу — отображается заглушка
- [ ]
check-brokenформирует отчёт битых видео со списком затронутых страниц - [ ] Fixed aspect-ratio placeholder присутствует в разметке блока (контрактный тест на CLS)
- [ ] Ссылка вне
enabled_providers/некорректного формата отклоняется 422, блок не сохраняется - [ ] Достижение
max_videos_per_pageотклоняет вставку следующего блока с понятной ошибкой - [ ] Kill-switch
oembed_resolve_enabled=falseблокирует новый резолв, не трогает уже показанные видео - [ ]
frame-src-домены модуля корректно попадают в CSP через фильтрsecurity.csp(по активнымenabled_providers) - [ ] Превью, загружаемое в медиатеку через
cms/storage-s3, проходит карантинupload-scannerпри подключённой реализации; 0 реализаций — скан пропускается без ошибки - [ ]
youtube_nocookie=trueрендеритiframeнаyoutube-nocookie.com - [ ] Массовое добавление видео резолвится батчем в очереди, не блокирует сохранение страницы
- [ ] Legacy-импорт: парсинг тестовых iframe-вставок создаёт корректные блоки, повторный прогон не дублирует
- [ ] При выключении модуля существующие превью продолжают отображаться из кеша
- [ ] Матрица ролей:
video-hosting.manageдоступен редактору, изменение настроек — только admin/studio - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут; тестовая БД только
video_hosting_test,migrate:freshзапрещён