diff --git a/CHANGELOG.md b/CHANGELOG.md
index f136cf0ea..5128d3644 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- These libraries will now appear as ETV Other Video libraries
- Items in these libraries will have tag metadata added from folders just like local Other Video libraries
- Thanks @raknam for adding this feature!
+- Add *experimental* support for `On Demand` channel progress
+ - With `On Demand` channel progress, the schedule will only advance when the channel is being streamed
+ - When the channel is idle, the schedule is unmodified and will be shifted forward as needed so no content is missed
+ - Setting a channel to `On Demand` progress will disable alternate schedules
+ - The `On Demand` setting will only be used for `Flood` playouts (NOT `Block` or `External JSON`)
+ - It is NOT recommended to use fixed start times with `On Demand` progress
+ - This will probably be disabled with a future update
### Fixed
- Add basic cache busting to XMLTV image URLs
diff --git a/ErsatzTV.Application/Channels/ChannelViewModel.cs b/ErsatzTV.Application/Channels/ChannelViewModel.cs
index 8faa3f6da..0fc9b9996 100644
--- a/ErsatzTV.Application/Channels/ChannelViewModel.cs
+++ b/ErsatzTV.Application/Channels/ChannelViewModel.cs
@@ -12,6 +12,7 @@ public record ChannelViewModel(
string Logo,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
+ ChannelProgressMode ProgressMode,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
diff --git a/ErsatzTV.Application/Channels/Commands/CreateChannel.cs b/ErsatzTV.Application/Channels/Commands/CreateChannel.cs
index e3427b582..b0682ca24 100644
--- a/ErsatzTV.Application/Channels/Commands/CreateChannel.cs
+++ b/ErsatzTV.Application/Channels/Commands/CreateChannel.cs
@@ -12,6 +12,7 @@ public record CreateChannel(
string Logo,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
+ ChannelProgressMode ProgressMode,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
diff --git a/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs
index 72f48314a..0ad8c330e 100644
--- a/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs
+++ b/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs
@@ -73,6 +73,7 @@ public class CreateChannelHandler(
Group = request.Group,
Categories = request.Categories,
FFmpegProfileId = ffmpegProfileId,
+ ProgressMode = request.ProgressMode,
StreamingMode = request.StreamingMode,
Artwork = artwork,
PreferredAudioLanguageCode = preferredAudioLanguageCode,
diff --git a/ErsatzTV.Application/Channels/Commands/UpdateChannel.cs b/ErsatzTV.Application/Channels/Commands/UpdateChannel.cs
index 96667106a..3857e6939 100644
--- a/ErsatzTV.Application/Channels/Commands/UpdateChannel.cs
+++ b/ErsatzTV.Application/Channels/Commands/UpdateChannel.cs
@@ -13,6 +13,7 @@ public record UpdateChannel(
string Logo,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
+ ChannelProgressMode ProgressMode,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
diff --git a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
index b0ca4ec03..e7005b5ef 100644
--- a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
+++ b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
@@ -67,6 +67,7 @@ public class UpdateChannelHandler(
});
}
+ c.ProgressMode = update.ProgressMode;
c.StreamingMode = update.StreamingMode;
c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId;
diff --git a/ErsatzTV.Application/Channels/Mapper.cs b/ErsatzTV.Application/Channels/Mapper.cs
index 8ec03bd65..0ac3ff3b4 100644
--- a/ErsatzTV.Application/Channels/Mapper.cs
+++ b/ErsatzTV.Application/Channels/Mapper.cs
@@ -16,6 +16,7 @@ internal static class Mapper
GetLogo(channel),
channel.PreferredAudioLanguageCode,
channel.PreferredAudioTitle,
+ channel.ProgressMode,
channel.StreamingMode,
channel.WatermarkId,
channel.FallbackFillerId,
diff --git a/ErsatzTV.Application/ErsatzTV.Application.csproj b/ErsatzTV.Application/ErsatzTV.Application.csproj
index 48709b163..58f054408 100644
--- a/ErsatzTV.Application/ErsatzTV.Application.csproj
+++ b/ErsatzTV.Application/ErsatzTV.Application.csproj
@@ -12,7 +12,7 @@
-
+
@@ -20,8 +20,8 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+
diff --git a/ErsatzTV.Application/Playouts/Commands/CreateFloodPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/CreateFloodPlayoutHandler.cs
index ad7f44aa8..ca8e2a34c 100644
--- a/ErsatzTV.Application/Playouts/Commands/CreateFloodPlayoutHandler.cs
+++ b/ErsatzTV.Application/Playouts/Commands/CreateFloodPlayoutHandler.cs
@@ -37,6 +37,10 @@ public class CreateFloodPlayoutHandler : IRequestHandler>, IBackgroundServiceRequest;
diff --git a/ErsatzTV.Application/Playouts/Commands/TimeShiftOnDemandPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/TimeShiftOnDemandPlayoutHandler.cs
new file mode 100644
index 000000000..041b2988c
--- /dev/null
+++ b/ErsatzTV.Application/Playouts/Commands/TimeShiftOnDemandPlayoutHandler.cs
@@ -0,0 +1,41 @@
+using ErsatzTV.Core;
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Core.Interfaces.Scheduling;
+using ErsatzTV.Infrastructure.Data;
+using ErsatzTV.Infrastructure.Extensions;
+using Microsoft.EntityFrameworkCore;
+
+namespace ErsatzTV.Application.Playouts;
+
+public class TimeShiftOnDemandPlayoutHandler(
+ IPlayoutTimeShifter playoutTimeShifter,
+ IDbContextFactory dbContextFactory)
+ : IRequestHandler>
+{
+ public async Task> Handle(TimeShiftOnDemandPlayout request, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+
+ Option maybePlayout = await dbContext.Playouts
+ .Include(p => p.Channel)
+ .Include(p => p.Items)
+ .Include(p => p.Anchor)
+ .Include(p => p.ProgramScheduleAnchors)
+ .SelectOneAsync(p => p.Channel.Number, p => p.Channel.Number == request.ChannelNumber);
+
+ foreach (Playout playout in maybePlayout)
+ {
+ playoutTimeShifter.TimeShift(playout, request.Now, request.Force);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+
+ return Option.None;
+ }
+ catch (Exception ex)
+ {
+ return BaseError.New(ex.Message);
+ }
+ }
+}
diff --git a/ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayoutHandler.cs
index d76e8fe22..588737fdf 100644
--- a/ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayoutHandler.cs
+++ b/ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayoutHandler.cs
@@ -49,9 +49,10 @@ public class
playout.ProgramSchedulePlayoutType,
playout.Channel.Name,
playout.Channel.Number,
+ playout.Channel.ProgressMode,
playout.ProgramSchedule?.Name ?? string.Empty,
playout.ExternalJsonFile,
- Optional(playout.DailyRebuildTime));
+ playout.DailyRebuildTime);
}
private static Task> Validate(
diff --git a/ErsatzTV.Application/Playouts/Commands/UpdateOnDemandCheckpoint.cs b/ErsatzTV.Application/Playouts/Commands/UpdateOnDemandCheckpoint.cs
new file mode 100644
index 000000000..32320fcfb
--- /dev/null
+++ b/ErsatzTV.Application/Playouts/Commands/UpdateOnDemandCheckpoint.cs
@@ -0,0 +1,4 @@
+namespace ErsatzTV.Application.Playouts;
+
+public record UpdateOnDemandCheckpoint(string ChannelNumber, DateTimeOffset Checkpoint)
+ : IRequest;
diff --git a/ErsatzTV.Application/Playouts/Commands/UpdateOnDemandCheckpointHandler.cs b/ErsatzTV.Application/Playouts/Commands/UpdateOnDemandCheckpointHandler.cs
new file mode 100644
index 000000000..3694fa8fd
--- /dev/null
+++ b/ErsatzTV.Application/Playouts/Commands/UpdateOnDemandCheckpointHandler.cs
@@ -0,0 +1,62 @@
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Core.Interfaces.Repositories;
+using ErsatzTV.Infrastructure.Data;
+using ErsatzTV.Infrastructure.Extensions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace ErsatzTV.Application.Playouts;
+
+public class UpdateOnDemandCheckpointHandler(
+ IDbContextFactory dbContextFactory,
+ IConfigElementRepository configElementRepository,
+ ILogger logger)
+ : IRequestHandler
+{
+ public async Task Handle(UpdateOnDemandCheckpoint request, CancellationToken cancellationToken)
+ {
+ await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+
+ Option maybePlayout = await dbContext.Playouts
+ .Include(p => p.Channel)
+ .Include(p => p.Items)
+ .SelectOneAsync(p => p.Channel.Number, p => p.Channel.Number == request.ChannelNumber);
+
+ foreach (Playout playout in maybePlayout)
+ {
+ if (playout.Channel.ProgressMode is not ChannelProgressMode.OnDemand)
+ {
+ return;
+ }
+
+ int timeout = await (await configElementRepository.GetValue(ConfigElementKey.FFmpegSegmenterTimeout))
+ .IfNoneAsync(60);
+
+ // don't move checkpoint back in time
+ DateTimeOffset newCheckpoint = request.Checkpoint - TimeSpan.FromSeconds(timeout);
+ if (newCheckpoint > playout.OnDemandCheckpoint)
+ {
+ playout.OnDemandCheckpoint = newCheckpoint;
+ }
+
+ // don't checkpoint before the first item
+ // this could happen if you watch a new playout for less time than the segmenter timeout
+ if (playout.Items.Count > 0)
+ {
+ DateTimeOffset minStart = playout.Items.Min(p => p.StartOffset);
+ if (playout.OnDemandCheckpoint < minStart)
+ {
+ playout.OnDemandCheckpoint = minStart;
+ }
+ }
+
+ logger.LogDebug(
+ "Updating on demand checkpoint for channel {Number} - {Name} to {Checkpoint}",
+ playout.Channel.Number,
+ playout.Channel.Name,
+ playout.OnDemandCheckpoint);
+
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+ }
+}
diff --git a/ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs
index 47b378223..ea2bfa7e7 100644
--- a/ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs
+++ b/ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs
@@ -41,9 +41,10 @@ public class UpdatePlayoutHandler : IRequestHandler> Validate(TvContext dbContext, UpdatePlayout request) =>
diff --git a/ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs b/ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs
index c7ad3c856..8163b942c 100644
--- a/ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs
+++ b/ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs
@@ -7,6 +7,10 @@ public record PlayoutNameViewModel(
ProgramSchedulePlayoutType PlayoutType,
string ChannelName,
string ChannelNumber,
+ ChannelProgressMode ProgressMode,
string ScheduleName,
string ExternalJsonFile,
- Option DailyRebuildTime);
+ TimeSpan? DbDailyRebuildTime)
+{
+ public Option DailyRebuildTime => Optional(DbDailyRebuildTime);
+}
diff --git a/ErsatzTV.Application/Playouts/Queries/GetAllPlayoutsHandler.cs b/ErsatzTV.Application/Playouts/Queries/GetAllPlayoutsHandler.cs
index 7c84f28c9..7f3f5f02f 100644
--- a/ErsatzTV.Application/Playouts/Queries/GetAllPlayoutsHandler.cs
+++ b/ErsatzTV.Application/Playouts/Queries/GetAllPlayoutsHandler.cs
@@ -25,9 +25,10 @@ public class GetAllPlayoutsHandler : IRequestHandler
-
+
@@ -24,9 +24,9 @@
-
+
-
+
diff --git a/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs b/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs
index 48b201342..40886bb88 100644
--- a/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs
+++ b/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs
@@ -558,6 +558,7 @@ public class PlayoutBuilderTests
Substitute.For();
ILocalFileSystem localFileSystem = Substitute.For();
var builder = new PlayoutBuilder(
+ Substitute.For(),
configRepo,
fakeRepository,
televisionRepo,
@@ -657,6 +658,7 @@ public class PlayoutBuilderTests
Substitute.For();
ILocalFileSystem localFileSystem = Substitute.For();
var builder = new PlayoutBuilder(
+ Substitute.For(),
configRepo,
fakeRepository,
televisionRepo,
@@ -804,6 +806,7 @@ public class PlayoutBuilderTests
Substitute.For();
ILocalFileSystem localFileSystem = Substitute.For();
var builder = new PlayoutBuilder(
+ Substitute.For(),
configRepo,
fakeRepository,
televisionRepo,
@@ -909,6 +912,7 @@ public class PlayoutBuilderTests
Substitute.For();
ILocalFileSystem localFileSystem = Substitute.For();
var builder = new PlayoutBuilder(
+ Substitute.For(),
configRepo,
fakeRepository,
televisionRepo,
@@ -1023,6 +1027,7 @@ public class PlayoutBuilderTests
Substitute.For();
ILocalFileSystem localFileSystem = Substitute.For();
var builder = new PlayoutBuilder(
+ Substitute.For(),
configRepo,
fakeRepository,
televisionRepo,
@@ -1130,6 +1135,7 @@ public class PlayoutBuilderTests
Substitute.For();
ILocalFileSystem localFileSystem = Substitute.For();
var builder = new PlayoutBuilder(
+ Substitute.For(),
configRepo,
fakeRepository,
televisionRepo,
@@ -1241,6 +1247,7 @@ public class PlayoutBuilderTests
Substitute.For();
ILocalFileSystem localFileSystem = Substitute.For();
var builder = new PlayoutBuilder(
+ Substitute.For(),
configRepo,
fakeRepository,
televisionRepo,
@@ -1357,6 +1364,7 @@ public class PlayoutBuilderTests
Substitute.For();
ILocalFileSystem localFileSystem = Substitute.For();
var builder = new PlayoutBuilder(
+ Substitute.For(),
configRepo,
fakeRepository,
televisionRepo,
@@ -1462,6 +1470,7 @@ public class PlayoutBuilderTests
Substitute.For();
ILocalFileSystem localFileSystem = Substitute.For();
var builder = new PlayoutBuilder(
+ Substitute.For(),
configRepo,
fakeRepository,
televisionRepo,
@@ -1578,6 +1587,7 @@ public class PlayoutBuilderTests
Substitute.For();
ILocalFileSystem localFileSystem = Substitute.For();
var builder = new PlayoutBuilder(
+ Substitute.For(),
configRepo,
fakeRepository,
televisionRepo,
@@ -1705,6 +1715,7 @@ public class PlayoutBuilderTests
Substitute.For();
ILocalFileSystem localFileSystem = Substitute.For();
var builder = new PlayoutBuilder(
+ Substitute.For(),
configRepo,
fakeRepository,
televisionRepo,
@@ -1824,6 +1835,7 @@ public class PlayoutBuilderTests
Substitute.For();
ILocalFileSystem localFileSystem = Substitute.For();
var builder = new PlayoutBuilder(
+ Substitute.For(),
configRepo,
fakeRepository,
televisionRepo,
@@ -1903,6 +1915,7 @@ public class PlayoutBuilderTests
Substitute.For();
ILocalFileSystem localFileSystem = Substitute.For();
var builder = new PlayoutBuilder(
+ Substitute.For(),
configRepo,
fakeRepository,
televisionRepo,
@@ -2118,6 +2131,7 @@ public class PlayoutBuilderTests
Substitute.For();
ILocalFileSystem localFileSystem = Substitute.For();
var builder = new PlayoutBuilder(
+ Substitute.For(),
configRepo,
fakeRepository,
televisionRepo,
@@ -2609,6 +2623,7 @@ public class PlayoutBuilderTests
Substitute.For();
ILocalFileSystem localFileSystem = Substitute.For();
var builder = new PlayoutBuilder(
+ Substitute.For(),
configRepo,
fakeRepository,
televisionRepo,
@@ -2723,6 +2738,7 @@ public class PlayoutBuilderTests
Substitute.For();
ILocalFileSystem localFileSystem = Substitute.For();
var builder = new PlayoutBuilder(
+ Substitute.For(),
configRepo,
fakeRepository,
televisionRepo,
@@ -2837,6 +2853,7 @@ public class PlayoutBuilderTests
Substitute.For();
ILocalFileSystem localFileSystem = Substitute.For();
var builder = new PlayoutBuilder(
+ Substitute.For(),
configRepo,
fakeRepository,
televisionRepo,
@@ -2946,6 +2963,7 @@ public class PlayoutBuilderTests
Substitute.For();
ILocalFileSystem localFileSystem = Substitute.For();
var builder = new PlayoutBuilder(
+ Substitute.For(),
configRepo,
collectionRepo,
televisionRepo,
@@ -3001,6 +3019,7 @@ public class PlayoutBuilderTests
Substitute.For();
ILocalFileSystem localFileSystem = Substitute.For();
var builder = new PlayoutBuilder(
+ Substitute.For(),
configRepo,
collectionRepo,
televisionRepo,
diff --git a/ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs b/ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs
index 541ed2494..c092de582 100644
--- a/ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs
+++ b/ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs
@@ -117,6 +117,7 @@ public class ScheduleIntegrationTests
provider.GetRequiredService());
var builder = new PlayoutBuilder(
+ Substitute.For(),
new ConfigElementRepository(factory),
new MediaCollectionRepository(Substitute.For(), searchIndex, factory),
new TelevisionRepository(factory, provider.GetRequiredService>()),
@@ -287,6 +288,7 @@ public class ScheduleIntegrationTests
DateTimeOffset finish = start.AddDays(2);
var builder = new PlayoutBuilder(
+ Substitute.For(),
new ConfigElementRepository(factory),
new MediaCollectionRepository(Substitute.For(), Substitute.For(), factory),
new TelevisionRepository(factory, provider.GetRequiredService>()),
diff --git a/ErsatzTV.Core/Domain/Channel.cs b/ErsatzTV.Core/Domain/Channel.cs
index 17b7d4465..d4c13da82 100644
--- a/ErsatzTV.Core/Domain/Channel.cs
+++ b/ErsatzTV.Core/Domain/Channel.cs
@@ -28,4 +28,5 @@ public class Channel
public ChannelSubtitleMode SubtitleMode { get; set; }
public ChannelMusicVideoCreditsMode MusicVideoCreditsMode { get; set; }
public string MusicVideoCreditsTemplate { get; set; }
+ public ChannelProgressMode ProgressMode { get; set; }
}
diff --git a/ErsatzTV.Core/Domain/ChannelProgressMode.cs b/ErsatzTV.Core/Domain/ChannelProgressMode.cs
new file mode 100644
index 000000000..614fd6e1f
--- /dev/null
+++ b/ErsatzTV.Core/Domain/ChannelProgressMode.cs
@@ -0,0 +1,7 @@
+namespace ErsatzTV.Core.Domain;
+
+public enum ChannelProgressMode
+{
+ Always = 0,
+ OnDemand = 1
+}
diff --git a/ErsatzTV.Core/Domain/Playout.cs b/ErsatzTV.Core/Domain/Playout.cs
index d0cc7d317..a2ba0467a 100644
--- a/ErsatzTV.Core/Domain/Playout.cs
+++ b/ErsatzTV.Core/Domain/Playout.cs
@@ -22,4 +22,5 @@ public class Playout
public TimeSpan? DailyRebuildTime { get; set; }
public int? DecoId { get; set; }
public Deco Deco { get; set; }
+ public DateTimeOffset? OnDemandCheckpoint { get; set; }
}
diff --git a/ErsatzTV.Core/ErsatzTV.Core.csproj b/ErsatzTV.Core/ErsatzTV.Core.csproj
index aef25a4ec..b2e0c9dec 100644
--- a/ErsatzTV.Core/ErsatzTV.Core.csproj
+++ b/ErsatzTV.Core/ErsatzTV.Core.csproj
@@ -12,21 +12,21 @@
-
+
-
+
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+
diff --git a/ErsatzTV.Core/Interfaces/Scheduling/IPlayoutTimeShifter.cs b/ErsatzTV.Core/Interfaces/Scheduling/IPlayoutTimeShifter.cs
new file mode 100644
index 000000000..78e8b6bca
--- /dev/null
+++ b/ErsatzTV.Core/Interfaces/Scheduling/IPlayoutTimeShifter.cs
@@ -0,0 +1,8 @@
+using ErsatzTV.Core.Domain;
+
+namespace ErsatzTV.Core.Interfaces.Scheduling;
+
+public interface IPlayoutTimeShifter
+{
+ public void TimeShift(Playout playout, DateTimeOffset now, bool force);
+}
diff --git a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
index 9e8ba9c6c..9dbbd6c1b 100644
--- a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
+++ b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
@@ -17,6 +17,7 @@ public class PlayoutBuilder : IPlayoutBuilder
{
private static readonly Random Random = new();
private readonly IArtistRepository _artistRepository;
+ private readonly IPlayoutTimeShifter _playoutTimeShifter;
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
@@ -26,6 +27,7 @@ public class PlayoutBuilder : IPlayoutBuilder
private ILogger _logger;
public PlayoutBuilder(
+ IPlayoutTimeShifter playoutTimeShifter,
IConfigElementRepository configElementRepository,
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository,
@@ -34,6 +36,7 @@ public class PlayoutBuilder : IPlayoutBuilder
ILocalFileSystem localFileSystem,
ILogger logger)
{
+ _playoutTimeShifter = playoutTimeShifter;
_configElementRepository = configElementRepository;
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
@@ -79,6 +82,12 @@ public class PlayoutBuilder : IPlayoutBuilder
// return await Build(playout, mode, parameters with { Start = parameters.Start.AddDays(-2) });
// }
+ // time shift on demand channel if needed
+ if (playout.Channel.ProgressMode is ChannelProgressMode.OnDemand && mode is not PlayoutBuildMode.Reset)
+ {
+ _playoutTimeShifter.TimeShift(playout, parameters.Start, false);
+ }
+
return await Build(playout, mode, parameters, cancellationToken);
}
@@ -238,6 +247,13 @@ public class PlayoutBuilder : IPlayoutBuilder
playout.Items.Clear();
playout.Anchor = null;
playout.ProgramScheduleAnchors.Clear();
+ playout.OnDemandCheckpoint = null;
+
+ // don't trim start for on demand channels, we want to time shift it all forward
+ if (playout.Channel.ProgressMode is ChannelProgressMode.OnDemand)
+ {
+ TrimStart = false;
+ }
await BuildPlayoutItems(
playout,
@@ -247,6 +263,12 @@ public class PlayoutBuilder : IPlayoutBuilder
true,
cancellationToken);
+ // time shift on demand channel if needed
+ if (playout.Channel.ProgressMode is ChannelProgressMode.OnDemand)
+ {
+ _playoutTimeShifter.TimeShift(playout, parameters.Start, false);
+ }
+
return playout;
}
@@ -391,20 +413,24 @@ public class PlayoutBuilder : IPlayoutBuilder
playout.Items.RemoveAll(old => old.FinishOffset < trimBefore);
}
- // check for future items that aren't grouped inside range
- var futureItems = playout.Items.Filter(i => i.StartOffset > trimAfter).ToList();
- foreach (PlayoutItem futureItem in futureItems)
+ // on demand channels end up with slightly more than expected due to time shifting from midnight to first build
+ if (playout.Channel.ProgressMode is not ChannelProgressMode.OnDemand)
{
- if (playout.Items.All(i => i == futureItem || i.GuideGroup != futureItem.GuideGroup))
+ // check for future items that aren't grouped inside range
+ var futureItems = playout.Items.Filter(i => i.StartOffset > trimAfter).ToList();
+ foreach (PlayoutItem futureItem in futureItems)
{
- _logger.LogError(
- "Playout item scheduled for {Time} after hard stop of {HardStop}",
- futureItem.StartOffset,
- trimAfter);
+ if (playout.Items.All(i => i == futureItem || i.GuideGroup != futureItem.GuideGroup))
+ {
+ _logger.LogError(
+ "Playout item scheduled for {Time} after hard stop of {HardStop}",
+ futureItem.StartOffset,
+ trimAfter);
- // it feels hacky to have to clean up a playlist like this,
- // so only log the error, and leave the bad data to fail tests
- // playout.Items.Remove(futureItem);
+ // it feels hacky to have to clean up a playlist like this,
+ // so only log the error, and leave the bad data to fail tests
+ // playout.Items.Remove(futureItem);
+ }
}
}
@@ -425,6 +451,12 @@ public class PlayoutBuilder : IPlayoutBuilder
playout.ProgramScheduleAlternates,
playoutStart);
+ // on demand channels do NOT use alternate schedules
+ if (playout.Channel.ProgressMode is ChannelProgressMode.OnDemand)
+ {
+ activeSchedule = playout.ProgramSchedule;
+ }
+
// _logger.LogDebug("Active schedule is: {Schedule}", activeSchedule.Name);
// random start points are disabled in some scenarios, so ensure it's enabled and active
diff --git a/ErsatzTV.Core/Scheduling/PlayoutTimeShifter.cs b/ErsatzTV.Core/Scheduling/PlayoutTimeShifter.cs
new file mode 100644
index 000000000..88c17471c
--- /dev/null
+++ b/ErsatzTV.Core/Scheduling/PlayoutTimeShifter.cs
@@ -0,0 +1,90 @@
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Core.Interfaces.FFmpeg;
+using ErsatzTV.Core.Interfaces.Scheduling;
+using Microsoft.Extensions.Logging;
+
+namespace ErsatzTV.Core.Scheduling;
+
+public class PlayoutTimeShifter(IFFmpegSegmenterService segmenterService, ILogger logger)
+ : IPlayoutTimeShifter
+{
+ public void TimeShift(Playout playout, DateTimeOffset now, bool force)
+ {
+ if (playout.Channel.ProgressMode is not ChannelProgressMode.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 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;
+ }
+}
diff --git a/ErsatzTV.FFmpeg/ErsatzTV.FFmpeg.csproj b/ErsatzTV.FFmpeg/ErsatzTV.FFmpeg.csproj
index 99f772df7..72d05c640 100644
--- a/ErsatzTV.FFmpeg/ErsatzTV.FFmpeg.csproj
+++ b/ErsatzTV.FFmpeg/ErsatzTV.FFmpeg.csproj
@@ -10,7 +10,7 @@
-
+
diff --git a/ErsatzTV.Infrastructure.MySql/ErsatzTV.Infrastructure.MySql.csproj b/ErsatzTV.Infrastructure.MySql/ErsatzTV.Infrastructure.MySql.csproj
index 14ac77c68..14d5d415b 100644
--- a/ErsatzTV.Infrastructure.MySql/ErsatzTV.Infrastructure.MySql.csproj
+++ b/ErsatzTV.Infrastructure.MySql/ErsatzTV.Infrastructure.MySql.csproj
@@ -16,7 +16,7 @@
-
+
diff --git a/ErsatzTV.Infrastructure.MySql/Migrations/20240716141648_Add_Channel_ProgressMode.Designer.cs b/ErsatzTV.Infrastructure.MySql/Migrations/20240716141648_Add_Channel_ProgressMode.Designer.cs
new file mode 100644
index 000000000..51aa4b682
--- /dev/null
+++ b/ErsatzTV.Infrastructure.MySql/Migrations/20240716141648_Add_Channel_ProgressMode.Designer.cs
@@ -0,0 +1,5802 @@
+//
+using System;
+using ErsatzTV.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace ErsatzTV.Infrastructure.MySql.Migrations
+{
+ [DbContext(typeof(TvContext))]
+ [Migration("20240716141648_Add_Channel_ProgressMode")]
+ partial class Add_Channel_ProgressMode
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.7")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("ArtistMetadataId")
+ .HasColumnType("int");
+
+ b.Property("ArtworkId")
+ .HasColumnType("int");
+
+ b.Property("EpisodeMetadataId")
+ .HasColumnType("int");
+
+ b.Property("ImageMetadataId")
+ .HasColumnType("int");
+
+ b.Property("MovieMetadataId")
+ .HasColumnType("int");
+
+ b.Property("MusicVideoMetadataId")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .HasColumnType("longtext");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ b.Property("OtherVideoMetadataId")
+ .HasColumnType("int");
+
+ b.Property("Role")
+ .HasColumnType("longtext");
+
+ b.Property("SeasonMetadataId")
+ .HasColumnType("int");
+
+ b.Property("ShowMetadataId")
+ .HasColumnType("int");
+
+ b.Property("SongMetadataId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ArtistMetadataId");
+
+ b.HasIndex("ArtworkId")
+ .IsUnique();
+
+ b.HasIndex("EpisodeMetadataId");
+
+ b.HasIndex("ImageMetadataId");
+
+ b.HasIndex("MovieMetadataId");
+
+ b.HasIndex("MusicVideoMetadataId");
+
+ b.HasIndex("OtherVideoMetadataId");
+
+ b.HasIndex("SeasonMetadataId");
+
+ b.HasIndex("ShowMetadataId");
+
+ b.HasIndex("SongMetadataId");
+
+ b.ToTable("Actor", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.ArtistMetadata", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("ArtistId")
+ .HasColumnType("int");
+
+ b.Property("Biography")
+ .HasColumnType("longtext");
+
+ b.Property("DateAdded")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DateUpdated")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Disambiguation")
+ .HasColumnType("longtext");
+
+ b.Property("Formed")
+ .HasColumnType("longtext");
+
+ b.Property("MetadataKind")
+ .HasColumnType("int");
+
+ b.Property("OriginalTitle")
+ .HasColumnType("longtext");
+
+ b.Property("ReleaseDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("SortTitle")
+ .HasColumnType("longtext");
+
+ b.Property("Title")
+ .HasColumnType("longtext");
+
+ b.Property("Year")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ArtistId");
+
+ b.ToTable("ArtistMetadata", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.Artwork", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("ArtistMetadataId")
+ .HasColumnType("int");
+
+ b.Property("ArtworkKind")
+ .HasColumnType("int");
+
+ b.Property("BlurHash43")
+ .HasColumnType("longtext");
+
+ b.Property("BlurHash54")
+ .HasColumnType("longtext");
+
+ b.Property("BlurHash64")
+ .HasColumnType("longtext");
+
+ b.Property("ChannelId")
+ .HasColumnType("int");
+
+ b.Property("DateAdded")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DateUpdated")
+ .HasColumnType("datetime(6)");
+
+ b.Property("EpisodeMetadataId")
+ .HasColumnType("int");
+
+ b.Property("ImageMetadataId")
+ .HasColumnType("int");
+
+ b.Property("MovieMetadataId")
+ .HasColumnType("int");
+
+ b.Property("MusicVideoMetadataId")
+ .HasColumnType("int");
+
+ b.Property("OtherVideoMetadataId")
+ .HasColumnType("int");
+
+ b.Property("Path")
+ .HasColumnType("longtext");
+
+ b.Property("SeasonMetadataId")
+ .HasColumnType("int");
+
+ b.Property("ShowMetadataId")
+ .HasColumnType("int");
+
+ b.Property("SongMetadataId")
+ .HasColumnType("int");
+
+ b.Property("SourcePath")
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ArtistMetadataId");
+
+ b.HasIndex("ChannelId");
+
+ b.HasIndex("EpisodeMetadataId");
+
+ b.HasIndex("ImageMetadataId");
+
+ b.HasIndex("MovieMetadataId");
+
+ b.HasIndex("MusicVideoMetadataId");
+
+ b.HasIndex("OtherVideoMetadataId");
+
+ b.HasIndex("SeasonMetadataId");
+
+ b.HasIndex("ShowMetadataId");
+
+ b.HasIndex("SongMetadataId");
+
+ b.ToTable("Artwork", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("Categories")
+ .HasColumnType("longtext");
+
+ b.Property("FFmpegProfileId")
+ .HasColumnType("int");
+
+ b.Property("FallbackFillerId")
+ .HasColumnType("int");
+
+ b.Property("Group")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("longtext")
+ .HasDefaultValue("ErsatzTV");
+
+ b.Property("MusicVideoCreditsMode")
+ .HasColumnType("int");
+
+ b.Property("MusicVideoCreditsTemplate")
+ .HasColumnType("longtext");
+
+ b.Property("Name")
+ .HasColumnType("longtext");
+
+ b.Property("Number")
+ .HasColumnType("varchar(255)");
+
+ b.Property("PreferredAudioLanguageCode")
+ .HasColumnType("longtext");
+
+ b.Property("PreferredAudioTitle")
+ .HasColumnType("longtext");
+
+ b.Property("PreferredSubtitleLanguageCode")
+ .HasColumnType("longtext");
+
+ b.Property("ProgressMode")
+ .HasColumnType("int");
+
+ b.Property("StreamingMode")
+ .HasColumnType("int");
+
+ b.Property("SubtitleMode")
+ .HasColumnType("int");
+
+ b.Property("UniqueId")
+ .HasColumnType("char(36)");
+
+ b.Property("WatermarkId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FFmpegProfileId");
+
+ b.HasIndex("FallbackFillerId");
+
+ b.HasIndex("Number")
+ .IsUnique();
+
+ b.HasIndex("WatermarkId");
+
+ b.ToTable("Channel", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelWatermark", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("DurationSeconds")
+ .HasColumnType("int");
+
+ b.Property("FrequencyMinutes")
+ .HasColumnType("int");
+
+ b.Property("HorizontalMarginPercent")
+ .HasColumnType("int");
+
+ b.Property("Image")
+ .HasColumnType("longtext");
+
+ b.Property("ImageSource")
+ .HasColumnType("int");
+
+ b.Property("Location")
+ .HasColumnType("int");
+
+ b.Property("Mode")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .HasColumnType("longtext");
+
+ b.Property("Opacity")
+ .HasColumnType("int");
+
+ b.Property("PlaceWithinSourceContent")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Size")
+ .HasColumnType("int");
+
+ b.Property("VerticalMarginPercent")
+ .HasColumnType("int");
+
+ b.Property("WidthPercent")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.ToTable("ChannelWatermark", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .HasColumnType("longtext");
+
+ b.Property("UseCustomPlaybackOrder")
+ .HasColumnType("tinyint(1)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Collection", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.CollectionItem", b =>
+ {
+ b.Property("CollectionId")
+ .HasColumnType("int");
+
+ b.Property("MediaItemId")
+ .HasColumnType("int");
+
+ b.Property("CustomIndex")
+ .HasColumnType("int");
+
+ b.HasKey("CollectionId", "MediaItemId");
+
+ b.HasIndex("MediaItemId");
+
+ b.ToTable("CollectionItem", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.ConfigElement", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("Key")
+ .HasColumnType("varchar(255)");
+
+ b.Property("Value")
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Key")
+ .IsUnique();
+
+ b.ToTable("ConfigElement", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.Director", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("EpisodeMetadataId")
+ .HasColumnType("int");
+
+ b.Property("MovieMetadataId")
+ .HasColumnType("int");
+
+ b.Property("MusicVideoMetadataId")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .HasColumnType("longtext");
+
+ b.Property("OtherVideoMetadataId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EpisodeMetadataId");
+
+ b.HasIndex("MovieMetadataId");
+
+ b.HasIndex("MusicVideoMetadataId");
+
+ b.HasIndex("OtherVideoMetadataId");
+
+ b.ToTable("Director", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyCollection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("Etag")
+ .HasColumnType("longtext");
+
+ b.Property("ItemId")
+ .HasColumnType("longtext");
+
+ b.Property("Name")
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.ToTable("EmbyCollection", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyConnection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("Address")
+ .HasColumnType("longtext");
+
+ b.Property("EmbyMediaSourceId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EmbyMediaSourceId");
+
+ b.ToTable("EmbyConnection", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyPathReplacement", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("EmbyMediaSourceId")
+ .HasColumnType("int");
+
+ b.Property("EmbyPath")
+ .HasColumnType("longtext");
+
+ b.Property("LocalPath")
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EmbyMediaSourceId");
+
+ b.ToTable("EmbyPathReplacement", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("DateAdded")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DateUpdated")
+ .HasColumnType("datetime(6)");
+
+ b.Property("EpisodeId")
+ .HasColumnType("int");
+
+ b.Property("EpisodeNumber")
+ .HasColumnType("int");
+
+ b.Property("MetadataKind")
+ .HasColumnType("int");
+
+ b.Property("OriginalTitle")
+ .HasColumnType("longtext");
+
+ b.Property("Outline")
+ .HasColumnType("longtext");
+
+ b.Property("Plot")
+ .HasColumnType("longtext");
+
+ b.Property("ReleaseDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("SortTitle")
+ .HasColumnType("longtext");
+
+ b.Property("Tagline")
+ .HasColumnType("longtext");
+
+ b.Property("Title")
+ .HasColumnType("longtext");
+
+ b.Property("Year")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EpisodeId");
+
+ b.ToTable("EpisodeMetadata", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("AllowBFrames")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("AudioBitrate")
+ .HasColumnType("int");
+
+ b.Property("AudioBufferSize")
+ .HasColumnType("int");
+
+ b.Property("AudioChannels")
+ .HasColumnType("int");
+
+ b.Property("AudioFormat")
+ .HasColumnType("int");
+
+ b.Property("AudioSampleRate")
+ .HasColumnType("int");
+
+ b.Property("BitDepth")
+ .HasColumnType("int");
+
+ b.Property("DeinterlaceVideo")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("tinyint(1)")
+ .HasDefaultValue(true);
+
+ b.Property("HardwareAcceleration")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .HasColumnType("longtext");
+
+ b.Property("NormalizeFramerate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("tinyint(1)")
+ .HasDefaultValue(false);
+
+ b.Property("NormalizeLoudnessMode")
+ .HasColumnType("int");
+
+ b.Property("QsvExtraHardwareFrames")
+ .HasColumnType("int");
+
+ b.Property("ResolutionId")
+ .HasColumnType("int");
+
+ b.Property("ScalingBehavior")
+ .HasColumnType("int");
+
+ b.Property("ThreadCount")
+ .HasColumnType("int");
+
+ b.Property("VaapiDevice")
+ .HasColumnType("longtext");
+
+ b.Property("VaapiDriver")
+ .HasColumnType("int");
+
+ b.Property("VideoBitrate")
+ .HasColumnType("int");
+
+ b.Property("VideoBufferSize")
+ .HasColumnType("int");
+
+ b.Property("VideoFormat")
+ .HasColumnType("int");
+
+ b.Property("VideoPreset")
+ .HasColumnType("longtext");
+
+ b.Property("VideoProfile")
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ResolutionId");
+
+ b.ToTable("FFmpegProfile", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.Filler.FillerPreset", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("AllowWatermarks")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("CollectionId")
+ .HasColumnType("int");
+
+ b.Property("CollectionType")
+ .HasColumnType("int");
+
+ b.Property("Count")
+ .HasColumnType("int");
+
+ b.Property("Duration")
+ .HasColumnType("time(6)");
+
+ b.Property("FillerKind")
+ .HasColumnType("int");
+
+ b.Property("FillerMode")
+ .HasColumnType("int");
+
+ b.Property("MediaItemId")
+ .HasColumnType("int");
+
+ b.Property("MultiCollectionId")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .HasColumnType("longtext");
+
+ b.Property("PadToNearestMinute")
+ .HasColumnType("int");
+
+ b.Property("SmartCollectionId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CollectionId");
+
+ b.HasIndex("MediaItemId");
+
+ b.HasIndex("MultiCollectionId");
+
+ b.HasIndex("SmartCollectionId");
+
+ b.ToTable("FillerPreset", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.Genre", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("ArtistMetadataId")
+ .HasColumnType("int");
+
+ b.Property("EpisodeMetadataId")
+ .HasColumnType("int");
+
+ b.Property("ImageMetadataId")
+ .HasColumnType("int");
+
+ b.Property("MovieMetadataId")
+ .HasColumnType("int");
+
+ b.Property("MusicVideoMetadataId")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .HasColumnType("longtext");
+
+ b.Property("OtherVideoMetadataId")
+ .HasColumnType("int");
+
+ b.Property("SeasonMetadataId")
+ .HasColumnType("int");
+
+ b.Property("ShowMetadataId")
+ .HasColumnType("int");
+
+ b.Property("SongMetadataId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ArtistMetadataId");
+
+ b.HasIndex("EpisodeMetadataId");
+
+ b.HasIndex("ImageMetadataId");
+
+ b.HasIndex("MovieMetadataId");
+
+ b.HasIndex("MusicVideoMetadataId");
+
+ b.HasIndex("OtherVideoMetadataId");
+
+ b.HasIndex("SeasonMetadataId");
+
+ b.HasIndex("ShowMetadataId");
+
+ b.HasIndex("SongMetadataId");
+
+ b.ToTable("Genre");
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.ImageFolderDuration", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("DurationSeconds")
+ .HasColumnType("double");
+
+ b.Property("LibraryFolderId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LibraryFolderId")
+ .IsUnique();
+
+ b.ToTable("ImageFolderDuration", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.ImageMetadata", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("DateAdded")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DateUpdated")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DurationSeconds")
+ .HasColumnType("double");
+
+ b.Property("ImageId")
+ .HasColumnType("int");
+
+ b.Property("MetadataKind")
+ .HasColumnType("int");
+
+ b.Property("OriginalTitle")
+ .HasColumnType("longtext");
+
+ b.Property("ReleaseDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("SortTitle")
+ .HasColumnType("longtext");
+
+ b.Property("Title")
+ .HasColumnType("longtext");
+
+ b.Property("Year")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ImageId");
+
+ b.ToTable("ImageMetadata", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinCollection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("Etag")
+ .HasColumnType("longtext");
+
+ b.Property("ItemId")
+ .HasColumnType("longtext");
+
+ b.Property("Name")
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.ToTable("JellyfinCollection", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinConnection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("Address")
+ .HasColumnType("longtext");
+
+ b.Property("JellyfinMediaSourceId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("JellyfinMediaSourceId");
+
+ b.ToTable("JellyfinConnection", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinPathReplacement", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("JellyfinMediaSourceId")
+ .HasColumnType("int");
+
+ b.Property("JellyfinPath")
+ .HasColumnType("longtext");
+
+ b.Property("LocalPath")
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.HasIndex("JellyfinMediaSourceId");
+
+ b.ToTable("JellyfinPathReplacement", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.LanguageCode", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("EnglishName")
+ .HasColumnType("longtext");
+
+ b.Property("FrenchName")
+ .HasColumnType("longtext");
+
+ b.Property("ThreeCode1")
+ .HasColumnType("longtext");
+
+ b.Property("ThreeCode2")
+ .HasColumnType("longtext");
+
+ b.Property("TwoCode")
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.ToTable("LanguageCode", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("LastScan")
+ .HasColumnType("datetime(6)");
+
+ b.Property("MediaKind")
+ .HasColumnType("int");
+
+ b.Property("MediaSourceId")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MediaSourceId");
+
+ b.ToTable("Library", (string)null);
+
+ b.UseTptMappingStrategy();
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryFolder", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("Etag")
+ .HasColumnType("longtext");
+
+ b.Property("LibraryPathId")
+ .HasColumnType("int");
+
+ b.Property("ParentId")
+ .HasColumnType("int");
+
+ b.Property("Path")
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LibraryPathId");
+
+ b.HasIndex("ParentId");
+
+ b.ToTable("LibraryFolder", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("LastScan")
+ .HasColumnType("datetime(6)");
+
+ b.Property("LibraryId")
+ .HasColumnType("int");
+
+ b.Property("Path")
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LibraryId");
+
+ b.ToTable("LibraryPath", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.MediaChapter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("ChapterId")
+ .HasColumnType("bigint");
+
+ b.Property("EndTime")
+ .HasColumnType("time(6)");
+
+ b.Property("MediaVersionId")
+ .HasColumnType("int");
+
+ b.Property