fix(68): rebuild on-demand channel guide (and mirrors) on thaw #540
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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()` | — |
|
||||
|
||||
Reference in New Issue
Block a user