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
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>
152 lines
5.6 KiB
C#
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 [];
|
|
}
|
|
}
|