From 398b60facc12fabe0eb72d5b51a62cb0d027000b Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 30 Jul 2026 09:55:14 +0300 Subject: [PATCH] Refactor GridScheduleGenerator and related components to improve cursor management and group handling 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. --- .../Planning/GridScheduleGenerator.cs | 86 +++++++++++++++---- .../Templates/Generate/GridPlanner.cs | 14 ++- .../Templates/Generate/GroupCatalog.cs | 14 ++- .../Templates/Transfer/GridPromptText.cs | 2 + .../Programming/Planning/SchedulePlanner.cs | 23 +++-- .../Programming/GenerateGridTests.cs | 38 ++++++++ .../Programming/SchedulePlannerTests.cs | 31 +++++++ .../GridScheduleGeneratorIntegrationTests.cs | 40 +++++++++ docs/media-storage-and-streaming.md | 9 ++ docs/tv-scheduler-architecture.md | 26 +++++- .../channels/components/SchedulePreview.tsx | 13 +++ .../src/features/streaming/ChannelPlayer.tsx | 48 ++++++++++- frontend/src/shared/lib/locales/en.ts | 2 + frontend/src/shared/lib/locales/ru.ts | 2 + 14 files changed, 317 insertions(+), 31 deletions(-) diff --git a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs index 6228378..4060301 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs @@ -82,11 +82,13 @@ public sealed class GridScheduleGenerator( // Пересборка выбрасывает хвост, который курсоры уже «съели»: без отмотки каждое применение // проматывало бы библиотеку на горизонт вперёд, теряя серии, которые так и не вышли. - var aired = new Dictionary(); + // null здесь значит «отматывать нечего»: обычный тик ничего не сносил. + Dictionary? aired = null; if (rebuildFuture) { await DropFutureAsync(channelId, now, cancellationToken); aired = await LoadAiredPositionsAsync(channelId, cancellationToken); + await ResetUnairedCursorsAsync(template, aired, cancellationToken); } // Точка продолжения — конец последней сохранённой записи. При пересборке будущее уже удалено, @@ -179,7 +181,7 @@ public sealed class GridScheduleGenerator( template, from, horizonEnd, - new Dictionary(), + null, cancellationToken ); var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random); @@ -286,6 +288,29 @@ public sealed class GridScheduleGenerator( .ExecuteDeleteAsync(cancellationToken); } + /// + /// Забывает позиции слотов, за которыми не стоит ни одного отыгранного выхода. Состояние такого + /// слота набрано целиком из хвоста, который только что снесли, и хранить его дальше нельзя: + /// прогон его уже игнорирует, но обычный тик потом поднял бы то же самое из базы. + /// + private async Task ResetUnairedCursorsAsync( + ScheduleTemplate template, + IReadOnlyDictionary aired, + CancellationToken cancellationToken + ) + { + var slotIds = template.Layers.SelectMany(l => l.Slots).Select(s => s.Id).ToList(); + if (slotIds.Count == 0) + return; + + var states = await dbContext + .SlotStates.Where(s => slotIds.Contains(s.SlotId) && s.CurrentElementId != null) + .ToListAsync(cancellationToken); + + foreach (var state in states.Where(s => !aired.ContainsKey(s.SlotId))) + state.Reset(); + } + private async Task SaveCursorsAsync( IReadOnlyList cursors, CancellationToken cancellationToken @@ -316,13 +341,13 @@ public sealed class GridScheduleGenerator( } /// Куда слот доиграл в уцелевшей ленте — перебивает снимок состояния при - /// пересборке; пусто на обычном тике и в предпросмотре. + /// пересборке; null на обычном тике и в предпросмотре, где сносить нечего. private async Task BuildInputAsync( Channel channel, ScheduleTemplate template, DateTimeOffset startUtc, DateTimeOffset horizonEnd, - IReadOnlyDictionary aired, + IReadOnlyDictionary? aired, CancellationToken cancellationToken ) { @@ -400,21 +425,7 @@ public sealed class GridScheduleGenerator( : await LoadRepeatUnitsAsync(channel.Id, repeatWindow, startUtc, cancellationToken); var strategy = ToPlanningStrategy(SlotStrategy.FromJson(slot.StrategyJson)); - var cursor = - ( - aired.TryGetValue(slot.Id, out var position) - ? RewoundCursor(position, elements) - : null - ) - ?? ( - states.TryGetValue(slot.Id, out var state) - ? new PlanningCursor( - state.CurrentElementKind, - state.CurrentElementId, - state.NextUnitIndex - ) - : null - ); + var cursor = CursorFor(slot.Id, elements, aired, states); planned.Add( item, @@ -603,6 +614,43 @@ public sealed class GridScheduleGenerator( : null; } + /// + /// С какой позиции слот продолжает прогон. + /// + /// На обычном тике ( = null) это снимок состояния из базы. При пересборке + /// правда — только уцелевшая лента: снимок считает пройденным весь снесённый хвост, и доверять + /// ему нельзя. Слот, у которого в ленте не осталось ни одного отыгранного выхода, начинает + /// с начала: иначе повторное «Применить» на канале, который ещё не вышел в эфир, каждый раз + /// проматывало бы библиотеку на горизонт вперёд — сериал прыгал бы через серии, а франшиза + /// начиналась бы с третьей части. + /// + private static PlanningCursor? CursorFor( + Guid slotId, + IReadOnlyList elements, + IReadOnlyDictionary? aired, + IReadOnlyDictionary states + ) + { + PlanningCursor? stored = states.TryGetValue(slotId, out var state) + ? new PlanningCursor( + state.CurrentElementKind, + state.CurrentElementId, + state.NextUnitIndex + ) + : null; + + if (aired is null) + return stored; + + if (!aired.TryGetValue(slotId, out var position)) + return null; + + // Отыгранное есть, но той единицы в элементе больше нет (серию удалили, ассет перестал быть + // готовым). Отматывать некуда — пусть решает сохранённое состояние: слот в эфире уже был, + // и откат в начало группы был бы виден зрителю. + return RewoundCursor(position, elements) ?? stored; + } + /// Последняя запись слота в уцелевшей ленте: элемент и единица, которой он доиграл. private sealed record AiredPosition( GroupElementKind ElementKind, diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs index 17659b7..181ae08 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs @@ -99,8 +99,12 @@ public sealed class GridPlanner(IAppDbContext dbContext, GroupCatalog groups) .Sum(gap => gap.To - gap.From); // Аварийная группа отдаётся отдельным полем, а не замечанием: замечания — про то, что пошло - // не так, а это штатное действие, и в предпросмотре у него своя строка. - var fallback = candidates.MaxBy(g => g.UnitCount); + // не так, а это штатное действие, и в предпросмотре у него своя строка. Ролики сюда не идут + // по той же причине, что и в слот: дыру они закрывают рекламой, подписанной как программа, + // а по размеру группа роликов почти всегда самая большая на канале. + var fallback = candidates + .Where(g => g.DominantKind != ShowKind.Interstitial) + .MaxBy(g => g.UnitCount); var needsFallback = template.FallbackGroupId is null && fallback is not null; var everyDaySpan = EveryDaySpan(mode, profile); @@ -560,6 +564,12 @@ public sealed class GridPlanner(IAppDbContext dbContext, GroupCatalog groups) private static bool Allowed(GridBand band, GroupCandidate group) { + // Ролики — материал для стыков, а не для эфира (см. 6.7): слот по такой группе выдаёт + // в эфир полосу рекламы, подписанную как программа. Отсев жёсткий, до баллов: по размеру + // группа роликов бьёт любую библиотеку. + if (group.DominantKind == ShowKind.Interstitial) + return false; + // Группа без рейтингов проходит любой потолок — так же ведут себя фильтр набора и // планировщик: источники проставляют рейтинг далеко не всему. if (band.MaxAudience is { } max && group.Strictest is { } strictest && strictest > max) diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GroupCatalog.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GroupCatalog.cs index 7c963e3..c586db5 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Generate/GroupCatalog.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GroupCatalog.cs @@ -10,6 +10,10 @@ namespace TeleWave.Application.Programming.Templates.Generate; /// Группы канала глазами раскладки: сколько эфира каждая даёт, какой она природы и насколько строгий /// в ней контент. Отдельный тип, а не метод планировщика: те же факты нужны и запросу к ИИ, а считать /// их дважды значит однажды разойтись — сетка собиралась бы по одним числам, а ИИ предлагал по другим. +/// +/// Группы роликов отсюда не выбрасываются: они существуют, их видно в запросе к ИИ, и молчаливое +/// исчезновение группы из списка выглядело бы потерей. Не годятся они только в слот — это решает +/// раскладка по . /// public sealed class GroupCatalog( IAppDbContext dbContext, @@ -111,6 +115,12 @@ public sealed class GroupCatalog( // У коллекции своего типа нет: франшиза — это почти всегда полнометражки. var seriesUnits = resolved.Where(r => r.ShowKind == ShowKind.Series).Sum(r => r.UnitCount); + // Ролики распознаются первыми: группа рекламы даёт сотни коротких единиц, то есть самый + // большой UnitCount из всех, и записанная «полным метром» она побеждала бы по баллам + // в любой полосе без предпочтения по типу — в эфир уходила бы полоса рекламы. + var interstitialUnits = resolved + .Where(r => r.ShowKind == ShowKind.Interstitial) + .Sum(r => r.UnitCount); var duration = resolved.Aggregate(TimeSpan.Zero, (sum, r) => sum + r.TotalDuration); return new GroupCandidate( @@ -119,7 +129,9 @@ public sealed class GroupCatalog( composition.Count, units, resolved.Max(r => r.Audience), - seriesUnits * 2 >= units ? ShowKind.Series : ShowKind.Single, + interstitialUnits * 2 >= units ? ShowKind.Interstitial + : seriesUnits * 2 >= units ? ShowKind.Series + : ShowKind.Single, duration.TotalMinutes / units, primaryGenreId ); diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridPromptText.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridPromptText.cs index ad165af..d6c398b 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridPromptText.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridPromptText.cs @@ -29,6 +29,8 @@ public static class GridPromptText - Названия шоу в группах и коллекциях обязаны дословно совпадать с библиотекой ниже. Шоу импорт не создаёт: их приносит загрузка медиа, и придуманного названия в эфире не будет. - Стыки и заставки придумывать нельзя — только имена из списков ниже. + - Группы типа «ролик» — это реклама и джинглы для стыков. В слоты их ставить нельзя: + в эфир уйдёт полоса роликов, подписанная как программа. - Слоты внутри одного слоя не пересекаются. Слоты разных слоёв пересекаться могут: слой с большим priority перекрывает меньший (например «Выходные» поверх «Основной сетки»). - weekday: 0=воскресенье .. 6=суббота, либо null — слот идёт каждый день. Предпочитай diff --git a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs index c61f362..5ab7d56 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs @@ -475,23 +475,32 @@ public static class SchedulePlanner { // Шов до круглой отметки и до якоря по замыслу занимает реклама: «ещё одна серия вместо // рекламы» — первое, что выдаёт в эфире плейлист вместо телеканала. - var cursor = padFirst ? FillWithPad(from, until, run, slotId) : from; + var maxPad = run.Input.MaxPadDuration; + var cursor = padFirst ? FillWithPad(from, until, run, slotId, maxPad) : from; - cursor = FillWithBackground(cursor, until, run); - // Остаток, куда программа уже не влезает, — снова шов, и он тоже рекламный. - cursor = FillWithPad(cursor, until, run, slotId); + var afterBackground = FillWithBackground(cursor, until, run); + + // Остаток, куда программа уже не влезает, — снова шов, и он тоже рекламный. Потолок считается + // от последней программы, а не от каждого добора по отдельности: без фона оба добора сходятся + // встык, и в эфир вместо десяти минут рекламы уходит двадцать. + var left = afterBackground > cursor ? maxPad : maxPad - (cursor - from); + cursor = FillWithPad(afterBackground, until, run, slotId, left); return FillWithFallback(cursor, until, run, slotId); } - /// Ставит ролики и анонсы, пока влезают и пока не исчерпан потолок одного добора. + /// Ставит ролики и анонсы, пока влезают и пока не исчерпан . private static DateTimeOffset FillWithPad( DateTimeOffset from, DateTimeOffset until, PlanningRun run, - Guid? slotId + Guid? slotId, + TimeSpan budget ) { - var limit = Min(until, from + run.Input.MaxPadDuration); + if (budget <= TimeSpan.Zero) + return from; + + var limit = Min(until, from + budget); var cursor = from; var guard = 0; diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs index 44e2bb3..499405a 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs @@ -228,6 +228,44 @@ public class GenerateGridTests Assert.Equal("Сериалы", morning.GroupName); } + [Fact] + public async Task InterstitialGroup_GoesNeitherIntoSlots_NorIntoFallback() + { + // Группа рекламы — материал для стыков. По размеру она бьёт любую библиотеку, поэтому + // раньше выигрывала каждую полосу без предпочтения по типу и становилась аварийной: + // в эфир уходила полоса роликов, подписанная как программа. + var fixture = new TestDb(); + var series = Playable("Сериал", ShowKind.Series, 40, 25); + var films = Films(8, 100); + var ads = Enumerable + .Range(0, 60) + .Select(i => Playable($"Ролик {i}", ShowKind.Interstitial, 1, 1)) + .ToList(); + + var adsGroup = GroupOf("Реклама", [.. ads.Select(a => a.Show.Id)]); + var (channelId, _) = await SeedChannelAsync( + fixture, + [series.Show, .. films.Shows, .. ads.Select(a => a.Show)], + [.. series.Assets, .. films.Assets, .. ads.SelectMany(a => a.Assets)], + [ + GroupOf("Сериалы", series.Show.Id), + GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]), + adsGroup, + ] + ); + + var plan = await PlanAsync( + fixture, + channelId, + GridProfileKind.Mixed, + GridGenerationMode.Rebuild + ); + + Assert.DoesNotContain(plan.Slots, s => s.GroupId == adsGroup.Id); + Assert.NotNull(plan.FallbackGroupId); + Assert.NotEqual(adsGroup.Id, plan.FallbackGroupId); + } + [Fact] public async Task ChildrenBand_SkipsAdultGroup() { diff --git a/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs b/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs index 9c1cb58..bd0c8fc 100644 --- a/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs @@ -559,6 +559,37 @@ public class SchedulePlannerTests Assert.Contains(result.Items, i => i.SlotId == background.SlotId); } + [Fact] + public void Pad_CapCountsWholeSeam_WhenBackgroundHasNothingToPlay() + { + // Часовая дыра и пустой фоновый слой: добор до якоря и остаток перед ним сходятся встык, + // и без общего потолка в эфир ушло бы двадцать минут рекламы подряд вместо десяти. + var result = Run( + Input( + [Slot(T0.AddHours(1), 30, [Element(1, 30)], isAnchor: true)], + horizonHours: 2, + pad: [Ad(5)], + maxPad: TimeSpan.FromMinutes(10) + ) + ); + + // Считается самый длинный рекламный блок подряд: два добора по разные стороны от фона — + // это два шва, а сошедшиеся встык — один, и он потолком и ограничен. + var longest = TimeSpan.Zero; + var current = TimeSpan.Zero; + foreach (var item in result.Items.OrderBy(i => i.StartsAtUtc)) + { + current = + item.Kind == PlannedItemKind.Ad + ? current + (item.EndsAtUtc - item.StartsAtUtc) + : TimeSpan.Zero; + if (current > longest) + longest = current; + } + + Assert.Equal(TimeSpan.FromMinutes(10), longest); + } + [Fact] public void Pad_ClosesTailWhenProgrammeDoesNotFit() { diff --git a/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs index a5e57eb..56ca3b2 100644 --- a/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs +++ b/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs @@ -163,6 +163,46 @@ public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixtur Assert.Equal(aired.EpisodeIndex + 1, firstAfterRebuild.EpisodeIndex); } + /// + /// Канал, который ещё не вышел в эфир: применять сетку можно сколько угодно раз, и каждый раз + /// эфир обязан начинаться с начала. Отматывать здесь не к чему — отыгранного нет вовсе, — и без + /// сброса курсора второе применение начинало бы сериал с той серии, до которой курсор домотал + /// по только что снесённому хвосту. + /// + [SkippableFact] + public async Task Generate_Rebuild_StartsOver_WhenNothingHasAiredYet() + { + Skip.IfNot(fixture.Available, "Docker недоступен"); + + await using var seedDb = fixture.CreateContext(); + var world = await SeedAsync(seedDb, episodes: 100); + + await using var first = fixture.CreateContext(); + await Generator(first).GenerateAsync(world.ChannelId, Now, false, default); + + await using var beforeDb = fixture.CreateContext(); + // Ничего не отыграно: вся построенная лента — будущее. + Assert.Empty( + beforeDb.ScheduleEntries.Where(e => + e.ChannelId == world.ChannelId && e.StartsAtUtc < Now + ) + ); + Assert.True(beforeDb.SlotStates.Single(s => s.SlotId == world.SlotId).NextUnitIndex > 0); + + await using var rebuild = fixture.CreateContext(); + await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default); + + await using var verify = fixture.CreateContext(); + var firstProgram = verify + .ScheduleEntries.Where(e => + e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program + ) + .OrderBy(e => e.StartsAtUtc) + .First(); + + Assert.Equal(0, firstProgram.EpisodeIndex); + } + [SkippableFact] public async Task Generate_CollectionInGroup_StampsCollectionOnEntries() { diff --git a/docs/media-storage-and-streaming.md b/docs/media-storage-and-streaming.md index 15ca551..81c00a5 100644 --- a/docs/media-storage-and-streaming.md +++ b/docs/media-storage-and-streaming.md @@ -150,6 +150,15 @@ BuildLiveWindow(расписание, филлер, now, windowSegments, segment UTC; локальный пояс показывает фронтенд. Раздача встык — типичный случай без филлера; филлер подставляется только при пустом расписании. +**Плеер возвращается к живому краю сам.** Окно короткое, а вкладка в фоне живёт по другим правилам: +браузер душит таймеры, hls.js не успевает тянуть сегменты, видео доигрывает буфер и встаёт. Пока +зритель не смотрит, окно уходит вперёд на сколько угодно — и, вернувшись, он увидел бы программу, +которая в эфире давно кончилась. Поэтому отставание больше 30 секунд плеер закрывает прыжком на +живой край (`liveSyncPosition`, в нативной ветке — конец `seekable`), а не догоном: эфир общий +и неперематываемый, догнать его нельзя, можно только застать. Проверка — на каждом обновлении +медиаплейлиста и на возврате вкладки; после сетевого сбоя загрузка тоже возобновляется с края, +а не с позиции, на которой всё встало. + Это самая ответственная часть системы, покрывается юнит-тестами: границы записей, вставка рекламы, монотонность `MEDIA-SEQUENCE`, правка хвоста на лету, вывод «следующей серии» из расписания. Нагрузка на API при 10 зрителях — ~5 запросов/с за сегментами и ~5 за плейлистами, пренебрежимо. diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index 63e502c..6bf1ef1 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -365,7 +365,15 @@ Slot На самом краю остаётся `ScheduleTemplate.fallbackGroupId` и зацикленный аварийный ассет канала — на случай, когда пуст и фон. Только это и считается пост-проверкой «доля фона»: зацикленный филлер -в эфире выглядит поломкой, а фоновый слот — ночным блоком. +в эфире выглядит поломкой, а фоновый слот — ночным блоком. В расписании админки аварийный запас +подписан меткой: у его записей есть и шоу, и номер серии, и без метки залитая им дыра читается как +задуманная сетка. + +**Рекламный шов ограничен целиком, а не по частям.** Добор до круглой отметки и остаток, куда +программа уже не влезла, — это два конца одного шва, и потолок (`maxPadDuration`, по умолчанию +10 минут) считается на них общий. Когда фону нечего играть, они сходятся встык, и раздельный +потолок давал бы вдвое больше рекламы подряд, чем настроено. Поставил фон программу — шов после неё +начинается заново. Аварийная группа выбирается в карточке правил канала и снимается там же. Настройка обязана быть видимой: её проставляет ещё и автосборка сетки (если своя не выбрана), а сетка без слотов отдаёт @@ -437,6 +445,14 @@ SlotState элемент — из её трейса, позиция — поиском единицы в развёрнутом элементе (у коллекции серии нумеруются внутри каждой части, и `episodeIndex` как индекс в общей последовательности не годится). +Слот, за которым **не осталось ни одного отыгранного выхода**, начинает с начала, а сохранённое +состояние забывается (`SlotState.Reset`): оно целиком набрано из только что снесённого хвоста. +Случай не краевой, а обычный — так выглядит любой новый канал и любой добавленный слот, и без +сброса второе «Применить» до первого эфира начинало бы сериал с середины, а франшизу — с третьей +части. Отмотка не удалась иначе (единицы больше нет в элементе — серию удалили, ассет перестал быть +готовым) — остаётся сохранённое состояние: слот в эфире уже был, и откат в начало группы виден +зрителю. + Из этого следует: `SchedulerOptions.RetentionHours` (сейчас 24 часа) заменяется на `RetentionDays`, дефолт 90. Значение обязано покрывать максимальный `cooldownDays` среди правил канала и максимальный `daysAgo` среди слотов повтора — иначе остывание начнёт врать, а повторы находить нечего. Валидация @@ -1117,6 +1133,14 @@ seed = hash(channelId, date, slotId, occurrenceInDay) сутках. Не нашлось кандидата — полоса остаётся незакрытой, и это написано в предпросмотре, а не обнаруживается потом списком ошибок. +**Группы роликов в сетку не идут.** Отсев такой же жёсткий, как по рейтингу, и по той же причине: +ролики — материал для стыков (6.7), а не для эфира, и слот по такой группе выдаёт полосу рекламы, +подписанную в программе как обычная передача. Отсев обязателен, а не желателен: в группе роликов +сотни коротких единиц, то есть самый большой запас на канале, — по баллам она выигрывала бы любую +полосу без предпочтения по типу и становилась бы аварийной группой. Тип группы считает +`GroupCatalog` (`DominantKind = Interstitial`, когда роликов половина единиц и больше), поэтому его +же видит запрос к ИИ — там про это сказано отдельной строкой правил. + **Два режима.** «Заполнить дыры» застраивает только непокрытое время (дыры считаются тем же `GridCoverage`, что и в 5.1) и не трогает ручные слоты; интервалы, свободные во все семь дней, схлопываются в один слот «каждый день». «Собрать неделю с нуля» сносит все слоты шаблона и строит diff --git a/frontend/src/features/admin/channels/components/SchedulePreview.tsx b/frontend/src/features/admin/channels/components/SchedulePreview.tsx index 579b355..4d6a5e9 100644 --- a/frontend/src/features/admin/channels/components/SchedulePreview.tsx +++ b/frontend/src/features/admin/channels/components/SchedulePreview.tsx @@ -10,6 +10,19 @@ function EntryLabel({ entry }: Readonly<{ entry: ScheduleEntryDto }>) { if (entry.kind === 'Ad') return {t('air.ad')} + // Аварийный запас и конец вещания подписаны отдельно: у них есть и шоу, и номер серии, и без + // метки полоса роликов из аварийной группы читается как обычная программа — то есть как будто + // сетка так и задумана, хотя на деле это дыра. + if (entry.kind === 'Fallback' || entry.kind === 'SignOff') + return ( + + + {t(entry.kind === 'Fallback' ? 'air.fallback' : 'air.signOff')} + + {entry.showName ?? '—'} + + ) + if (entry.kind === 'Bumper') return ( diff --git a/frontend/src/features/streaming/ChannelPlayer.tsx b/frontend/src/features/streaming/ChannelPlayer.tsx index 0f967f8..4bb0ef7 100644 --- a/frontend/src/features/streaming/ChannelPlayer.tsx +++ b/frontend/src/features/streaming/ChannelPlayer.tsx @@ -47,6 +47,20 @@ function startPlaybackWithAudio( }) } +/** + * Насколько плеер вправе отстать от живого края, прежде чем его вернут туда прыжком. Порог заметно + * больше окна плейлиста: обычное отставание — это секунды буфера, и дёргать зрителя из-за них + * нельзя. Догонять ускорением тут нечего — отставание набирается минутами, а не секундами. + */ +const MaxDriftSeconds = 30 + +/** Живой край в шкале плеера: у hls.js он свой, у нативного HLS — конец seekable-диапазона. */ +function liveEdgeOf(video: HTMLVideoElement, hls: Hls | null): number | null { + if (hls) return hls.liveSyncPosition ?? null + const { seekable } = video + return seekable.length > 0 ? seekable.end(seekable.length - 1) : null +} + /** * HLS-плеер линейного канала. Это живой эфир: ни перемотки, ни паузы — только звук, громкость и * полноэкранный режим. Cookie tw_stream уже выдана к монтированию. @@ -120,8 +134,29 @@ export function ChannelPlayer({ const startPlayback = () => startPlaybackWithAudio(video, audioRef.current, setMuted) + /** + * Возвращает плеер к живому краю, если он отстал. Отставание копится, пока вкладка в фоне: + * браузер душит таймеры, hls.js не успевает тянуть сегменты, а видео доигрывает буфер. Вернувшись + * на вкладку, зритель видит программу, которая в эфире давно кончилась, — для линейного канала + * это поломка, а не задержка: догонять эфир нельзя, его можно только застать. + */ + const syncToLive = () => { + const edge = liveEdgeOf(video, hls) + if (edge === null || !Number.isFinite(edge)) return + if (edge - video.currentTime <= MaxDriftSeconds) return + + video.currentTime = edge + if (video.paused) void video.play().catch(() => undefined) + } + // Слушатели нативной ветки — держим ссылки, чтобы снять их в cleanup (симметрично hls.destroy()). const onNativeError = () => onUnavailable?.() + // Возврат на вкладку — единственный момент, когда отставание можно поймать в нативной ветке: + // своего события «плейлист обновился» она не даёт. + const onVisible = () => { + if (!document.hidden) syncToLive() + } + document.addEventListener('visibilitychange', onVisible) if (Hls.isSupported()) { hls = new Hls({ liveSyncDurationCount: 3, enableWorker: true, lowLatencyMode: false }) @@ -129,15 +164,25 @@ export function ChannelPlayer({ hls.loadSource(src) hls.attachMedia(video) hls.on(Hls.Events.MANIFEST_PARSED, startPlayback) + // Проверяем дрейф на каждом обновлении медиаплейлиста, а не только по возврату на вкладку: + // так же чинится залипание после сетевого сбоя, когда буфер доигрался, а окно ушло вперёд. + hls.on(Hls.Events.LEVEL_UPDATED, syncToLive) // Живой эфир: краткий сетевой сбой сегмента или media-ошибку сперва пробуем восстановить // (hls.js рекомендует startLoad / recoverMediaError), и только исчерпав попытки — уходим в offline. let recoverAttempts = 0 + // Счётчик про подряд идущие неудачи: за долгий просмотр три разных сбоя — это норма, и гасить + // из-за них канал нельзя. Успешно доехавший сегмент означает, что сбой позади. + hls.on(Hls.Events.FRAG_BUFFERED, () => { + recoverAttempts = 0 + }) hls.on(Hls.Events.ERROR, (_event, data) => { if (!data.fatal) return if (data.type === Hls.ErrorTypes.NETWORK_ERROR && recoverAttempts < 3) { recoverAttempts += 1 - hls?.startLoad() + // −1 — «продолжить с живого края»: окно за время сбоя ушло вперёд, и возвращаться + // к позиции, на которой всё встало, значит доигрывать вчерашний эфир. + hls?.startLoad(-1) return } if (data.type === Hls.ErrorTypes.MEDIA_ERROR && recoverAttempts < 3) { @@ -161,6 +206,7 @@ export function ChannelPlayer({ return () => { hls?.destroy() hlsRef.current = null + document.removeEventListener('visibilitychange', onVisible) video.removeEventListener('loadedmetadata', startPlayback) video.removeEventListener('error', onNativeError) } diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index 22078d8..dfc4be1 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -58,6 +58,8 @@ export const en = { next: 'Up next', ad: 'Ad', bumper: 'Bumper', + fallback: 'Emergency filler', + signOff: 'Sign-off', episode: 'Episode', collectionPart: 'part {{part}} of {{total}}', volume: 'Volume', diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index 7b042da..756cc51 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -58,6 +58,8 @@ export const ru = { next: 'Далее', ad: 'Реклама', bumper: 'Заставка', + fallback: 'Аварийный запас', + signOff: 'Конец вещания', episode: 'Серия', collectionPart: 'часть {{part}} из {{total}}', live: 'В эфире',