fix(68): rebuild on-demand channel guide (and mirrors) on thaw #540

Merged
timothy merged 1 commits from fix/68-ondemand-guide-refresh into main 2026-07-21 19:49:15 +02:00
9 changed files with 383 additions and 9 deletions
@@ -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<IBackgroundServiceRequest> workerChannel)
: IRequestHandler<TimeShiftOnDemandPlayout, Option<BaseError>>
{
public async Task<Option<BaseError>> Handle(TimeShiftOnDemandPlayout request, CancellationToken cancellationToken)
{
try
{
await playoutTimeShifter.TimeShift(request.PlayoutId, request.Now, request.Force, cancellationToken);
List<string> 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)
{
@@ -2,5 +2,14 @@ namespace ErsatzTV.Core.Interfaces.Scheduling;
public interface IPlayoutTimeShifter
{
Task TimeShift(int playoutId, DateTimeOffset now, bool force, CancellationToken cancellationToken);
/// <summary>
/// Slides an on-demand playout's materialized timeline forward so the item the viewer had
/// reached is active again at <paramref name="now" />.
/// </summary>
/// <returns>
/// 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.
/// </returns>
Task<List<string>> TimeShift(int playoutId, DateTimeOffset now, bool force, CancellationToken cancellationToken);
}
@@ -15,7 +15,11 @@ public class PlayoutTimeShifter(
ILogger<PlayoutTimeShifter> logger)
: IPlayoutTimeShifter
{
public async Task TimeShift(int playoutId, DateTimeOffset now, bool force, CancellationToken cancellationToken)
public async Task<List<string>> 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<string> mirrorNumbers = await dbContext.Channels
.AsNoTracking()
.Filter(c => c.MirrorSourceChannelId == channel.Id)
.Map(c => c.Number)
.ToListAsync(cancellationToken);
return [playout.Channel.Number, .. mirrorNumbers];
}
}
}
return [];
}
}
@@ -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<IBackgroundServiceRequest> _worker = null!;
[SetUp]
public void SetUp()
{
_timeShifter = Substitute.For<IPlayoutTimeShifter>();
_worker = Channel.CreateUnbounded<IBackgroundServiceRequest>();
}
private TimeShiftOnDemandPlayoutHandler CreateHandler() => new(_timeShifter, _worker.Writer);
private List<string> DrainWorker()
{
var messages = new List<string>();
while (_worker.Reader.TryRead(out IBackgroundServiceRequest message))
{
message.ShouldBeOfType<RefreshChannelData>();
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<int>(), Arg.Any<DateTimeOffset>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(["42"]);
Option<ErsatzTV.Core.BaseError> 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<int>(), Arg.Any<DateTimeOffset>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(["42", "43", "44"]);
Option<ErsatzTV.Core.BaseError> 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<int>(), Arg.Any<DateTimeOffset>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns([]);
Option<ErsatzTV.Core.BaseError> result = await CreateHandler().Handle(
new TimeShiftOnDemandPlayout(1, DateTimeOffset.Now, true),
CancellationToken.None);
result.IsNone.ShouldBeTrue();
DrainWorker().ShouldBeEmpty();
}
}
@@ -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<IFFmpegSegmenterService>();
_segmenter.IsActive(Arg.Any<string>()).Returns(false);
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private PlayoutTimeShifter CreateSubject() =>
new(_db.Factory, _segmenter, NullLogger<PlayoutTimeShifter>.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<int> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> shifted = await CreateSubject().TimeShift(playoutId, Now, force: true, CancellationToken.None);
shifted.ShouldBeEmpty();
}
}
+24 -1
View File
@@ -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
+15
View File
@@ -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<string>` (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<string>``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.
+1
View File
@@ -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) |
+1 -1
View File
@@ -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()` | — |