diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml
index 8fa32cda0..b90794028 100644
--- a/.gitea/workflows/docker-build.yml
+++ b/.gitea/workflows/docker-build.yml
@@ -408,6 +408,16 @@ jobs:
done
echo "::endgroup::"
+ # NOTE (ersatztv#491 -> #627): running the LibraryFolder dedupe fixture against the live `mysql`
+ # service was implemented here and then REMOVED. The coverage gap it closes is real — the two
+ # checks above only ever apply migrations to a fresh EMPTY database, so they execute no rows of any
+ # data-migration logic, and two MySql-only collation defects escaped exactly this gate. But the
+ # fixture proved non-deterministic in CI across three attempts (stale pooled session after a drop,
+ # then lost isolation from a shared database name, then a connect-before-create), and an
+ # intermittently-red gate is worse than none: it trains everyone to re-run instead of read, which is
+ # how the original defects escaped. The fixture itself is retained and is opt-in via
+ # ETV_TEST_MYSQL_CONNECTION (skipped, visibly, without it). Re-arming it here is tracked by #627.
+
functional-e2e:
name: Functional E2E (curl + UI contracts)
runs-on: ubuntu-latest
diff --git a/ErsatzTV.Architecture.Tests/ProviderStaticsWiringTests.cs b/ErsatzTV.Architecture.Tests/ProviderStaticsWiringTests.cs
new file mode 100644
index 000000000..60acf0f81
--- /dev/null
+++ b/ErsatzTV.Architecture.Tests/ProviderStaticsWiringTests.cs
@@ -0,0 +1,104 @@
+using System.Text.RegularExpressions;
+using NUnit.Framework;
+using Shouldly;
+
+namespace ErsatzTV.Architecture.Tests;
+
+///
+/// ersatztv#491: TvContext carries settable provider statics (LastInsertedRowId,
+/// CaseInsensitiveCollation, IsUniqueConstraintViolation, …) that Infrastructure code
+/// reads at runtime. There are TWO composition roots that execute that Infrastructure code —
+/// ErsatzTV/Startup.cs (the host) and ErsatzTV.Scanner/Program.cs (a separate
+/// executable launched per scan by CallLibraryScannerHandler) — and each wires the statics in
+/// its own copy of the provider branch.
+///
+/// The failure mode this guards is "a static nobody assigned": #491 added
+/// IsUniqueConstraintViolation to Startup only, so every production caller of
+/// GetOrAddFolder (all of which live in the scanner) silently kept the conservative
+/// _ => false default and the new catch was inert. Nothing about that is visible in a
+/// unit test, because every test harness wires the classifier itself.
+///
+///
+/// Source-level rather than reflective on purpose: the wiring lives inside a host-builder
+/// lambda that cannot be invoked without standing up a real application, and the thing being
+/// asserted is precisely that a line of code exists in both files.
+///
+///
+[TestFixture]
+public class ProviderStaticsWiringTests
+{
+ ///
+ /// Statics the host wires that the scanner deliberately does not. Add to this only with a reason:
+ /// the default must be provably harmless in the scanner process.
+ ///
+ private static readonly Dictionary ScannerExemptions = new()
+ {
+ // Only read by DbInitializer / DatabaseMigratorService, which run in the host exclusively; no
+ // Infrastructure code on a scan path reads it. Pre-dates #491.
+ ["IsSqlite"] = "read only by DbInitializer + DatabaseMigratorService, both host-only"
+ };
+
+ private static string HostSource => ReadRepoFile(Path.Combine("ErsatzTV", "Startup.cs"));
+
+ private static string ScannerSource => ReadRepoFile(Path.Combine("ErsatzTV.Scanner", "Program.cs"));
+
+ [Test]
+ public void Scanner_should_wire_every_TvContext_provider_static_the_host_wires()
+ {
+ HashSet host = AssignedStatics(HostSource);
+ HashSet scanner = AssignedStatics(ScannerSource);
+
+ // sanity: the parser found the wiring at all, so a rename can't turn this test into a no-op
+ host.ShouldContain("LastInsertedRowId");
+ host.ShouldContain("IsUniqueConstraintViolation");
+ scanner.ShouldContain("LastInsertedRowId");
+
+ List missing = host
+ .Except(scanner)
+ .Except(ScannerExemptions.Keys)
+ .OrderBy(name => name, StringComparer.Ordinal)
+ .ToList();
+
+ missing.ShouldBeEmpty(
+ "ErsatzTV.Scanner/Program.cs does not assign TvContext static(s) that ErsatzTV/Startup.cs "
+ + $"assigns: {string.Join(", ", missing)}. The scanner is a separate process, so an unassigned "
+ + "static keeps its default in every library scan. Wire it in BOTH provider branches, or add "
+ + "it to ScannerExemptions with a reason if the default is provably harmless there.");
+ }
+
+ [Test]
+ public void Both_hosts_should_wire_the_unique_constraint_classifier_for_both_providers()
+ {
+ // The specific #491 regression, asserted directly rather than via set arithmetic: the classifier
+ // must be pointed at a real provider implementation on BOTH branches of BOTH composition roots.
+ foreach ((string name, string source) in new[] { ("host", HostSource), ("scanner", ScannerSource) })
+ {
+ source.ShouldContain(
+ "TvContext.IsUniqueConstraintViolation = SqliteErrorClassifier.IsUniqueConstraintViolation",
+ customMessage: $"{name} does not wire the Sqlite unique-constraint classifier");
+ source.ShouldContain(
+ "TvContext.IsUniqueConstraintViolation = MySqlErrorClassifier.IsUniqueConstraintViolation",
+ customMessage: $"{name} does not wire the MySql unique-constraint classifier");
+ }
+ }
+
+ private static HashSet AssignedStatics(string source) =>
+ Regex.Matches(source, @"\bTvContext\.(?[A-Za-z_][A-Za-z0-9_]*)\s*=[^=]")
+ .Select(m => m.Groups["name"].Value)
+ .ToHashSet(StringComparer.Ordinal);
+
+ private static string ReadRepoFile(string relativePath)
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "ErsatzTV.sln")))
+ {
+ directory = directory.Parent;
+ }
+
+ directory.ShouldNotBeNull("could not locate the repository root (no ErsatzTV.sln above the test binary)");
+
+ string path = Path.Combine(directory!.FullName, relativePath);
+ File.Exists(path).ShouldBeTrue($"expected source file not found: {path}");
+ return File.ReadAllText(path);
+ }
+}
diff --git a/ErsatzTV.Core/Domain/Library/LibraryFolder.cs b/ErsatzTV.Core/Domain/Library/LibraryFolder.cs
index 4a47006e3..f11793fc4 100644
--- a/ErsatzTV.Core/Domain/Library/LibraryFolder.cs
+++ b/ErsatzTV.Core/Domain/Library/LibraryFolder.cs
@@ -1,9 +1,19 @@
-namespace ErsatzTV.Core.Domain;
+namespace ErsatzTV.Core.Domain;
public class LibraryFolder
{
public int Id { get; set; }
public string Path { get; set; }
+
+ ///
+ /// SHA-256 hex of (), the
+ /// indexable stand-in for the unbounded column that backs the unique
+ /// (LibraryPathId, PathHash) constraint — the same shape as MediaFile.PathHash.
+ /// Nullable: rows created before ersatztv#491 carry null until a scan heals them, and a
+ /// unique index treats nulls as distinct so those legacy rows never collide.
+ ///
+ public string PathHash { get; set; }
+
public int LibraryPathId { get; set; }
public LibraryPath LibraryPath { get; set; }
public int? ParentId { get; set; }
diff --git a/ErsatzTV.Infrastructure.MySql/Migrations/20260725121841_Add_LibraryFolder_PathHash_UniqueIndex.Designer.cs b/ErsatzTV.Infrastructure.MySql/Migrations/20260725121841_Add_LibraryFolder_PathHash_UniqueIndex.Designer.cs
new file mode 100644
index 000000000..9e82084ef
--- /dev/null
+++ b/ErsatzTV.Infrastructure.MySql/Migrations/20260725121841_Add_LibraryFolder_PathHash_UniqueIndex.Designer.cs
@@ -0,0 +1,7314 @@
+//
+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("20260725121841_Add_LibraryFolder_PathHash_UniqueIndex")]
+ partial class Add_LibraryFolder_PathHash_UniqueIndex
+ {
+ ///
+ 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("Origin")
+ .HasColumnType("int");
+
+ 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.ChannelGraphicsElement", b =>
+ {
+ b.Property("ChannelId")
+ .HasColumnType("int");
+
+ b.Property("GraphicsElementId")
+ .HasColumnType("int");
+
+ b.HasKey("ChannelId", "GraphicsElementId");
+
+ b.HasIndex("GraphicsElementId");
+
+ b.ToTable("ChannelGraphicsElement");
+ });
+
+ 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("QsvPreferNativeDecoder")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("tinyint(1)")
+ .HasDefaultValue(true);
+
+ 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("PlaylistId")
+ .HasColumnType("int");
+
+ b.Property("SmartCollectionId")
+ .HasColumnType("int");
+
+ b.Property("UseChaptersAsMediaItems")
+ .HasColumnType("tinyint(1)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CollectionId");
+
+ b.HasIndex("MediaItemId");
+
+ b.HasIndex("MultiCollectionId");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.HasIndex("PlaylistId");
+
+ 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("RemoteStreamMetadataId")
+ .HasColumnType("int");
+
+ b.Property