Updated the GridScheduleGenerator to reset cursors for slots with no aired positions, ensuring proper state management during rebuilds. Introduced a new method, ResetUnairedCursorsAsync, to handle cursor resets effectively. Enhanced the handling of interstitial groups in the scheduling process, preventing them from being included in slots or fallback groups. Updated related tests to verify the correct behavior of these changes, ensuring robust scheduling logic and accurate group categorization.
337 lines
31 KiB
Markdown
337 lines
31 KiB
Markdown
# Хранение медиа и линейный эфир
|
||
|
||
Проектное решение для следующего этапа 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 (скрывать врезки или показывать «Реклама»).
|