# Хранение медиа и линейный эфир

Проектное решение для следующего этапа TeleWave: библиотека контента, автоматическое
программирование каналов и раздача линейного эфира. Документ описывает согласованный дизайн —
реализации пока нет.

## Что строим

Админ работает не со слотами вручную, а с **библиотекой шоу** и **правилами канала**:

1. Загружает контент в библиотеку: **шоу** (сериал с упорядоченными сериями либо полнометражка) и
   **рекламные врезки**. Загруженное переиспользуется — одно шоу можно ставить на разные каналы.
2. Создаёт **канал** (например «Мультики»), добавляет в него несколько шоу с **весами** и размером
   **блока** (сколько серий подряд крутить за раз), плюс пул рекламы и политику её вставки.
3. **Планировщик** сам строит расписание на несколько дней вперёд: взвешенно-случайно выбирает
   шоу, ставит блок из K серий подряд (продолжая сериал с того места, где он остановился), между
   блоками/сериями вставляет рекламу — и так встык, без дыр.

Пример эфира «Мультиков»: 3 серии Симпсонов подряд → реклама → 2 серии другого шоу → реклама →
полнометражка → реклама → снова Симпсоны (следующие невиденные серии) — и дальше по кругу.

## Принятые решения

| Вопрос | Решение |
| --- | --- |
| Модель канала | Линейное вещание; программы идут встык, расписание строит система |
| Доставка | Пре-сегментированный HLS; эфирный `m3u8` генерируется математикой, ffmpeg в рантайме не запущен |
| Программирование | Взвешенная случайная ротация шоу; блок подряд за выбор |
| Размер блока | На выбор: по числу серий (K) или по времени (~M минут, набор серий по их длине) |
| Марафоны / override | Отдельная сущность на окне [от, до]: подменённые веса или эксклюзив шоу |
| Библиотека | Общая: шоу и реклама переиспользуются между каналами |
| Порядок серий | Серии сериала идут строго по порядку; «следующая» выводится из хвоста расписания |
| Конец сериала | Дошли до последней серии — по кругу с первой |
| Реклама | Пул на канале, политика вставки настраивается (между блоками / между сериями) |
| Дыры в эфире | В норме их нет (встык); филлер — аварийная подстраховка |
| Длина сегмента | 2 секунды — вход в эфир ≈6с |
| Исходники | Разношёрстные (mkv/avi, HEVC, AC3) — нормализация транскодом обязательна |
| Наполнение | Загрузка через админку **и** ручная укладка в `inbox/` со сканером |
| Путь хранилища | Корень — в env, относительные подпути — в БД |
| Качество | Одно: 1080p, ~4 Мбит/с |
| Оригиналы | Удаляются после успешной нарезки |
| Защита потока | Отдельная httpOnly cookie с ограниченным `Path` |
| Обработка | Сразу после загрузки, с `nice` и `-threads 3` |
| Масштаб | 1–5 каналов, до 10 одновременных зрителей |

## Доменная модель наполнения

Всё в стиле существующего `RefreshToken`: rich model, приватные сеттеры, фабрики, поведенческие
методы.

**Domain**

- `Media/MediaAsset` — один видеофайл. Статус (`Pending|Processing|Ready|Failed`), точная
  длительность (кратная 2с), `SegmentCount`, относительный путь, метаданные (разрешение, кодеки,
  битрейт), исходное имя файла. Роль (серия/реклама) определяется тем, откуда на него ссылаются, а
  не полем самого ассета; для фильтрации в админке допустима мягкая метка `kind`.
- `Library/Show` — переиспользуемое шоу в общей библиотеке. Имя, описание, `ShowKind`
  (`Series` | `Single`), **упорядоченный** список серий (`ShowEpisode`: позиция + `MediaAssetId`).
  Полнометражка — это `Single` с одной серией.
- `Broadcast/Channel` — slug, название, вкл/выкл, `EpochUtc`, политика рекламы
  (`AdInsertion: BetweenBlocks | BetweenEpisodes`, `AdsPerBreak`), `FillerAssetId` (подстраховка).
- `Broadcast/ChannelShow` — связка канал↔шоу: `Weight` (частота в случайном выборе), `BlockMode`
  (`Count` | `Duration`) и `BlockValue` (число серий K либо бюджет в минутах M), вкл/выкл, а также
  `NextEpisodeIndex` — **персональный для этого канала курсор серий**. Для `Single` блок = 1 серия.
  «Шоу по одной серии» = `Count`/K=1.
- `Broadcast/ChannelAd` — связка канал↔рекламный ассет: пул врезок канала.
- `Broadcast/ProgrammingOverride` — временный override на канале: окно `[StartsAtUtc, EndsAtUtc]`,
  режим (`Exclusive` — только это шоу; `Boost` — подменённые веса), ссылка(и) на шоу. На пересечении
  окна с генерируемым временем планировщик использует override вместо базовой ротации. Марафон на
  день — это `Exclusive`-override на 24ч с большим временным блоком. Повторяемость (напр. «каждую
  субботу») — задел на будущее, в первой версии override разовый.
- `Broadcast/ScheduleEntry` — **материализованная** запись расписания: канал, `MediaAssetId`,
  `StartsAtUtc`, `EntryKind` (`Program | Ad`), для программ — `ShowId` и номер серии (для EPG).
  Генерируется планировщиком на несколько дней вперёд.

**Курсоры серий и рекламы — персистентные** (`ChannelShow.NextEpisodeIndex`, `Channel.NextAdIndex`),
их двигает планировщик по мере постановки в расписание. Так серии идут по порядку и продолжаются
через дни, а курсор переживает чистку старых `ScheduleEntry` по ретеншну (вывод «из хвоста» этого не
гарантировал бы для редко играемых шоу). Заворот на первую серию — `(index + 1) % count`.

Компромисс: при **перегенерации** будущего хвоста (правка конфигурации канала) удалённые из будущего
серии уже «отмотаны» курсором вперёд и однократно пропускаются — для домашнего канала это незаметно,
зато код без сложной «перемотки» курсоров. Расширение горизонта (обычный тик) ничего не удаляет и
пропусков не даёт.

## Планировщик

Доменный сервис `SchedulePlanner` + `BackgroundService`, который держит у каждого включённого
канала расписание, покрывающее `now + HorizonDays` (например 3 дня). Тик раз в
`Scheduler__TickMinutes` дописывает хвост от времени окончания последней записи.

Генерация одного шага для канала (от `lastEnd`):

1. **Определить активную политику** на момент `lastEnd`: если время попадает в окно
   `ProgrammingOverride` — берём его (`Exclusive` фиксирует одно шоу, `Boost` подменяет веса), иначе
   базовую ротацию канала.
2. **Взвешенно-случайный выбор** включённого `ChannelShow` по действующим весам (`System.Random`,
   seed на генерацию логируется для воспроизводимости; отдельного состояния не требует, т.к.
   результат материализуется).
3. Набрать блок серий подряд от «следующей» (курсор `NextEpisodeIndex`), с заворотом на первую по
   достижении конца:
   - `BlockMode = Count` — ровно `BlockValue` серий;
   - `BlockMode = Duration` — добавлять серии, пока суммарная длительность не достигнет `BlockValue`
     минут (учитывается реальная длина каждой серии; последняя серия, переваливающая за бюджет,
     либо входит целиком, либо отсекается по правилу «ближе к бюджету» — фиксируем «входит целиком»,
     чтобы не резать серии).
   Каждую серию дописать как `Program` встык.
4. Вставить рекламу по политике канала: `BetweenBlocks` — после блока; `BetweenEpisodes` — после
   каждой серии. Взять `AdsPerBreak` врезок из ротации пула, дописать как `Ad`.
5. Повторять, пока не покрыт горизонт.

Ключевой инвариант — **встык**: `StartsAt` каждой следующей записи равен `StartsAt + Duration`
предыдущей. Так как каждая длительность кратна 2с (см. ниже), все старты автоматически кратны 2с
от эпохи — выравнивание на сегмент держится само собой, без часовой сетки и без дыр.

**Правка на лету.** После изменения весов/блоков/рекламы/состава шоу или добавления override админ
дёргает перегенерацию канала (`POST /api/admin/channels/{id}/regenerate`): удаляется ещё не
стартовавший хвост (записи с `StartsAtUtc >= now`), сохраняется текущая идущая программа, и хвост
достраивается заново от её конца. Обычный фоновый тик только **расширяет** горизонт (без удаления).
Override, начинающийся в будущем, подхватится и штатным тиком.

**Ретеншн.** Прошедшие `ScheduleEntry` старше `Scheduler__RetentionDays` удаляются. Окно длинное
(90 суток по умолчанию), потому что история показов берётся из самой ленты: по ней работают остывание
и потолок повторов, и окно обязано покрывать самое долгое правило канала. Сегменты ассетов при этом
не трогаются — ассеты переиспользуются, чистится только расписание.

**Дыры.** В норме их нет. Если у канала нет ни одного включённого шоу с готовыми сериями (или
планировщик отстал) — эфир играет `FillerAssetId` по кругу, пока не появится расписание.

## Ключевой инвариант: выравнивание на длину сегмента

Длина сегмента — 2 секунды (`Storage__SegmentSeconds`). При нарезке **длительность каждого ассета
дополняется до кратности 2с** (хвост добивается чёрным кадром). Тогда встык-программирование даёт
старты, кратные 2с, а `EXT-X-MEDIA-SEQUENCE = (now - epoch) / 2` монотонен по построению.

Без выравнивания хвосты программ были бы некратны сегменту, а счётчик `MEDIA-SEQUENCE` «съезжал» бы
относительно фактического числа сегментов — плееры реагируют пропуском или повтором сегмента. Плата
— до 2с чёрного экрана на стыке, практически незаметно.

## Построение эфирного окна

```
BuildLiveWindow(расписание, филлер, now, windowSegments, segmentSeconds)
    → (mediaSequence, discontinuitySequence, сегменты[])
```

Окно — 10 сегментов (20с). Идём назад от `now`, для каждого 2-секундного слота находим запись
расписания (программу или рекламу) и позицию сегмента в её ассете как `(now - StartsAt) / 2`. На
стыке записей ставим `EXT-X-DISCONTINUITY` (склейка разнородного контента и рекламы). Всё время —
UTC; локальный пояс показывает фронтенд. Раздача встык — типичный случай без филлера; филлер
подставляется только при пустом расписании.

**Плеер возвращается к живому краю сам.** Окно короткое, а вкладка в фоне живёт по другим правилам:
браузер душит таймеры, hls.js не успевает тянуть сегменты, видео доигрывает буфер и встаёт. Пока
зритель не смотрит, окно уходит вперёд на сколько угодно — и, вернувшись, он увидел бы программу,
которая в эфире давно кончилась. Поэтому отставание больше 30 секунд плеер закрывает прыжком на
живой край (`liveSyncPosition`, в нативной ветке — конец `seekable`), а не догоном: эфир общий
и неперематываемый, догнать его нельзя, можно только застать. Проверка — на каждом обновлении
медиаплейлиста и на возврате вкладки; после сетевого сбоя загрузка тоже возобновляется с края,
а не с позиции, на которой всё встало.

Это самая ответственная часть системы, покрывается юнит-тестами: границы записей, вставка рекламы,
монотонность `MEDIA-SEQUENCE`, правка хвоста на лету, вывод «следующей серии» из расписания.
Нагрузка на API при 10 зрителях — ~5 запросов/с за сегментами и ~5 за плейлистами, пренебрежимо.

## Раскладка на диске

Хост: `sdb` (700G) — один раздел ext4, смонтирован в `/srv/telewave/media`, проброшен в контейнер
как `/media` (см. `docs/server-storage-setup.md`).

```
/media
├── inbox/                  # ручная укладка файлов (SFTP/rsync), подбирается сканером
├── uploads/                # незавершённые загрузки из админки; регистрация уносит файл в originals/
├── originals/{assetId}/    # исходник; удаляется после успешной нарезки
└── assets/{assetId}/
     ├── seg00000.ts …      # сегменты строго по 2с
     └── index.m3u8         # VOD-плейлист ассета (превью в админке)
```

Сегменты — **MPEG-TS** (стыки через `EXT-X-DISCONTINUITY` без `EXT-X-MAP`). Ёмкость при 1080p
~4 Мбит/с: час ≈ 1.8 ГБ → **~390 часов** на 700G (оригиналы удаляются). Ассеты переиспользуются
многими каналами и записями расписания, поэтому библиотека растёт медленнее, чем «часы эфира».

## Обработка при загрузке

Единственное место, где работает ffmpeg. Исходники разношёрстные, `-c copy` не применим:

```bash
nice -n 10 ffmpeg -i src \
  -threads 3 \
  -c:v libx264 -preset veryfast -crf 21 -maxrate 4500k -bufsize 9000k \
  -vf "scale='min(1920,iw)':-2" \
  -force_key_frames "expr:gte(t,n_forced*2)" -sc_threshold 0 \
  -af "loudnorm=I=-16:TP=-1.5:LRA=11" \
  -c:a aac -b:a 128k -ac 2 -ar 48000 \
  -f hls -hls_time 2 -hls_playlist_type vod -hls_list_size 0 \
  -hls_segment_filename seg%05d.ts index.m3u8
```

- `scale='min(1920,iw)':-2` — не апскейлим то, что меньше 1080p.
- `-force_key_frames expr:gte(t,n_forced*2)` надёжнее фиксированного `-g` при нецелых fps.
- `-sc_threshold 0` убирает сцен-детект, иначе сегменты перестанут быть ровными.
- `maxrate 4500k` — ключевой кадр каждые 2с стоит ~5–10% битрейта.
- `loudnorm=I=-16:TP=-1.5:LRA=11` — нормализация громкости (EBU R128), чтобы все ролики звучали
  одинаково; цель настраивается (`Media__LoudnessTargetLufs`), можно выключить (`Media__NormalizeLoudness`).
- `-threads 3` + `nice` оставляют ядро эфиру. Финальный шаг — добивка хвоста до кратности 2с
  (`-af ...,apad=pad_dur=PAD -t TARGET`).

Очередь обработки: `System.Threading.Channels` + `BackgroundService`, строго одна задача за раз.
Inbox-сканер — второй `BackgroundService`: замечает новый файл, ждёт стабилизации размера, заводит
`MediaAsset` в `Pending` и ставит в очередь. Из inbox файлы попадают в библиотеку как отдельные
ассеты; привязка к шоу/сериям — уже действие админа в UI.

## Разбор фильмов

Сериал грузится пачкой файлов **в одно шоу** — этим занят ручной разбор `manual/`. У полнометражек
наоборот: файл — это отдельное шоу, и вручную заводить сорок штук никто не станет. Отсюда отдельный
разбор, у которого три входа и один общий код.

**Разбор имени** (`MovieNameParser`) даёт название и год. Порядок: сначала отрезается хвост релиза
по первому техническому маркеру (rip, кодек, разрешение, `by_...`), и только в остатке ищется год —
иначе «Edward Scissorhands by_IVAN@1990» дал бы название с ником релизера, а год из ника сошёл бы
за год выпуска. Год в начале имени годом не считается: «2012.2009.BDRip» — это фильм «2012» девятого
года. Первое слово маркером не считается («Extended Family» существует), а `web` и `rip` из списка
маркеров исключены — «Charlotte's Web» дороже лишнего хвоста. Папка релиза разбирается по имени
папки: оно чище, чем `video.mkv` внутри, а файлом берётся самый большой (остальное — сэмплы).

**Подбор** (`MovieMatcher`) ищет по названию среди полнометражек источника и решает, уверенное ли
это совпадение: нормализованное название сошлось дословно, год сошёлся с точностью до года (год
проката и год производства расходятся) и такой кандидат ровно один. Правило то же, что у массового
обогащения из 6.x, и по той же причине: чужой постер и чужой рейтинг у фильма в эфире хуже, чем их
отсутствие. Статусы строки — «совпадение точное», «проверьте», «не найдено», «уже в библиотеке»
(по идентификатору источника).

**Три входа, одно правило:**

- **Кнопка, файлы из `manual/`.** Таблица разбора: строку правят руками (транслитные имена вроде
  `Shou.Trumana` источник не находит — впишите название и перезапросите одну строку), затем импорт
  заводит шоу, применяет метаданные и отправляет файл в очередь.
- **Кнопка, файлы с диска.** Имена разбираются **до** загрузки: узнать, что половина строк не
  распозналась, после заливки десятков гигабайт — слишком поздно. Подтверждённые файлы грузятся
  по одному, и каждый заводится сразу после своего аплоада.
- **Автоматика inbox.** Сканер после регистрации ассета пробует опознать фильм и заводит шоу
  **только при уверенном совпадении** (`Media__InboxAutoMovies`, по умолчанию включено). Всё
  неуверенное молча остаётся ассетом без шоу и попадает в ту же таблицу разбора — автоматика
  обязана молчать там, где таблица показала бы жёлтым.

**Как называется заведённый фильм.** Поле «Название» в таблице — это строка поиска, а не имя шоу:
«Die Hard 2» там стоит ровно для того, чтобы найти «Крепкий орешек 2». В библиотеку попадает
название карточки источника, а то, что распозналось в имени файла, становится **оригинальным**
названием — по нему потом ищутся релизы и сходятся имена. Если источник отдал своё original title
(у TMDb это `original_title`/`original_name`), берётся оно. Уже проставленное оригинальное название
не переписывается: его мог вписать человек.

Шоу импорт создаёт, а **шоу-контент — никогда**: файл приносит загрузка, и придуманное источником
название не должно превращаться в запись библиотеки без файла. Франшизы отдельным шагом не
собираются: `FranchiseExternalId` проставляется вместе с метаданными, а коллекции предлагает
существующий `CollectionSuggestionBuilder` — там виден состав, и спин-оффы TMDb отсекает человек.

## Слои и API

**Application** — порты `IMediaStorage` (резолв путей, запись, удаление, свободное место),
`IMediaProcessor` (ffprobe + нарезка), `IMediaProcessingQueue`. Команды: `CreateShow`,
`AddEpisode`, `CreateChannel`, `AddChannelShow` (вес + режим блока), `SetAdPolicy`, `AddChannelAd`,
`CreateProgrammingOverride`, `DeleteProgrammingOverride`, `DeleteMediaAsset`, `RegenerateSchedule`,
`ScanInbox`. Запросы: `GetLivePlaylist`, `GetChannelEpg`, `ListChannels`, `ListShows`,
`ListMediaAssets`.

**Infrastructure** — `FileSystemMediaStorage`, `FfmpegMediaProcessor`, `SchedulePlanner`, фоновые
сервисы очереди, сканера и планировщика.

**Api** — публичное: `GET /api/channels`, `GET /api/channels/{slug}/live.m3u8`,
`GET /api/channels/{slug}/epg`, `GET /api/stream/{assetId}/{segment}.ts`. Админское:
`/api/admin/media/*`, `/api/admin/shows/*`, `/api/admin/channels/*`.

## Конфигурация

```
Storage__RootPath=/media
Storage__SegmentSeconds=2
Storage__LiveWindowSegments=10
Storage__KeepOriginals=false
Storage__MinFreeSpaceBytes=10737418240
Storage__StaleUploadHours=24
Media__FfmpegPath=/usr/bin/ffmpeg
Media__FfprobePath=/usr/bin/ffprobe
Media__MaxUploadBytes=21474836480
Media__InboxAutoMovies=true
Scheduler__HorizonDays=7
Scheduler__RetentionDays=90
Scheduler__TickMinutes=30
```

В БД хранятся только **относительные** пути (`assets/{id}`), никогда абсолютные. Смена корня — это
перемонтирование volume и рестарт, поэтому корню место в env, а не в настройках админки.
`IMediaStorage.Resolve(relative)` обязан проверять, что итоговый путь физически внутри корня —
часть путей приходит из имён загруженных файлов, это прямой вектор path traversal.

## Отдача и защита

Сегменты иммутабельны → `Cache-Control: public, max-age=31536000, immutable`. Плейлист →
`no-cache`. Отдача через `SendFileAsync`; отдельный nginx не вводится (нарушило бы единый
контейнер). Узкое место на 10 зрителях — сеть (~40 Мбит/с), не CPU.

Защита — короткоживущая httpOnly cookie `tw_stream` (`Path=/api/stream`, `SameSite=Strict`,
подписанный токен с id пользователя). Работает и с hls.js, и с нативным HLS в Safari/iOS, где
`Authorization` поставить некуда. Продлевается на каждом ответе плейлиста — а тот запрашивается
каждые ~2с, поэтому cookie живёт, пока зритель смотрит.

## Ресурсы сервера

Замеры на `tvvm`: **4 vCPU**, память динамическая до 8 ГБ, диск `sdb` 700G. Видеоадаптер —
`1234:1111` (эмулируемый QEMU/Bochs stdvga), в `/dev/dri` только `card0` без `renderD128`:
**аппаратное кодирование недоступно**, транскод только на CPU. `veryfast` 1080p при `-threads 3` —
~1.5–3× realtime (полуторачасовой фильм 30–60 мин в фоне).

Памяти при 8 ГБ достаточно. Но выделение динамическое, а .NET настраивает GC по памяти при старте,
поэтому в compose нужен явный `mem_limit` (например `3g`) — детерминированный cgroup-лимит вместо
плавающего значения хоста, заодно предсказуемый выбор OOM-killer. Загрузка стримится на диск без
буферизации тела в память: chunked-куски пишутся в `uploads/`, `complete` делает атомарный `move`
внутри той же ФС.

`uploads/` — перевалочный каталог: токен загрузки нигде не хранится, поэтому всё, что там осталось
после запроса, — мусор, на который никто не сослался. Убирается в два эшелона: недописанный файл
сносится на месте (обрыв заливки, отказ регистрации, сбой БД), а `UploadsCleanupBackgroundService`
раз в час подметает залежавшееся дольше `Storage__StaleUploadHours` (0 — уборку не делать) — это
страховка от аварийного рестарта посреди заливки. Порог считается от времени последней записи,
поэтому идущая многочасовая загрузка под него не попадает.

## Открытые эксплуатационные вопросы

- Удаление ассета/шоу запрещать, пока на них ссылается будущее расписание или пул канала.
- Свободное место: проверка перед загрузкой и индикация в админке
  (`DriveInfo.AvailableFreeSpace`), отказ ниже `Storage__MinFreeSpaceBytes`.
- Что показывать зрителю на границе рекламы в EPG (скрывать врезки или показывать «Реклама»).
