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: 'В эфире',