diff --git a/ErsatzTV.Application/Playouts/Commands/TimeShiftOnDemandPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/TimeShiftOnDemandPlayoutHandler.cs index 83cf9429a..c6c128768 100644 --- a/ErsatzTV.Application/Playouts/Commands/TimeShiftOnDemandPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/TimeShiftOnDemandPlayoutHandler.cs @@ -1,16 +1,35 @@ +using System.Threading.Channels; +using ErsatzTV.Application.Channels; using ErsatzTV.Core; using ErsatzTV.Core.Interfaces.Scheduling; namespace ErsatzTV.Application.Playouts; -public class TimeShiftOnDemandPlayoutHandler(IPlayoutTimeShifter playoutTimeShifter) +public class TimeShiftOnDemandPlayoutHandler( + IPlayoutTimeShifter playoutTimeShifter, + ChannelWriter workerChannel) : IRequestHandler> { public async Task> Handle(TimeShiftOnDemandPlayout request, CancellationToken cancellationToken) { try { - await playoutTimeShifter.TimeShift(request.PlayoutId, request.Now, request.Force, cancellationToken); + List staleGuideChannels = await playoutTimeShifter.TimeShift( + request.PlayoutId, + request.Now, + request.Force, + cancellationToken); + + // the time shift rewrote stored PlayoutItem timestamps but not the cached XMLTV + // fragment; rebuild the guide for the shifted channel (and any mirrors of it) so a + // client tuning in doesn't see a stale timeline. this is a post-commit side effect + // (TimeShift already saved) so it runs on CancellationToken.None — a session token that + // cancels between the DB commit and this enqueue must not leave the guide stale + // (decisions.md api.postcommit-cancellation-none) + foreach (string channelNumber in staleGuideChannels) + { + await workerChannel.WriteAsync(new RefreshChannelData(channelNumber), CancellationToken.None); + } } catch (Exception ex) { diff --git a/ErsatzTV.Core/Interfaces/Scheduling/IPlayoutTimeShifter.cs b/ErsatzTV.Core/Interfaces/Scheduling/IPlayoutTimeShifter.cs index 254674ed1..d5e632ee2 100644 --- a/ErsatzTV.Core/Interfaces/Scheduling/IPlayoutTimeShifter.cs +++ b/ErsatzTV.Core/Interfaces/Scheduling/IPlayoutTimeShifter.cs @@ -2,5 +2,14 @@ namespace ErsatzTV.Core.Interfaces.Scheduling; public interface IPlayoutTimeShifter { - Task TimeShift(int playoutId, DateTimeOffset now, bool force, CancellationToken cancellationToken); + /// + /// Slides an on-demand playout's materialized timeline forward so the item the viewer had + /// reached is active again at . + /// + /// + /// The channel numbers whose cached XMLTV guide is now stale and should be rebuilt — the shifted + /// channel plus any channels that mirror it — when a non-zero shift was persisted; otherwise an + /// empty list. + /// + Task> TimeShift(int playoutId, DateTimeOffset now, bool force, CancellationToken cancellationToken); } diff --git a/ErsatzTV.Infrastructure/Scheduling/PlayoutTimeShifter.cs b/ErsatzTV.Infrastructure/Scheduling/PlayoutTimeShifter.cs index bac5f42e4..ddcaca5f8 100644 --- a/ErsatzTV.Infrastructure/Scheduling/PlayoutTimeShifter.cs +++ b/ErsatzTV.Infrastructure/Scheduling/PlayoutTimeShifter.cs @@ -15,7 +15,11 @@ public class PlayoutTimeShifter( ILogger logger) : IPlayoutTimeShifter { - public async Task TimeShift(int playoutId, DateTimeOffset now, bool force, CancellationToken cancellationToken) + public async Task> TimeShift( + int playoutId, + DateTimeOffset now, + bool force, + CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); @@ -39,7 +43,7 @@ public class PlayoutTimeShifter( { if (playout.Channel.PlayoutMode is not ChannelPlayoutMode.OnDemand) { - return; + return []; } if (!force && segmenterService.IsActive(playout.Channel.Number)) @@ -49,7 +53,7 @@ public class PlayoutTimeShifter( playout.Channel.Number, playout.Channel.Name); - return; + return []; } if (playout.Items.Count == 0) @@ -59,7 +63,7 @@ public class PlayoutTimeShifter( playout.Channel.Number, playout.Channel.Name); - return; + return []; } if (playout.OnDemandCheckpoint is null) @@ -122,7 +126,26 @@ public class PlayoutTimeShifter( playout.OnDemandCheckpoint = now; await dbContext.SaveChangesAsync(cancellationToken); + + // a non-zero shift moved every PlayoutItem (and its guide window), so the cached + // XMLTV fragment for this channel is now stale; report the channel number so the + // caller can rebuild the guide and keep it in sync with playback + if (toOffset != TimeSpan.Zero) + { + // mirror channels relay this channel's timeline (shifted by their PlayoutOffset), + // so their cached guides are stale too — refresh them alongside, mirroring the + // fan-out BuildPlayoutHandler already does after it time-shifts + List mirrorNumbers = await dbContext.Channels + .AsNoTracking() + .Filter(c => c.MirrorSourceChannelId == channel.Id) + .Map(c => c.Number) + .ToListAsync(cancellationToken); + + return [playout.Channel.Number, .. mirrorNumbers]; + } } } + + return []; } } diff --git a/ErsatzTV.Tests/Application/Playouts/TimeShiftOnDemandPlayoutHandlerTests.cs b/ErsatzTV.Tests/Application/Playouts/TimeShiftOnDemandPlayoutHandlerTests.cs new file mode 100644 index 000000000..b501dcdbf --- /dev/null +++ b/ErsatzTV.Tests/Application/Playouts/TimeShiftOnDemandPlayoutHandlerTests.cs @@ -0,0 +1,90 @@ +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Channels; +using ErsatzTV.Application.Playouts; +using ErsatzTV.Core.Interfaces.Scheduling; +using LanguageExt; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Channel = System.Threading.Channels.Channel; + +namespace ErsatzTV.Tests.Application.Playouts; + +[TestFixture] +public class TimeShiftOnDemandPlayoutHandlerTests +{ + private IPlayoutTimeShifter _timeShifter = null!; + private Channel _worker = null!; + + [SetUp] + public void SetUp() + { + _timeShifter = Substitute.For(); + _worker = Channel.CreateUnbounded(); + } + + private TimeShiftOnDemandPlayoutHandler CreateHandler() => new(_timeShifter, _worker.Writer); + + private List DrainWorker() + { + var messages = new List(); + while (_worker.Reader.TryRead(out IBackgroundServiceRequest message)) + { + message.ShouldBeOfType(); + messages.Add(((RefreshChannelData)message).ChannelNumber); + } + + return messages; + } + + [Test] + public async Task Should_Rebuild_Guide_When_Playout_Was_Shifted() + { + // a non-zero shift reports the channel number whose cached XMLTV is now stale + _timeShifter + .TimeShift(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(["42"]); + + Option result = await CreateHandler().Handle( + new TimeShiftOnDemandPlayout(1, DateTimeOffset.Now, true), + CancellationToken.None); + + result.IsNone.ShouldBeTrue(); + + // the guide for that channel must be rebuilt so playback and EPG stay in sync + DrainWorker().ShouldBe(["42"]); + } + + [Test] + public async Task Should_Rebuild_Guides_For_Shifted_Channel_And_Its_Mirrors() + { + // a mirror channel relays the shifted timeline, so its cached guide is stale too + _timeShifter + .TimeShift(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(["42", "43", "44"]); + + Option result = await CreateHandler().Handle( + new TimeShiftOnDemandPlayout(1, DateTimeOffset.Now, true), + CancellationToken.None); + + result.IsNone.ShouldBeTrue(); + DrainWorker().ShouldBe(["42", "43", "44"]); + } + + [Test] + public async Task Should_Not_Rebuild_Guide_When_No_Shift_Occurred() + { + // no shift (wrong mode / active session / no elapsed time) => nothing to rebuild + _timeShifter + .TimeShift(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns([]); + + Option result = await CreateHandler().Handle( + new TimeShiftOnDemandPlayout(1, DateTimeOffset.Now, true), + CancellationToken.None); + + result.IsNone.ShouldBeTrue(); + DrainWorker().ShouldBeEmpty(); + } +} diff --git a/ErsatzTV.Tests/Infrastructure/Scheduling/PlayoutTimeShifterTests.cs b/ErsatzTV.Tests/Infrastructure/Scheduling/PlayoutTimeShifterTests.cs new file mode 100644 index 000000000..2e0f887b9 --- /dev/null +++ b/ErsatzTV.Tests/Infrastructure/Scheduling/PlayoutTimeShifterTests.cs @@ -0,0 +1,194 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.FFmpeg; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Scheduling; +using ErsatzTV.Tests.Support; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Infrastructure.Scheduling; + +[TestFixture] +public class PlayoutTimeShifterTests +{ + // fixed timestamps so the test is deterministic (Date.Now is never read here) + private static readonly DateTimeOffset Checkpoint = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset Now = Checkpoint.AddDays(3); + + private InMemoryTvContext _db = null!; + private IFFmpegSegmenterService _segmenter = null!; + + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _segmenter = Substitute.For(); + _segmenter.IsActive(Arg.Any()).Returns(false); + } + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + private PlayoutTimeShifter CreateSubject() => + new(_db.Factory, _segmenter, NullLogger.Instance); + + private static Channel MakeChannel(int id, string number, ChannelPlayoutMode mode) => + new(Guid.NewGuid()) + { + Id = id, + Number = number, + Name = $"Channel {number}", + Group = "", + Categories = "", + StreamSelector = "", + PreferredAudioLanguageCode = "", + PreferredAudioTitle = "", + PreferredSubtitleLanguageCode = "", + MusicVideoCreditsTemplate = "", + PlayoutMode = mode + }; + + private async Task SeedPlayout( + ChannelPlayoutMode mode, + DateTimeOffset? checkpoint, + bool withMirror = false) + { + await using TvContext context = _db.CreateContext(); + + Channel channel = MakeChannel(1, "1", mode); + + var playout = new Playout + { + Id = 1, + ChannelId = channel.Id, + Channel = channel, + OnDemandCheckpoint = checkpoint, + Items = + [ + new PlayoutItem + { + Id = 1, + Start = Checkpoint.UtcDateTime, + Finish = Checkpoint.UtcDateTime.AddHours(1), + GuideStart = Checkpoint.UtcDateTime, + GuideFinish = Checkpoint.UtcDateTime.AddHours(1) + } + ], + PlayoutHistory = [], + ProgramScheduleAnchors = [] + }; + + await context.Channels.AddAsync(channel); + await context.Playouts.AddAsync(playout); + + if (withMirror) + { + Channel mirror = MakeChannel(2, "2", ChannelPlayoutMode.Continuous); + mirror.PlayoutSource = ChannelPlayoutSource.Mirror; + mirror.MirrorSourceChannelId = channel.Id; + await context.Channels.AddAsync(mirror); + } + + await context.SaveChangesAsync(); + return playout.Id; + } + + [Test] + public async Task Should_Shift_Items_And_Report_Channel_Number_For_OnDemand_Playout() + { + int playoutId = await SeedPlayout(ChannelPlayoutMode.OnDemand, Checkpoint); + + List shifted = await CreateSubject().TimeShift(playoutId, Now, force: true, CancellationToken.None); + + // reports the channel number so the caller can rebuild the (now-stale) cached guide + shifted.ShouldBe(["1"]); + + await using TvContext context = _db.CreateContext(); + Playout playout = await context.Playouts.Include(p => p.Items).SingleAsync(p => p.Id == playoutId); + PlayoutItem item = playout.Items.Single(); + + TimeSpan expectedOffset = Now - Checkpoint; // 3 days + // both playback (Start/Finish) and the guide window (GuideStart/GuideFinish) advance together + item.Start.ShouldBe(Checkpoint.UtcDateTime + expectedOffset); + item.Finish.ShouldBe(Checkpoint.UtcDateTime.AddHours(1) + expectedOffset); + item.GuideStart!.Value.ShouldBe(Checkpoint.UtcDateTime + expectedOffset); + item.GuideFinish!.Value.ShouldBe(Checkpoint.UtcDateTime.AddHours(1) + expectedOffset); + playout.OnDemandCheckpoint.ShouldBe(Now); + } + + [Test] + public async Task Should_Also_Report_Mirror_Channels_So_Their_Guides_Rebuild() + { + // a channel mirroring the shifted on-demand channel relays its timeline, so its cached + // guide is stale after the thaw and must be rebuilt too + int playoutId = await SeedPlayout(ChannelPlayoutMode.OnDemand, Checkpoint, withMirror: true); + + List shifted = await CreateSubject().TimeShift(playoutId, Now, force: true, CancellationToken.None); + + shifted.ShouldBe(["1", "2"]); // shifted channel first, then its mirror + } + + [Test] + public async Task Should_Shift_And_Seed_Checkpoint_For_Never_Watched_Playout() + { + // null checkpoint => it is seeded to the earliest item start, then the whole timeline slides + // forward to `now` (the subtlest branch: an unwatched channel still resumes correctly) + int playoutId = await SeedPlayout(ChannelPlayoutMode.OnDemand, checkpoint: null); + + List shifted = await CreateSubject().TimeShift(playoutId, Now, force: true, CancellationToken.None); + + shifted.ShouldBe(["1"]); + + await using TvContext context = _db.CreateContext(); + Playout playout = await context.Playouts.Include(p => p.Items).SingleAsync(p => p.Id == playoutId); + playout.OnDemandCheckpoint.ShouldBe(Now); + // item started at Checkpoint; with the seed = earliest start, the offset is Now - Checkpoint + playout.Items.Single().Start.ShouldBe(Now.UtcDateTime); + } + + [Test] + public async Task Should_Not_Shift_Or_Report_For_Continuous_Playout() + { + // negative control: a Continuous channel is never time-shifted + int playoutId = await SeedPlayout(ChannelPlayoutMode.Continuous, Checkpoint); + + List shifted = await CreateSubject().TimeShift(playoutId, Now, force: true, CancellationToken.None); + + shifted.ShouldBeEmpty(); + + await using TvContext context = _db.CreateContext(); + PlayoutItem item = await context.PlayoutItems.SingleAsync(); + item.Start.ShouldBe(Checkpoint.UtcDateTime); // untouched + } + + [Test] + public async Task Should_Not_Shift_When_Active_And_Not_Forced() + { + // an active session must not have its timeline yanked out from under it unless forced + _segmenter.IsActive("1").Returns(true); + int playoutId = await SeedPlayout(ChannelPlayoutMode.OnDemand, Checkpoint); + + List shifted = await CreateSubject().TimeShift(playoutId, Now, force: false, CancellationToken.None); + + shifted.ShouldBeEmpty(); + + await using TvContext context = _db.CreateContext(); + PlayoutItem item = await context.PlayoutItems.SingleAsync(); + item.Start.ShouldBe(Checkpoint.UtcDateTime); // untouched + } + + [Test] + public async Task Should_Not_Report_When_Shift_Is_Zero() + { + // non-vacuous gate: an OnDemand playout already at `now` produces a zero offset, + // so there is no stale guide to rebuild + int playoutId = await SeedPlayout(ChannelPlayoutMode.OnDemand, Now); + + List shifted = await CreateSubject().TimeShift(playoutId, Now, force: true, CancellationToken.None); + + shifted.ShouldBeEmpty(); + } +} diff --git a/docs/channels.md b/docs/channels.md index dd9a0251c..552efb206 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -7,7 +7,7 @@ Defined in `ErsatzTV.Core/Domain/Channel.cs`. Key fields: - **Identity**: `Number` (e.g., "1", "2.1"), `Name`, `UniqueId` (GUID for M3U/XMLTV) - **Encoding**: `FFmpegProfileId` — video/audio codec, bitrate, resolution, hardware acceleration - **Streaming**: `StreamingMode` (TransportStream, HLS Direct, HLS Segmenter, TS Hybrid) -- **Behavior**: `PlayoutMode` (Continuous vs OnDemand), `IdleBehavior` (StopOnDisconnect vs KeepRunning) +- **Behavior**: `PlayoutMode` (Continuous vs OnDemand — OnDemand *is* the "resume where I left off" mode, see [On-demand resume](#on-demand-resume-freeze-the-clock-when-unwatched)), `IdleBehavior` (StopOnDisconnect vs KeepRunning) - **Visual**: `WatermarkId`, `FallbackFillerId`, artwork (logos) - **Mirroring**: `PlayoutSource` (Generated vs Mirror) — a mirror channel copies another with optional time offset - **Display**: `Group`, `Categories`, `ShowInEpg`, `IsEnabled` @@ -137,6 +137,29 @@ Channel The scheduling engine (`ErsatzTV.Core/Scheduling/`) resolves schedule items into concrete `PlayoutItem` entries with precise start/finish times. Each `PlayoutItem` references a specific `MediaItem` and includes trim points (`InPoint`/`OutPoint`), filler classification, and per-item audio/subtitle overrides. +## On-demand resume (freeze the clock when unwatched) + +A channel with `PlayoutMode = OnDemand` is the built-in "resume / bookmark" behavior (issue #68): its +playout clock advances only while someone is watching and freezes when nobody is, so on the next +tune-in it resumes where the last viewer stopped rather than jumping to a live wall-clock point. + +- **Resume position**: `Playout.OnDemandCheckpoint` (a `DateTimeOffset?`) persists the viewer's spot. + `UpdateOnDemandCheckpointHandler` advances it — monotonically, minus one segmenter-timeout of + rewind-for-context — on each transcode iteration while watching. +- **Freeze/thaw**: on tune-in, `HlsSessionWorker.Run` sends `TimeShiftOnDemandPlayout`, and + `PlayoutTimeShifter.TimeShift` slides the **whole** materialized timeline (`PlayoutItem.Start/Finish` + **and** `GuideStart/GuideFinish`, plus history/anchors) forward by `now − checkpoint`, so the item the + viewer had reached is active at `now` again. +- **Guide stays in sync — the make-or-break requirement.** Guide and playback both read the same stored + `PlayoutItem.Start/Finish`, so freezing them together avoids the classic desync (guide ticking on + wall-clock while playback resumes from a saved spot). Because the XMLTV guide is served from a + **cached** fragment, the shift also enqueues `RefreshChannelData` for the channel — and for any + channels that mirror it — so every affected cache is rebuilt from the just-shifted rows; see + `decisions.md` → `scheduling.ondemand-guide-refresh-on-thaw` (#68). +- **Scope**: resume is **per-channel** (a single checkpoint on the playout), not per-viewer. Pair it with + a sequential playout (Sequential/Chronological order) for a "continue watching" channel of ordered + content. + ## Watermarks `ChannelWatermark` supports modes: Permanent, Intermittent, OpacityExpression. Image sources: custom diff --git a/docs/decisions.md b/docs/decisions.md index 9df647f52..d601e60b1 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -3362,3 +3362,18 @@ barrier that validates the winner count and resets the pool. The negative contro the test file: reinstate the check-then-act body (**not** `if (true)`, which trips CS0219 under warnings-as-errors and leaves `--no-build` running a stale, still-fixed dll). Verified: with the pre-fix shape, 15 912 of 20 000 rounds over-claimed. + +## 2026-07-21 — An on-demand time shift rebuilds the channel's cached XMLTV so the guide can't lag playback (#68) + +`key: scheduling.ondemand-guide-refresh-on-thaw` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none` +**Rule:** When `PlayoutTimeShifter.TimeShift` slides an on-demand playout's materialized timeline forward on tune-in, it reports the channel numbers whose cached guide is now stale — the shifted channel **plus any channels that mirror it** — and `TimeShiftOnDemandPlayoutHandler` enqueues a `RefreshChannelData` for each, so every affected cached XMLTV fragment is regenerated from the just-shifted `PlayoutItem` rows. The guide and playback both read the same stored `PlayoutItem.Start/Finish`, but the guide is served from a **cached** projection — rewriting the rows without rebuilding the cache would leave the guide advertising a stale timeline. +**Signals:** on-demand resume, bookmark playout, freeze the clock when unwatched, guide/EPG desync, "guide showed S2E3 while tune-in played S1E1", XMLTV cache staleness, mirror channel guide · paths: `ErsatzTV.Infrastructure/Scheduling/PlayoutTimeShifter.cs`, `ErsatzTV.Application/Playouts/Commands/TimeShiftOnDemandPlayoutHandler.cs`, `ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs` · issues: #68 +**Mechanics:** `IPlayoutTimeShifter.TimeShift` returns `List` (shifted channel + its mirrors on a non-zero shift, empty otherwise); handler `foreach`-enqueues `RefreshChannelData` on `CancellationToken.None` + +**This is what #68 ("resume/bookmark for sequential channels") actually needed.** The freeze-when-unwatched, resume-where-you-left-off behavior already existed as `ChannelPlayoutMode.OnDemand`: `Playout.OnDemandCheckpoint` persists the viewer's position, `UpdateOnDemandCheckpoint` advances it (monotonically, minus one segmenter-timeout of rewind-for-context) while watching, and `PlayoutTimeShifter` slides the whole schedule forward by `now − checkpoint` on the next tune-in so the item the viewer had reached is active again. Because the shift rewrites `GuideStart/GuideFinish` alongside `Start/Finish`, ErsatzTV **freezes the guide and playback together** — structurally avoiding the free-running-wall-clock desync the issue was filed about (a guide that keeps ticking while playback resumes from a saved spot). + +**The one gap was cache freshness, not the timing model.** `RefreshChannelData` was enqueued by playout-build and channel/config edits, but **not** by the on-tune-in time shift, and `GetChannelGuideHandler` serves a cached `.xml` fragment (the live-recomputed SPA JSON guide already self-healed). So after a thaw an external EPG client (Jellyfin) could poll a guide reflecting the pre-shift timeline until the next incidental rebuild. `BuildPlayoutHandler` already refreshes the guide after it time-shifts — including a fan-out to every channel that mirrors the built channel, because `RefreshChannelDataHandler` refreshes only the one channel it is handed and does not cascade downstream. The tune-in path (`TimeShiftOnDemandPlayoutHandler`) was unpatched, so this decision replicates both halves: the source channel's guide **and** its mirrors' guides are rebuilt on thaw. A channel mirroring an on-demand source is an uncommon combination, but omitting it would leave the same desync one hop out. + +**Why the return-value plumbing rather than enqueuing inside the shifter.** `PlayoutTimeShifter` lives in `ErsatzTV.Infrastructure`, which cannot reference the `RefreshChannelData` request type (an `ErsatzTV.Application` type), so the enqueue must happen in the Application-layer handler. `TimeShift` therefore returns `Option` — `Some(channelNumber)` only when a non-zero offset was actually persisted, `None` on every early-out (wrong mode, active-and-unforced, empty playout) and on a zero-offset re-tune — so a guide rebuild fires exactly once per real thaw, never on a no-op. The zero-offset `None` gate is covered by a dedicated non-vacuous test. + +**Per-viewer resume was deliberately not built.** `OnDemandCheckpoint` is a single value on the playout, so resume is per-channel, not per-viewer. #68 states per-channel suffices for a single household; multi-viewer identity would diverge from this model and is out of scope. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 52332d07d..88c4ede06 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -88,6 +88,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `sched.shuffle-source-builder` | Shuffle-source construction moves to a static, DI-free `ShuffleSourceBuilder` (a shared seam, not a service) so Classic and Playlist stop cross-engine reaching into `PlayoutBuilder` statics; a unified Classic+Playlist enumerator factory is explicitly rejected as a god-factory. Block/Scripted/YAML duplication is left alone, deferred to a follow-up gated on #381. | 2026-07-17 | [link](../decisions.md#2026-07-17--shuffle-source-construction-extracted-to-shufflesourcebuilder-per-family-seam-not-a-god-factory-380) | | `sched.weighted-shuffle` | Fair-share/weighted airtime distribution ships as one new `PlaybackOrder.WeightedShuffle = 9` order (equal weights = fair-share), not a retrofit of `ShuffleInOrder` (which only anti-clumps, since its padding spacers emit nothing) and not a separate orthogonal "distribution" setting; weights live on `MultiCollectionItem`/`MultiCollectionSmartItem` (DB default 1, dual-provider migration), bounded at write (1..1000) and clamped again in the enumerator, and the write path rejects `WeightedShuffle` at every dispatch site that doesn't handle it rather than let it silently degrade to unweighted random. | 2026-07-17 | [link](../decisions.md#2026-07-17--weighted--fair-share-distribution-is-a-new-weightedshuffle-order-shuffleinorder-is-anti-clumping-not-fair-share-70) | | `sched.weightedshuffle-editor` | WeightedShuffle per-source weights are edited on the multi-collection editor (property of the MultiCollection), while the WeightedShuffle order itself is offered only on classic MultiCollection schedule items; fair-share is a "reset weights to 1" action, not a stored mode. | 2026-07-19 | [link](../decisions.md#2026-07-19--weightedshuffle-spa-weights-edited-on-the-multi-collection-order-offered-only-on-classic-multicollection-schedule-items-fair-share-is-a-reset-not-a-mode-404) | +| `scheduling.ondemand-guide-refresh-on-thaw` | When `PlayoutTimeShifter.TimeShift` slides an on-demand playout's materialized timeline forward on tune-in, it reports the channel numbers whose cached guide is now stale — the shifted channel **plus any channels that mirror it** — and `TimeShiftOnDemandPlayoutHandler` enqueues a `RefreshChannelData` for each, so every affected cached XMLTV fragment is regenerated from the just-shifted `PlayoutItem` rows. The guide and playback both read the same stored `PlayoutItem.Start/Finish`, but the guide is served from a **cached** projection — rewriting the rows without rebuilding the cache would leave the guide advertising a stale timeline. | 2026-07-21 | [link](../decisions.md#2026-07-21--an-on-demand-time-shift-rebuilds-the-channels-cached-xmltv-so-the-guide-cant-lag-playback-68) | | `security.artwork-content-type-sniff` | Artwork content type is always derived from the stored bytes (never the client-declared value or a `?contentType=` query param) at both upload and serve, clamped to an image allow-list, closing the unauthenticated stored-XSS chain; Kestrel `MaxRequestBodySize` bounds upload DoS. | 2026-07-12 | [link](api-auth-security.md#2026-07-12--artwork-content-type-is-sniffed-never-reflected-283-s4s9-stored-xss) | | `security.baseline-response-headers` | `SecurityHeadersMiddleware`, registered first in the pipeline, sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and `Referrer-Policy: strict-origin-when-cross-origin` on every response (CSP/HSTS deliberately deferred); API-key comparison is constant-time and playout pagination is clamped. | 2026-07-11 | [link](api-auth-security.md#2026-07-11--baseline-security-response-headers--phase-0-api-hardening-197-pr-279) | | `security.blazor-removal-auth-posture` | Removing the Blazor UI's OIDC-challenged surface exposes nothing a user couldn't already reach via the already-open `/app` SPA (open since phase (a)); real SPA/API authentication is deliberately deferred to #197, and the removal PR must preserve `ConditionalIptvAuthorizeFilter`, `ApiKeyAuthorizationFilter`, and `JwtHelper` access_token support. | 2026-07-11 | [link](api-auth-security.md#2026-07-11--blazor-removal-auth-posture-no-new-exposure-beyond-phase-a-real-auth-deferred-to-197-206) | diff --git a/docs/domain-model.md b/docs/domain-model.md index 3e406ee89..e2763ff5f 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -79,7 +79,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe | **MediaItemState** | Health flag on a media item: Normal/FileNotFound/Unavailable/RemoteOnly. Drives the Trash screen. | `MediaItemState` | `/app/trash` | | **PlayoutItem** | One materialized, built entry in a playout's timeline (the actual thing that will play at a given time). | `PlayoutItem` | (generated, not directly edited) | | **PlayoutHistory** | Rotation/rerun bookkeeping per block (`BlockId`) + collection `Key`/`ChildKey`, used by block-playout schedulers to avoid repeats; inspectable via Troubleshooting. | `PlayoutHistory` | `/app/troubleshooting/blocks` | -| **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel`, `/app/auto-tune` (bulk-generate from library metadata, #69; per-channel DetailPanel content-source members read via `GET /api/v1/channels/auto-tune/members`, #384; per-channel `templateId`/`advanced`/`logo` overrides accepted by `POST /api/v1/channels/auto-tune`, #385; per-source rotation weights + query corrections via an optional `sources: [{sourceId, weight, excluded}]` on that same request, #425 — a customized channel is backed by a system-owned `MultiCollection` of per-source `SmartCollection`s with `PlaybackOrder.WeightedShuffle`, `OwnedByChannelId`-tagged so it's hidden from collection lists and cleaned up on channel delete) | +| **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand — OnDemand is the per-channel "resume where I left off" freeze/thaw mode, #68; see `channels.md` → On-demand resume), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel`, `/app/auto-tune` (bulk-generate from library metadata, #69; per-channel DetailPanel content-source members read via `GET /api/v1/channels/auto-tune/members`, #384; per-channel `templateId`/`advanced`/`logo` overrides accepted by `POST /api/v1/channels/auto-tune`, #385; per-source rotation weights + query corrections via an optional `sources: [{sourceId, weight, excluded}]` on that same request, #425 — a customized channel is backed by a system-owned `MultiCollection` of per-source `SmartCollection`s with `PlaybackOrder.WeightedShuffle`, `OwnedByChannelId`-tagged so it's hidden from collection lists and cleaned up on channel delete) | | **Channel health / `PlayoutCount`** (#72) | Whether a channel can play at all. `PlayoutCount` (channel's own playouts, **plus the mirror source's** when `PlayoutSource is Mirror` — computed by `Mapper.GetPlayoutsCount`) rides on both `ChannelResponseModel` (list) and `ChannelDetailResponseModel`; `0` ⇒ the channel can never play, rendered as a "No playout" badge + a matching **No playout** filter on the channels list (both name only the one fault the API can prove — a broader "Problems" label would read as a false all-clear to a user whose *other* fault classes below are uncomputed). It is a **raw fact, not a status enum** — see `decisions.md` 2026-07-17. Distinct from `/api/v1/channels/state`'s `OnAir`, which is runtime liveness ("someone is streaming right now"), not "would play if tuned". Empty-schedule, broken-source and auto-tuned-vs-user origin are deliberately **not** computed (see that decision entry for why each is unsafe today). | `Channel.Playouts` | `/app/channels` (read-only signal) | | **Guide / EPG (XMLTV)** | Per-channel programme guide generated from playout items; channels with `ShowInEpg=false` are excluded. The SPA's JSON guide grid is built by the sibling `GetChannelGuideDataHandler`. Both the JSON guide (`ChannelGuideChannelResponseModel`) and the channels-list DTO (`ChannelResponseModel`) expose a rooted, directly-usable `Logo` URL (#464) via `Mapper.GetLogoUrl` — `/iptv/logos/{file}` for an uploaded logo, the absolute URL passed through for an external one, `null` when unset (SPA then draws the generated initials "bug"). | `GetChannelGuideHandler`, `GetChannelGuideDataHandler` | `/app/guide` (viewer); settings at `/app/settings/xmltv` | | **M3U** | The channel lineup playlist Jellyfin/Dispatcharr consume. | `ChannelPlaylist.ToM3U()` | — |