diff --git a/ErsatzTV.Application/ConcurrencyExtensions.cs b/ErsatzTV.Application/ConcurrencyExtensions.cs
new file mode 100644
index 000000000..f77965b5c
--- /dev/null
+++ b/ErsatzTV.Application/ConcurrencyExtensions.cs
@@ -0,0 +1,33 @@
+using ErsatzTV.Core;
+using ErsatzTV.Core.Errors;
+using Microsoft.EntityFrameworkCore;
+
+namespace ErsatzTV.Application;
+
+public static class ConcurrencyExtensions
+{
+ ///
+ /// Persist pending changes, mapping the EF optimistic-concurrency failure to
+ /// (→ 412). When a versioned root carries an
+ /// IsConcurrencyToken column and its Version is bumped before saving, EF emits
+ /// UPDATE … WHERE Id=@id AND Version=@original; a zero-row result (another writer won
+ /// the race between our load and save) throws .
+ /// This is the backstop that closes the load→save TOCTOU the handler pre-check cannot.
+ /// Issue #253.
+ ///
+ public static async Task> SaveChangesWithConcurrencyGuard(
+ this DbContext dbContext,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ await dbContext.SaveChangesAsync(cancellationToken);
+ return Unit.Default;
+ }
+ catch (DbUpdateConcurrencyException)
+ {
+ return new PreconditionFailedError(
+ "The resource was modified by another request. Reload and try again.");
+ }
+ }
+}
diff --git a/ErsatzTV.Application/Scheduling/BlockViewModel.cs b/ErsatzTV.Application/Scheduling/BlockViewModel.cs
index d54a36213..cda0dbe9d 100644
--- a/ErsatzTV.Application/Scheduling/BlockViewModel.cs
+++ b/ErsatzTV.Application/Scheduling/BlockViewModel.cs
@@ -8,4 +8,5 @@ public record BlockViewModel(
string GroupName,
string Name,
int Minutes,
- BlockStopScheduling StopScheduling);
+ BlockStopScheduling StopScheduling,
+ int Version);
diff --git a/ErsatzTV.Application/Scheduling/Commands/ReplaceBlockItems.cs b/ErsatzTV.Application/Scheduling/Commands/ReplaceBlockItems.cs
index 1aebb0633..7f7173da1 100644
--- a/ErsatzTV.Application/Scheduling/Commands/ReplaceBlockItems.cs
+++ b/ErsatzTV.Application/Scheduling/Commands/ReplaceBlockItems.cs
@@ -9,5 +9,6 @@ public record ReplaceBlockItems(
string Name,
int Minutes,
BlockStopScheduling StopScheduling,
- List Items)
+ List Items,
+ Option ExpectedVersion = default)
: IRequest>;
diff --git a/ErsatzTV.Application/Scheduling/Commands/ReplaceBlockItemsHandler.cs b/ErsatzTV.Application/Scheduling/Commands/ReplaceBlockItemsHandler.cs
index a07d5261d..d028e3556 100644
--- a/ErsatzTV.Application/Scheduling/Commands/ReplaceBlockItemsHandler.cs
+++ b/ErsatzTV.Application/Scheduling/Commands/ReplaceBlockItemsHandler.cs
@@ -16,10 +16,21 @@ public class ReplaceBlockItemsHandler(IDbContextFactory dbContextFact
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation validation = await Validate(dbContext, request, cancellationToken);
- return await validation.Apply(ps => Persist(dbContext, request, ps, cancellationToken));
+
+ // Introduce the optimistic-concurrency check as a standalone Either AFTER the validation
+ // pipeline (never via Apply) so a PreconditionFailedError survives to a 412 and is not
+ // flattened to a generic 422 by Join() (issue #253 / api-conventions §7a).
+ // LanguageExtensions.ToEither joins the Seq to a single BaseError (the native
+ // Validation.ToEither() would keep Seq and is called explicitly here to avoid that shadow).
+ Either validated = LanguageExtensions.ToEither(validation)
+ .Bind(block => block.CheckVersion(request.ExpectedVersion));
+
+ return await validated.Match(
+ Right: block => Persist(dbContext, request, block, cancellationToken),
+ Left: error => Task.FromResult>(error));
}
- private static async Task Persist(
+ private static async Task> Persist(
TvContext dbContext,
ReplaceBlockItems request,
Block block,
@@ -33,7 +44,10 @@ public class ReplaceBlockItemsHandler(IDbContextFactory dbContextFact
dbContext.RemoveRange(block.Items);
block.Items = request.Items.Map(i => BuildItem(block, i.Index, i)).ToList();
- await dbContext.SaveChangesAsync(cancellationToken);
+ // Unconditional bump: EF emits the root UPDATE only when a scalar actually differs, so a
+ // same-value/no-op save would otherwise write no root row and neither fire the concurrency
+ // token nor rotate other clients' ETags. Bumping guarantees both on every save (issue #253).
+ block.Version++;
// TODO: refresh any playouts that use this schedule
// foreach (Playout playout in programSchedule.Playouts)
@@ -41,7 +55,9 @@ public class ReplaceBlockItemsHandler(IDbContextFactory dbContextFact
// await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh));
// }
- return Unit.Default;
+ // Save through the guard so an EF concurrency failure (a racing writer won between our load
+ // and save) maps to 412 rather than surfacing as a 500.
+ return await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
}
private static BlockItem BuildItem(Block block, int index, ReplaceBlockItem item)
diff --git a/ErsatzTV.Application/Scheduling/Mapper.cs b/ErsatzTV.Application/Scheduling/Mapper.cs
index 861d3266f..d82d9325f 100644
--- a/ErsatzTV.Application/Scheduling/Mapper.cs
+++ b/ErsatzTV.Application/Scheduling/Mapper.cs
@@ -32,7 +32,7 @@ internal static class Mapper
new(blockGroup.Id, blockGroup.Name);
internal static BlockViewModel ProjectToViewModel(Block block) =>
- new(block.Id, block.BlockGroupId, block.BlockGroup.Name, block.Name, block.Minutes, block.StopScheduling);
+ new(block.Id, block.BlockGroupId, block.BlockGroup.Name, block.Name, block.Minutes, block.StopScheduling, block.Version);
internal static BlockItemViewModel ProjectToViewModel(BlockItem blockItem) =>
new(
diff --git a/ErsatzTV.Core/Domain/Collection/Collection.cs b/ErsatzTV.Core/Domain/Collection/Collection.cs
index d882e0b16..04d71218f 100644
--- a/ErsatzTV.Core/Domain/Collection/Collection.cs
+++ b/ErsatzTV.Core/Domain/Collection/Collection.cs
@@ -3,9 +3,10 @@
namespace ErsatzTV.Core.Domain;
[SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix")]
-public class Collection
+public class Collection : IVersionedAggregate
{
public int Id { get; set; }
+ public int Version { get; set; }
public string Name { get; set; }
public bool UseCustomPlaybackOrder { get; set; }
public List MediaItems { get; set; }
diff --git a/ErsatzTV.Core/Domain/Collection/MultiCollection.cs b/ErsatzTV.Core/Domain/Collection/MultiCollection.cs
index e14c05744..741c7e1c2 100644
--- a/ErsatzTV.Core/Domain/Collection/MultiCollection.cs
+++ b/ErsatzTV.Core/Domain/Collection/MultiCollection.cs
@@ -3,9 +3,10 @@
namespace ErsatzTV.Core.Domain;
[SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix")]
-public class MultiCollection
+public class MultiCollection : IVersionedAggregate
{
public int Id { get; set; }
+ public int Version { get; set; }
public string Name { get; set; }
public List Collections { get; set; }
public List SmartCollections { get; set; }
diff --git a/ErsatzTV.Core/Domain/Collection/Playlist.cs b/ErsatzTV.Core/Domain/Collection/Playlist.cs
index fd3e204f5..411409918 100644
--- a/ErsatzTV.Core/Domain/Collection/Playlist.cs
+++ b/ErsatzTV.Core/Domain/Collection/Playlist.cs
@@ -1,8 +1,9 @@
namespace ErsatzTV.Core.Domain;
-public class Playlist
+public class Playlist : IVersionedAggregate
{
public int Id { get; set; }
+ public int Version { get; set; }
public int PlaylistGroupId { get; set; }
public PlaylistGroup PlaylistGroup { get; set; }
public string Name { get; set; }
diff --git a/ErsatzTV.Core/Domain/Collection/RerunCollection.cs b/ErsatzTV.Core/Domain/Collection/RerunCollection.cs
index e6ef7db34..e4fdebe68 100644
--- a/ErsatzTV.Core/Domain/Collection/RerunCollection.cs
+++ b/ErsatzTV.Core/Domain/Collection/RerunCollection.cs
@@ -3,9 +3,10 @@
namespace ErsatzTV.Core.Domain;
[SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix")]
-public class RerunCollection
+public class RerunCollection : IVersionedAggregate
{
public int Id { get; set; }
+ public int Version { get; set; }
public string Name { get; set; }
public CollectionType CollectionType { get; set; }
public int? CollectionId { get; set; }
diff --git a/ErsatzTV.Core/Domain/IVersionedAggregate.cs b/ErsatzTV.Core/Domain/IVersionedAggregate.cs
new file mode 100644
index 000000000..c863496d5
--- /dev/null
+++ b/ErsatzTV.Core/Domain/IVersionedAggregate.cs
@@ -0,0 +1,12 @@
+namespace ErsatzTV.Core.Domain;
+
+///
+/// A root aggregate that carries an integer optimistic-concurrency token (issue #253).
+/// The token is exposed as a strong ETag on the aggregate's GET, checked against a
+/// PUT's If-Match header (mismatch → 412 Precondition Failed), and bumped on every
+/// successful write so a stale second writer is rejected and every other client's ETag rotates.
+///
+public interface IVersionedAggregate
+{
+ int Version { get; set; }
+}
diff --git a/ErsatzTV.Core/Domain/Playout.cs b/ErsatzTV.Core/Domain/Playout.cs
index f83ee357e..128c60a79 100644
--- a/ErsatzTV.Core/Domain/Playout.cs
+++ b/ErsatzTV.Core/Domain/Playout.cs
@@ -2,9 +2,10 @@
namespace ErsatzTV.Core.Domain;
-public class Playout
+public class Playout : IVersionedAggregate
{
public int Id { get; set; }
+ public int Version { get; set; }
public int ChannelId { get; set; }
public Channel Channel { get; set; }
public int? ProgramScheduleId { get; set; }
diff --git a/ErsatzTV.Core/Domain/ProgramSchedule.cs b/ErsatzTV.Core/Domain/ProgramSchedule.cs
index a29bdc98b..422dd53a3 100644
--- a/ErsatzTV.Core/Domain/ProgramSchedule.cs
+++ b/ErsatzTV.Core/Domain/ProgramSchedule.cs
@@ -2,9 +2,10 @@
namespace ErsatzTV.Core.Domain;
-public class ProgramSchedule
+public class ProgramSchedule : IVersionedAggregate
{
public int Id { get; set; }
+ public int Version { get; set; }
public string Name { get; set; }
public bool KeepMultiPartEpisodesTogether { get; set; }
public bool TreatCollectionsAsShows { get; set; }
diff --git a/ErsatzTV.Core/Domain/Scheduling/Block.cs b/ErsatzTV.Core/Domain/Scheduling/Block.cs
index 645c34e80..16373b9bf 100644
--- a/ErsatzTV.Core/Domain/Scheduling/Block.cs
+++ b/ErsatzTV.Core/Domain/Scheduling/Block.cs
@@ -1,8 +1,9 @@
namespace ErsatzTV.Core.Domain.Scheduling;
-public class Block
+public class Block : IVersionedAggregate
{
public int Id { get; set; }
+ public int Version { get; set; }
public int BlockGroupId { get; set; }
public BlockGroup BlockGroup { get; set; }
public string Name { get; set; }
diff --git a/ErsatzTV.Core/Domain/Scheduling/DecoTemplate.cs b/ErsatzTV.Core/Domain/Scheduling/DecoTemplate.cs
index a33b283f8..67bba9681 100644
--- a/ErsatzTV.Core/Domain/Scheduling/DecoTemplate.cs
+++ b/ErsatzTV.Core/Domain/Scheduling/DecoTemplate.cs
@@ -1,8 +1,9 @@
namespace ErsatzTV.Core.Domain.Scheduling;
-public class DecoTemplate
+public class DecoTemplate : IVersionedAggregate
{
public int Id { get; set; }
+ public int Version { get; set; }
public int DecoTemplateGroupId { get; set; }
public DecoTemplateGroup DecoTemplateGroup { get; set; }
public string Name { get; set; }
diff --git a/ErsatzTV.Core/Domain/Scheduling/Template.cs b/ErsatzTV.Core/Domain/Scheduling/Template.cs
index 89030c537..d03efe95f 100644
--- a/ErsatzTV.Core/Domain/Scheduling/Template.cs
+++ b/ErsatzTV.Core/Domain/Scheduling/Template.cs
@@ -1,8 +1,9 @@
namespace ErsatzTV.Core.Domain.Scheduling;
-public class Template
+public class Template : IVersionedAggregate
{
public int Id { get; set; }
+ public int Version { get; set; }
public int TemplateGroupId { get; set; }
public TemplateGroup TemplateGroup { get; set; }
public string Name { get; set; }
diff --git a/ErsatzTV.Core/Errors/PreconditionFailedError.cs b/ErsatzTV.Core/Errors/PreconditionFailedError.cs
new file mode 100644
index 000000000..5c738427e
--- /dev/null
+++ b/ErsatzTV.Core/Errors/PreconditionFailedError.cs
@@ -0,0 +1,14 @@
+namespace ErsatzTV.Core.Errors;
+
+///
+/// A raised when an optimistic-concurrency precondition fails — the
+/// caller's If-Match version no longer matches the stored aggregate Version
+/// (issue #253). REST endpoints map this to HTTP 412 Precondition Failed; it is distinct from
+/// the 409 Conflict raised when a mutation races a background build lock (api-conventions §3a).
+///
+public class PreconditionFailedError : BaseError
+{
+ public PreconditionFailedError(string value) : base(value)
+ {
+ }
+}
diff --git a/ErsatzTV.Core/VersionedAggregateExtensions.cs b/ErsatzTV.Core/VersionedAggregateExtensions.cs
new file mode 100644
index 000000000..f134f0849
--- /dev/null
+++ b/ErsatzTV.Core/VersionedAggregateExtensions.cs
@@ -0,0 +1,24 @@
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Core.Errors;
+
+namespace ErsatzTV.Core;
+
+public static class VersionedAggregateExtensions
+{
+ ///
+ /// Optimistic-concurrency pre-check (issue #253). When the caller supplied an expected
+ /// version (from If-Match), fail with (→ 412)
+ /// if it no longer matches the loaded aggregate. An absent expectation is a force-write
+ /// (Phase 1 back-compat). This returns a standalone so the 412
+ /// is introduced AFTER the validation pipeline and never flattened to 422 by
+ /// (see api-conventions §7a).
+ ///
+ public static Either CheckVersion(this T aggregate, Option expectedVersion)
+ where T : IVersionedAggregate =>
+ expectedVersion.Match(
+ Some: expected => aggregate.Version == expected
+ ? Right(aggregate)
+ : Left(new PreconditionFailedError(
+ "The resource was modified by another request. Reload and try again.")),
+ None: () => Right(aggregate));
+}
diff --git a/ErsatzTV.Infrastructure.MySql/Migrations/20260711143732_AddAggregateVersions.Designer.cs b/ErsatzTV.Infrastructure.MySql/Migrations/20260711143732_AddAggregateVersions.Designer.cs
new file mode 100644
index 000000000..4d7386063
--- /dev/null
+++ b/ErsatzTV.Infrastructure.MySql/Migrations/20260711143732_AddAggregateVersions.Designer.cs
@@ -0,0 +1,7240 @@
+//
+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("20260711143732_AddAggregateVersions")]
+ partial class AddAggregateVersions
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.12")
+ .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("RemoteStreamMetadataId")
+ .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("RemoteStreamMetadataId");
+
+ 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("varchar(255)")
+ .UseCollation("utf8mb4_general_ci");
+
+ b.Property("Year")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ArtistId");
+
+ b.HasIndex("Title");
+
+ 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("IsMetadataOrphan")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("tinyint(1)")
+ .HasComputedColumnSql("CASE WHEN COALESCE(ArtistMetadataId, ChannelId, EpisodeMetadataId, MovieMetadataId, MusicVideoMetadataId, OtherVideoMetadataId, SeasonMetadataId, ShowMetadataId, SongMetadataId, ImageMetadataId, RemoteStreamMetadataId) IS NULL THEN 1 ELSE NULL END", false);
+
+ b.Property("MovieMetadataId")
+ .HasColumnType("int");
+
+ b.Property("MusicVideoMetadataId")
+ .HasColumnType("int");
+
+ b.Property("OriginalContentType")
+ .HasColumnType("longtext");
+
+ b.Property("OtherVideoMetadataId")
+ .HasColumnType("int");
+
+ b.Property("Path")
+ .HasColumnType("longtext");
+
+ b.Property("RemoteStreamMetadataId")
+ .HasColumnType("int");
+
+ 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("IsMetadataOrphan");
+
+ b.HasIndex("MovieMetadataId");
+
+ b.HasIndex("MusicVideoMetadataId");
+
+ b.HasIndex("OtherVideoMetadataId");
+
+ b.HasIndex("RemoteStreamMetadataId");
+
+ b.HasIndex("SeasonMetadataId");
+
+ b.HasIndex("ShowMetadataId");
+
+ b.HasIndex("SongMetadataId");
+
+ b.ToTable("Artwork", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.BlockItemGraphicsElement", b =>
+ {
+ b.Property("BlockItemId")
+ .HasColumnType("int");
+
+ b.Property("GraphicsElementId")
+ .HasColumnType("int");
+
+ b.HasKey("BlockItemId", "GraphicsElementId");
+
+ b.HasIndex("GraphicsElementId");
+
+ b.ToTable("BlockItemGraphicsElement");
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.BlockItemWatermark", b =>
+ {
+ b.Property("BlockItemId")
+ .HasColumnType("int");
+
+ b.Property("WatermarkId")
+ .HasColumnType("int");
+
+ b.HasKey("BlockItemId", "WatermarkId");
+
+ b.HasIndex("WatermarkId");
+
+ b.ToTable("BlockItemWatermark");
+ });
+
+ 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("IdleBehavior")
+ .HasColumnType("int");
+
+ b.Property("IsEnabled")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("tinyint(1)")
+ .HasDefaultValue(true);
+
+ b.Property("MirrorSourceChannelId")
+ .HasColumnType("int");
+
+ b.Property("MusicVideoCreditsMode")
+ .HasColumnType("int");
+
+ b.Property("MusicVideoCreditsTemplate")
+ .HasColumnType("longtext");
+
+ b.Property("Name")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)")
+ .UseCollation("utf8mb4_general_ci");
+
+ b.Property("Number")
+ .HasColumnType("varchar(255)");
+
+ b.Property("PlayoutMode")
+ .HasColumnType("int");
+
+ b.Property("PlayoutOffset")
+ .HasColumnType("time(6)");
+
+ b.Property("PlayoutSource")
+ .HasColumnType("int");
+
+ b.Property("PreferredAudioLanguageCode")
+ .HasColumnType("longtext");
+
+ b.Property("PreferredAudioTitle")
+ .HasColumnType("longtext");
+
+ b.Property("PreferredSubtitleLanguageCode")
+ .HasColumnType("longtext");
+
+ b.Property("ShowInEpg")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("tinyint(1)")
+ .HasDefaultValue(true);
+
+ b.Property("SlugSeconds")
+ .HasColumnType("double");
+
+ b.Property("SongVideoMode")
+ .HasColumnType("int");
+
+ b.Property("SortNumber")
+ .HasColumnType("double");
+
+ b.Property("StreamSelector")
+ .HasColumnType("longtext");
+
+ b.Property("StreamSelectorMode")
+ .HasColumnType("int");
+
+ b.Property("StreamingMode")
+ .HasColumnType("int");
+
+ b.Property("SubtitleMode")
+ .HasColumnType("int");
+
+ b.Property("TranscodeMode")
+ .HasColumnType("int");
+
+ b.Property("UniqueId")
+ .HasColumnType("char(36)");
+
+ b.Property("WatermarkId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FFmpegProfileId");
+
+ b.HasIndex("FallbackFillerId");
+
+ b.HasIndex("MirrorSourceChannelId");
+
+ b.HasIndex("Number")
+ .IsUnique();
+
+ b.HasIndex("WatermarkId");
+
+ b.ToTable("Channel", (string)null);
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelTemplate", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("Description")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)")
+ .HasDefaultValue("");
+
+ b.Property("FFmpegProfileId")
+ .HasColumnType("int");
+
+ b.Property("FallbackFillerId")
+ .HasColumnType("int");
+
+ b.Property("FixedStartTimeBehavior")
+ .HasColumnType("int");
+
+ b.Property("IdleBehavior")
+ .HasColumnType("int");
+
+ b.Property("IsSystem")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("MidRollFillerId")
+ .HasColumnType("int");
+
+ b.Property("MusicVideoCreditsMode")
+ .HasColumnType("int");
+
+ b.Property("MusicVideoCreditsTemplate")
+ .HasColumnType("longtext");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)")
+ .UseCollation("utf8mb4_general_ci");
+
+ b.Property("PlayoutMode")
+ .HasColumnType("int");
+
+ b.Property("PlayoutSource")
+ .HasColumnType("int");
+
+ b.Property("PostRollFillerId")
+ .HasColumnType("int");
+
+ b.Property("PreRollFillerId")
+ .HasColumnType("int");
+
+ b.Property("PreferredAudioLanguageCode")
+ .HasColumnType("longtext");
+
+ b.Property("PreferredAudioTitle")
+ .HasColumnType("longtext");
+
+ b.Property("PreferredSubtitleLanguageCode")
+ .HasColumnType("longtext");
+
+ b.Property("RandomStartPoint")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ShuffleScheduleItems")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("SongVideoMode")
+ .HasColumnType("int");
+
+ b.Property("StreamSelector")
+ .HasColumnType("longtext");
+
+ b.Property("StreamSelectorMode")
+ .HasColumnType("int");
+
+ b.Property("StreamingMode")
+ .HasColumnType("int");
+
+ b.Property("SubtitleMode")
+ .HasColumnType("int");
+
+ b.Property("TranscodeMode")
+ .HasColumnType("int");
+
+ b.Property("WatermarkId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FFmpegProfileId");
+
+ b.HasIndex("FallbackFillerId");
+
+ b.HasIndex("MidRollFillerId");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.HasIndex("PostRollFillerId");
+
+ b.HasIndex("PreRollFillerId");
+
+ b.HasIndex("WatermarkId");
+
+ b.ToTable("ChannelTemplate", (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("double");
+
+ b.Property("Image")
+ .HasColumnType("longtext");
+
+ b.Property("ImageSource")
+ .HasColumnType("int");
+
+ b.Property("Location")
+ .HasColumnType("int");
+
+ b.Property("Mode")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)")
+ .UseCollation("utf8mb4_general_ci");
+
+ b.Property("Opacity")
+ .HasColumnType("int");
+
+ b.Property("OpacityExpression")
+ .HasColumnType("longtext");
+
+ b.Property("OriginalContentType")
+ .HasColumnType("longtext");
+
+ b.Property("PlaceWithinSourceContent")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Size")
+ .HasColumnType("int");
+
+ b.Property("VerticalMarginPercent")
+ .HasColumnType("double");
+
+ b.Property("WidthPercent")
+ .HasColumnType("double");
+
+ b.Property("ZIndex")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ 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")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)")
+ .UseCollation("utf8mb4_general_ci");
+
+ b.Property("UseCustomPlaybackOrder")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ 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.DecoGraphicsElement", b =>
+ {
+ b.Property("DecoId")
+ .HasColumnType("int");
+
+ b.Property("GraphicsElementId")
+ .HasColumnType("int");
+
+ b.HasKey("DecoId", "GraphicsElementId");
+
+ b.HasIndex("GraphicsElementId");
+
+ b.ToTable("DecoGraphicsElement");
+ });
+
+ modelBuilder.Entity("ErsatzTV.Core.Domain.DecoWatermark", b =>
+ {
+ b.Property("DecoId")
+ .HasColumnType("int");
+
+ b.Property("WatermarkId")
+ .HasColumnType("int");
+
+ b.HasKey("DecoId", "WatermarkId");
+
+ b.HasIndex("WatermarkId");
+
+ b.ToTable("DecoWatermark");
+ });
+
+ 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("NormalizeAudio")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("tinyint(1)")
+ .HasDefaultValue(true);
+
+ b.Property("NormalizeColors")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("tinyint(1)")
+ .HasDefaultValue(true);
+
+ b.Property("NormalizeFramerate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("tinyint(1)")
+ .HasDefaultValue(false);
+
+ b.Property("NormalizeLoudnessMode")
+ .HasColumnType("int");
+
+ b.Property("NormalizeVideo")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("tinyint(1)")
+ .HasDefaultValue(true);
+
+ b.Property("PadMode")
+ .HasColumnType("int");
+
+ b.Property("QsvExtraHardwareFrames")
+ .HasColumnType("int");
+
+ b.Property("ResolutionId")
+ .HasColumnType("int");
+
+ b.Property("ScalingBehavior")
+ .HasColumnType("int");
+
+ b.Property("TargetLoudness")
+ .HasColumnType("double");
+
+ b.Property("ThreadCount")
+ .HasColumnType("int");
+
+ b.Property("TonemapAlgorithm")
+ .HasColumnType("int");
+
+ b.Property("VaapiDevice")
+ .HasColumnType("longtext");
+
+ b.Property("VaapiDisplay")
+ .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("Expression")
+ .HasColumnType("longtext");
+
+ b.Property("FillerKind")
+ .HasColumnType("int");
+
+ b.Property("FillerMode")
+ .HasColumnType("int");
+
+ b.Property("MediaItemId")
+ .HasColumnType("int");
+
+ b.Property("MultiCollectionId")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)")
+ .UseCollation("utf8mb4_general_ci");
+
+ b.Property("PadToNearestMinute")
+ .HasColumnType("int");
+
+ b.Property