Files
ersatztv/ErsatzTV.Infrastructure/Scheduling/PlayoutTimeShifter.cs
T
timothyandClaude Opus 4.8 dfed9a393b
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
PR Gates / Docs update reminder (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 27s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m26s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m38s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m52s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(68): rebuild on-demand channel guide (and mirrors) on thaw
An on-demand channel (`PlayoutMode.OnDemand`) already is the "resume where I
left off" feature: `Playout.OnDemandCheckpoint` persists the viewer's position
and `PlayoutTimeShifter.TimeShift` slides the materialized timeline forward on
tune-in so the paused item is active again. Because it rewrites `GuideStart`/
`GuideFinish` alongside `Start`/`Finish`, guide and playback freeze together —
structurally avoiding the free-running-wall-clock desync #68 was filed about.

The one gap: `TimeShift` rewrote the stored `PlayoutItem` rows but the XMLTV
guide is served from a cached fragment that only `RefreshChannelData` rebuilds,
and the tune-in path never enqueued it. So an external EPG client polling after
a thaw could see a stale timeline until the next incidental rebuild.

Fix: `IPlayoutTimeShifter.TimeShift` now returns the channel numbers whose cached
guide is stale — the shifted channel plus any channels that mirror it (the same
fan-out `BuildPlayoutHandler` already does) — and `TimeShiftOnDemandPlayoutHandler`
enqueues a `RefreshChannelData` for each on `CancellationToken.None` (post-commit
side effect must not be abandoned if the session token cancels).

Tests: handler enqueues a rebuild per stale channel (+ mirror + no-shift cases);
`PlayoutTimeShifter` reports source+mirrors on a shift, empty on Continuous /
zero-offset / active-unforced, and correctly seeds+shifts a never-watched playout.
Non-vacuity of the enqueue proven by a compiling negative control.

Docs: channels.md (On-demand resume section), domain-model.md, decisions.md
(scheduling.ondemand-guide-refresh-on-thaw). Per-viewer resume is out of scope
(single per-channel checkpoint; #68 says per-channel suffices).

fixes #68

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 19:12:42 +02:00

152 lines
5.6 KiB
C#

using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Infrastructure.Scheduling;
public class PlayoutTimeShifter(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegSegmenterService segmenterService,
ILogger<PlayoutTimeShifter> logger)
: IPlayoutTimeShifter
{
public async Task<List<string>> TimeShift(
int playoutId,
DateTimeOffset now,
bool force,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Channel> maybeChannel = await dbContext.Playouts
.AsNoTracking()
.Include(p => p.Channel)
.SelectOneAsync(p => p.Id, p => p.Id == playoutId, cancellationToken)
.MapT(p => p.Channel);
foreach (Channel channel in maybeChannel.Where(c => c.PlayoutMode is ChannelPlayoutMode.OnDemand))
{
Option<Playout> maybePlayout = await dbContext.Playouts
.Include(p => p.Channel)
.Include(p => p.Items)
.Include(p => p.Anchor)
.Include(p => p.ProgramScheduleAnchors)
.Include(p => p.PlayoutHistory)
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == channel.Id, cancellationToken);
foreach (Playout playout in maybePlayout)
{
if (playout.Channel.PlayoutMode is not ChannelPlayoutMode.OnDemand)
{
return [];
}
if (!force && segmenterService.IsActive(playout.Channel.Number))
{
logger.LogDebug(
"Will not time shift on demand playout that is active for channel {Number} - {Name}",
playout.Channel.Number,
playout.Channel.Name);
return [];
}
if (playout.Items.Count == 0)
{
logger.LogDebug(
"Unable to time shift empty playout for channel {Number} - {Name}",
playout.Channel.Number,
playout.Channel.Name);
return [];
}
if (playout.OnDemandCheckpoint is null)
{
logger.LogDebug(
"Time shifting unwatched playout for channel {Number} - {Name}",
playout.Channel.Number,
playout.Channel.Name);
playout.OnDemandCheckpoint = playout.Items.Min(p => p.StartOffset);
}
TimeSpan toOffset = now - playout.OnDemandCheckpoint.IfNone(now);
logger.LogDebug(
"Time shifting playout for channel {Number} - {Name} forward by {Time}",
playout.Channel.Number,
playout.Channel.Name,
toOffset);
// time shift history
foreach (PlayoutHistory history in playout.PlayoutHistory)
{
history.When += toOffset;
history.Finish += toOffset;
}
// time shift items
foreach (PlayoutItem playoutItem in playout.Items)
{
playoutItem.Start += toOffset;
playoutItem.Finish += toOffset;
if (playoutItem.GuideStart.HasValue)
{
playoutItem.GuideStart += toOffset;
}
if (playoutItem.GuideFinish.HasValue)
{
playoutItem.GuideFinish += toOffset;
}
}
// time shift anchors
foreach (PlayoutProgramScheduleAnchor anchor in playout.ProgramScheduleAnchors)
{
if (anchor.AnchorDate.HasValue)
{
anchor.AnchorDate += toOffset;
}
}
// time shift anchor
if (playout.Anchor is not null)
{
playout.Anchor.NextStart += toOffset;
}
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 [];
}
}