diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260305010333_InitialPostgreSql.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260305010333_InitialPostgreSql.Designer.cs new file mode 100644 index 0000000000..2ac347755b --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260305010333_InitialPostgreSql.Designer.cs @@ -0,0 +1,1690 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Jellyfin.Database.Providers.PostgreSQL.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20260305010333_InitialPostgreSql")] + partial class InitialPostgreSql + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("EndHour") + .HasColumnType("double precision"); + + b.Property("StartHour") + .HasColumnType("double precision"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LogSeverity") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ParentItemId") + .HasColumnType("uuid"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("Index") + .HasColumnType("integer"); + + b.Property("Codec") + .HasColumnType("text"); + + b.Property("CodecTag") + .HasColumnType("text"); + + b.Property("Comment") + .HasColumnType("text"); + + b.Property("Filename") + .HasColumnType("text"); + + b.Property("MimeType") + .HasColumnType("text"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Album") + .HasColumnType("text"); + + b.Property("AlbumArtists") + .HasColumnType("text"); + + b.Property("Artists") + .HasColumnType("text"); + + b.Property("Audio") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CleanName") + .HasColumnType("text"); + + b.Property("CommunityRating") + .HasColumnType("real"); + + b.Property("CriticRating") + .HasColumnType("real"); + + b.Property("CustomRating") + .HasColumnType("text"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateLastMediaAdded") + .HasColumnType("timestamp with time zone"); + + b.Property("DateLastRefreshed") + .HasColumnType("timestamp with time zone"); + + b.Property("DateLastSaved") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeTitle") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasColumnType("text"); + + b.Property("ExternalSeriesId") + .HasColumnType("text"); + + b.Property("ExternalServiceId") + .HasColumnType("text"); + + b.Property("ExtraIds") + .HasColumnType("text"); + + b.Property("ExtraType") + .HasColumnType("integer"); + + b.Property("ForcedSortName") + .HasColumnType("text"); + + b.Property("Genres") + .HasColumnType("text"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("IndexNumber") + .HasColumnType("integer"); + + b.Property("InheritedParentalRatingSubValue") + .HasColumnType("integer"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("integer"); + + b.Property("IsFolder") + .HasColumnType("boolean"); + + b.Property("IsInMixedFolder") + .HasColumnType("boolean"); + + b.Property("IsLocked") + .HasColumnType("boolean"); + + b.Property("IsMovie") + .HasColumnType("boolean"); + + b.Property("IsRepeat") + .HasColumnType("boolean"); + + b.Property("IsSeries") + .HasColumnType("boolean"); + + b.Property("IsVirtualItem") + .HasColumnType("boolean"); + + b.Property("LUFS") + .HasColumnType("real"); + + b.Property("MediaType") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("NormalizationGain") + .HasColumnType("real"); + + b.Property("OfficialRating") + .HasColumnType("text"); + + b.Property("OriginalTitle") + .HasColumnType("text"); + + b.Property("Overview") + .HasColumnType("text"); + + b.Property("OwnerId") + .HasColumnType("text"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("ParentIndexNumber") + .HasColumnType("integer"); + + b.Property("Path") + .HasColumnType("text"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("text"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("text"); + + b.Property("PremiereDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PresentationUniqueKey") + .HasColumnType("text"); + + b.Property("PrimaryVersionId") + .HasColumnType("text"); + + b.Property("ProductionLocations") + .HasColumnType("text"); + + b.Property("ProductionYear") + .HasColumnType("integer"); + + b.Property("RunTimeTicks") + .HasColumnType("bigint"); + + b.Property("SeasonId") + .HasColumnType("uuid"); + + b.Property("SeasonName") + .HasColumnType("text"); + + b.Property("SeriesId") + .HasColumnType("uuid"); + + b.Property("SeriesName") + .HasColumnType("text"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("text"); + + b.Property("ShowId") + .HasColumnType("text"); + + b.Property("Size") + .HasColumnType("bigint"); + + b.Property("SortName") + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Studios") + .HasColumnType("text"); + + b.Property("Tagline") + .HasColumnType("text"); + + b.Property("Tags") + .HasColumnType("text"); + + b.Property("TopParentId") + .HasColumnType("uuid"); + + b.Property("TotalBitrate") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UnratedType") + .HasColumnType("text"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + IsFolder = false, + IsInMixedFolder = false, + IsLocked = false, + IsMovie = false, + IsRepeat = false, + IsSeries = false, + IsVirtualItem = false, + Name = "This is a placeholder item for UserData that has been detacted from its original item", + Type = "PLACEHOLDER" + }); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Blurhash") + .HasColumnType("bytea"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("ImageType") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ProviderValue", "ItemId"); + + b.ToTable("BaseItemProviders"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ChapterIndex") + .HasColumnType("integer"); + + b.Property("ImageDateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("ImagePath") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("StartPositionTicks") + .HasColumnType("bigint"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChromecastVersion") + .HasColumnType("integer"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("boolean"); + + b.Property("IndexBy") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ScrollDirection") + .HasColumnType("integer"); + + b.Property("ShowBackdrop") + .HasColumnType("boolean"); + + b.Property("ShowSidebar") + .HasColumnType("boolean"); + + b.Property("SkipBackwardLength") + .HasColumnType("integer"); + + b.Property("SkipForwardLength") + .HasColumnType("integer"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DisplayPreferencesId") + .HasColumnType("integer"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("IndexBy") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("RememberIndexing") + .HasColumnType("boolean"); + + b.Property("RememberSorting") + .HasColumnType("boolean"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("ViewType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.HasIndex("Type", "Value") + .IsUnique(); + + b.ToTable("ItemValues"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("uuid"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.PrimitiveCollection("KeyframeTicks") + .HasColumnType("bigint[]"); + + b.Property("TotalDuration") + .HasColumnType("bigint"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EndTicks") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartTicks") + .HasColumnType("bigint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("StreamIndex") + .HasColumnType("integer"); + + b.Property("AspectRatio") + .HasColumnType("text"); + + b.Property("AverageFrameRate") + .HasColumnType("real"); + + b.Property("BitDepth") + .HasColumnType("integer"); + + b.Property("BitRate") + .HasColumnType("integer"); + + b.Property("BlPresentFlag") + .HasColumnType("integer"); + + b.Property("ChannelLayout") + .HasColumnType("text"); + + b.Property("Channels") + .HasColumnType("integer"); + + b.Property("Codec") + .HasColumnType("text"); + + b.Property("CodecTag") + .HasColumnType("text"); + + b.Property("CodecTimeBase") + .HasColumnType("text"); + + b.Property("ColorPrimaries") + .HasColumnType("text"); + + b.Property("ColorSpace") + .HasColumnType("text"); + + b.Property("ColorTransfer") + .HasColumnType("text"); + + b.Property("Comment") + .HasColumnType("text"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("integer"); + + b.Property("DvLevel") + .HasColumnType("integer"); + + b.Property("DvProfile") + .HasColumnType("integer"); + + b.Property("DvVersionMajor") + .HasColumnType("integer"); + + b.Property("DvVersionMinor") + .HasColumnType("integer"); + + b.Property("ElPresentFlag") + .HasColumnType("integer"); + + b.Property("Hdr10PlusPresentFlag") + .HasColumnType("boolean"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("IsAnamorphic") + .HasColumnType("boolean"); + + b.Property("IsAvc") + .HasColumnType("boolean"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("IsExternal") + .HasColumnType("boolean"); + + b.Property("IsForced") + .HasColumnType("boolean"); + + b.Property("IsHearingImpaired") + .HasColumnType("boolean"); + + b.Property("IsInterlaced") + .HasColumnType("boolean"); + + b.Property("KeyFrames") + .HasColumnType("text"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("Level") + .HasColumnType("real"); + + b.Property("NalLengthSize") + .HasColumnType("text"); + + b.Property("Path") + .HasColumnType("text"); + + b.Property("PixelFormat") + .HasColumnType("text"); + + b.Property("Profile") + .HasColumnType("text"); + + b.Property("RealFrameRate") + .HasColumnType("real"); + + b.Property("RefFrames") + .HasColumnType("integer"); + + b.Property("Rotation") + .HasColumnType("integer"); + + b.Property("RpuPresentFlag") + .HasColumnType("integer"); + + b.Property("SampleRate") + .HasColumnType("integer"); + + b.Property("StreamType") + .HasColumnType("integer"); + + b.Property("TimeBase") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + b.ToTable("MediaStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PersonType") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("PeopleId") + .HasColumnType("uuid"); + + b.Property("Role") + .HasColumnType("text"); + + b.Property("ListOrder") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("ItemId", "PeopleId", "Role"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("\"UserId\" IS NOT NULL"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("character varying(65535)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("\"UserId\" IS NOT NULL"); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateLastActivity") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateLastActivity") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CustomName") + .HasColumnType("text"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("Width") + .HasColumnType("integer"); + + b.Property("Bandwidth") + .HasColumnType("integer"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("Interval") + .HasColumnType("integer"); + + b.Property("ThumbnailCount") + .HasColumnType("integer"); + + b.Property("TileHeight") + .HasColumnType("integer"); + + b.Property("TileWidth") + .HasColumnType("integer"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("DisplayCollectionsView") + .HasColumnType("boolean"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("boolean"); + + b.Property("EnableAutoLogin") + .HasColumnType("boolean"); + + b.Property("EnableLocalPassword") + .HasColumnType("boolean"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("boolean"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("boolean"); + + b.Property("HidePlayedInLatest") + .HasColumnType("boolean"); + + b.Property("InternalId") + .HasColumnType("bigint"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("integer"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("integer"); + + b.Property("MaxActiveSessions") + .HasColumnType("integer"); + + b.Property("MaxParentalRatingScore") + .HasColumnType("integer"); + + b.Property("MaxParentalRatingSubScore") + .HasColumnType("integer"); + + b.Property("MustUpdatePassword") + .HasColumnType("boolean"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("character varying(65535)"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("boolean"); + + b.Property("RememberAudioSelections") + .HasColumnType("boolean"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("boolean"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SubtitleMode") + .HasColumnType("integer"); + + b.Property("SyncPlayAccess") + .HasColumnType("integer"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("CustomDataKey") + .HasColumnType("text"); + + b.Property("AudioStreamIndex") + .HasColumnType("integer"); + + b.Property("IsFavorite") + .HasColumnType("boolean"); + + b.Property("LastPlayedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Likes") + .HasColumnType("boolean"); + + b.Property("PlayCount") + .HasColumnType("integer"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("bigint"); + + b.Property("Played") + .HasColumnType("boolean"); + + b.Property("Rating") + .HasColumnType("double precision"); + + b.Property("RetentionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("integer"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("UserId"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.ToTable("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Parents") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("Children") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent") + .WithMany("DirectChildren") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("DirectParent"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("DirectChildren"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("Parents"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260911134055_InitialPostgreSql.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260305010333_InitialPostgreSql.cs similarity index 88% rename from src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260911134055_InitialPostgreSql.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260305010333_InitialPostgreSql.cs index acbf2d60a8..5963a41c68 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260911134055_InitialPostgreSql.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260305010333_InitialPostgreSql.cs @@ -97,8 +97,7 @@ namespace Jellyfin.Database.Providers.PostgreSQL.Migrations CleanName = table.Column(type: "text", nullable: true), PresentationUniqueKey = table.Column(type: "text", nullable: true), OriginalTitle = table.Column(type: "text", nullable: true), - OriginalLanguage = table.Column(type: "text", nullable: true), - PrimaryVersionId = table.Column(type: "uuid", nullable: true), + PrimaryVersionId = table.Column(type: "text", nullable: true), DateLastMediaAdded = table.Column(type: "timestamp with time zone", nullable: true), Album = table.Column(type: "text", nullable: true), LUFS = table.Column(type: "real", nullable: true), @@ -109,6 +108,7 @@ namespace Jellyfin.Database.Providers.PostgreSQL.Migrations ExternalSeriesId = table.Column(type: "text", nullable: true), Tagline = table.Column(type: "text", nullable: true), ProductionLocations = table.Column(type: "text", nullable: true), + ExtraIds = table.Column(type: "text", nullable: true), TotalBitrate = table.Column(type: "integer", nullable: true), ExtraType = table.Column(type: "integer", nullable: true), Artists = table.Column(type: "text", nullable: true), @@ -116,7 +116,7 @@ namespace Jellyfin.Database.Providers.PostgreSQL.Migrations ExternalId = table.Column(type: "text", nullable: true), SeriesPresentationUniqueKey = table.Column(type: "text", nullable: true), ShowId = table.Column(type: "text", nullable: true), - OwnerId = table.Column(type: "uuid", nullable: true), + OwnerId = table.Column(type: "text", nullable: true), Width = table.Column(type: "integer", nullable: true), Height = table.Column(type: "integer", nullable: true), Size = table.Column(type: "bigint", nullable: true), @@ -129,11 +129,6 @@ namespace Jellyfin.Database.Providers.PostgreSQL.Migrations constraints: table => { table.PrimaryKey("PK_BaseItems", x => x.Id); - table.ForeignKey( - name: "FK_BaseItems_BaseItems_OwnerId", - column: x => x.OwnerId, - principalTable: "BaseItems", - principalColumn: "Id"); table.ForeignKey( name: "FK_BaseItems_BaseItems_ParentId", column: x => x.ParentId, @@ -240,7 +235,6 @@ namespace Jellyfin.Database.Providers.PostgreSQL.Migrations { Id = table.Column(type: "uuid", nullable: false), Username = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), - NormalizedUsername = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), Password = table.Column(type: "character varying(65535)", maxLength: 65535, nullable: true), MustUpdatePassword = table.Column(type: "boolean", nullable: false), AudioLanguagePreference = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), @@ -443,30 +437,6 @@ namespace Jellyfin.Database.Providers.PostgreSQL.Migrations onDelete: ReferentialAction.Cascade); }); - migrationBuilder.CreateTable( - name: "LinkedChildren", - columns: table => new - { - ParentId = table.Column(type: "uuid", nullable: false), - SortOrder = table.Column(type: "integer", nullable: false), - ChildId = table.Column(type: "uuid", nullable: false), - ChildType = table.Column(type: "integer", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_LinkedChildren", x => new { x.ParentId, x.SortOrder }); - table.ForeignKey( - name: "FK_LinkedChildren_BaseItems_ChildId", - column: x => x.ChildId, - principalTable: "BaseItems", - principalColumn: "Id"); - table.ForeignKey( - name: "FK_LinkedChildren_BaseItems_ParentId", - column: x => x.ParentId, - principalTable: "BaseItems", - principalColumn: "Id"); - }); - migrationBuilder.CreateTable( name: "MediaStreamInfos", columns: table => new @@ -487,7 +457,6 @@ namespace Jellyfin.Database.Providers.PostgreSQL.Migrations IsDefault = table.Column(type: "boolean", nullable: false), IsForced = table.Column(type: "boolean", nullable: false), IsExternal = table.Column(type: "boolean", nullable: false), - IsOriginal = table.Column(type: "boolean", nullable: false), Height = table.Column(type: "integer", nullable: true), Width = table.Column(type: "integer", nullable: true), AverageFrameRate = table.Column(type: "real", nullable: true), @@ -717,10 +686,11 @@ namespace Jellyfin.Database.Providers.PostgreSQL.Migrations { Id = table.Column(type: "integer", nullable: false) .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - UserId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: true), Kind = table.Column(type: "integer", nullable: false), Value = table.Column(type: "boolean", nullable: false), - RowVersion = table.Column(type: "bigint", nullable: false) + RowVersion = table.Column(type: "bigint", nullable: false), + Permission_Permissions_Guid = table.Column(type: "uuid", nullable: true) }, constraints: table => { @@ -739,10 +709,11 @@ namespace Jellyfin.Database.Providers.PostgreSQL.Migrations { Id = table.Column(type: "integer", nullable: false) .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - UserId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: true), Kind = table.Column(type: "integer", nullable: false), Value = table.Column(type: "character varying(65535)", maxLength: 65535, nullable: false), - RowVersion = table.Column(type: "bigint", nullable: false) + RowVersion = table.Column(type: "bigint", nullable: false), + Preference_Preferences_Guid = table.Column(type: "uuid", nullable: true) }, constraints: table => { @@ -813,8 +784,8 @@ namespace Jellyfin.Database.Providers.PostgreSQL.Migrations migrationBuilder.InsertData( table: "BaseItems", - columns: new[] { "Id", "Album", "AlbumArtists", "Artists", "Audio", "ChannelId", "CleanName", "CommunityRating", "CriticRating", "CustomRating", "Data", "DateCreated", "DateLastMediaAdded", "DateLastRefreshed", "DateLastSaved", "DateModified", "EndDate", "EpisodeTitle", "ExternalId", "ExternalSeriesId", "ExternalServiceId", "ExtraType", "ForcedSortName", "Genres", "Height", "IndexNumber", "InheritedParentalRatingSubValue", "InheritedParentalRatingValue", "IsFolder", "IsInMixedFolder", "IsLocked", "IsMovie", "IsRepeat", "IsSeries", "IsVirtualItem", "LUFS", "MediaType", "Name", "NormalizationGain", "OfficialRating", "OriginalLanguage", "OriginalTitle", "Overview", "OwnerId", "ParentId", "ParentIndexNumber", "Path", "PreferredMetadataCountryCode", "PreferredMetadataLanguage", "PremiereDate", "PresentationUniqueKey", "PrimaryVersionId", "ProductionLocations", "ProductionYear", "RunTimeTicks", "SeasonId", "SeasonName", "SeriesId", "SeriesName", "SeriesPresentationUniqueKey", "ShowId", "Size", "SortName", "StartDate", "Studios", "Tagline", "Tags", "TopParentId", "TotalBitrate", "Type", "UnratedType", "Width" }, - values: new object[] { new Guid("00000000-0000-0000-0000-000000000001"), null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, false, false, false, false, false, false, false, null, null, "This is a placeholder item for UserData that has been detached from its original item", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, "PLACEHOLDER", null, null }); + columns: new[] { "Id", "Album", "AlbumArtists", "Artists", "Audio", "ChannelId", "CleanName", "CommunityRating", "CriticRating", "CustomRating", "Data", "DateCreated", "DateLastMediaAdded", "DateLastRefreshed", "DateLastSaved", "DateModified", "EndDate", "EpisodeTitle", "ExternalId", "ExternalSeriesId", "ExternalServiceId", "ExtraIds", "ExtraType", "ForcedSortName", "Genres", "Height", "IndexNumber", "InheritedParentalRatingSubValue", "InheritedParentalRatingValue", "IsFolder", "IsInMixedFolder", "IsLocked", "IsMovie", "IsRepeat", "IsSeries", "IsVirtualItem", "LUFS", "MediaType", "Name", "NormalizationGain", "OfficialRating", "OriginalTitle", "Overview", "OwnerId", "ParentId", "ParentIndexNumber", "Path", "PreferredMetadataCountryCode", "PreferredMetadataLanguage", "PremiereDate", "PresentationUniqueKey", "PrimaryVersionId", "ProductionLocations", "ProductionYear", "RunTimeTicks", "SeasonId", "SeasonName", "SeriesId", "SeriesName", "SeriesPresentationUniqueKey", "ShowId", "Size", "SortName", "StartDate", "Studios", "Tagline", "Tags", "TopParentId", "TotalBitrate", "Type", "UnratedType", "Width" }, + values: new object[] { new Guid("00000000-0000-0000-0000-000000000001"), null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, false, false, false, false, false, false, false, null, null, "This is a placeholder item for UserData that has been detacted from its original item", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, "PLACEHOLDER", null, null }); migrationBuilder.CreateIndex( name: "IX_AccessSchedules_UserId", @@ -838,9 +809,9 @@ namespace Jellyfin.Database.Providers.PostgreSQL.Migrations unique: true); migrationBuilder.CreateIndex( - name: "IX_BaseItemImageInfos_ItemId_ImageType", + name: "IX_BaseItemImageInfos_ItemId", table: "BaseItemImageInfos", - columns: new[] { "ItemId", "ImageType" }); + column: "ItemId"); migrationBuilder.CreateIndex( name: "IX_BaseItemMetadataFields_ItemId", @@ -848,14 +819,14 @@ namespace Jellyfin.Database.Providers.PostgreSQL.Migrations column: "ItemId"); migrationBuilder.CreateIndex( - name: "IX_BaseItemProviders_ProviderId_ItemId_ProviderValue", + name: "IX_BaseItemProviders_ProviderId_ProviderValue_ItemId", table: "BaseItemProviders", - columns: new[] { "ProviderId", "ItemId", "ProviderValue" }); + columns: new[] { "ProviderId", "ProviderValue", "ItemId" }); migrationBuilder.CreateIndex( - name: "IX_BaseItems_ExtraType_OwnerId", + name: "IX_BaseItems_Id_Type_IsFolder_IsVirtualItem", table: "BaseItems", - columns: new[] { "ExtraType", "OwnerId" }); + columns: new[] { "Id", "Type", "IsFolder", "IsVirtualItem" }); migrationBuilder.CreateIndex( name: "IX_BaseItems_IsFolder_TopParentId_IsVirtualItem_PresentationUn~", @@ -867,16 +838,6 @@ namespace Jellyfin.Database.Providers.PostgreSQL.Migrations table: "BaseItems", columns: new[] { "MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey" }); - migrationBuilder.CreateIndex( - name: "IX_BaseItems_Name", - table: "BaseItems", - column: "Name"); - - migrationBuilder.CreateIndex( - name: "IX_BaseItems_OwnerId", - table: "BaseItems", - column: "OwnerId"); - migrationBuilder.CreateIndex( name: "IX_BaseItems_ParentId", table: "BaseItems", @@ -892,68 +853,16 @@ namespace Jellyfin.Database.Providers.PostgreSQL.Migrations table: "BaseItems", column: "PresentationUniqueKey"); - migrationBuilder.CreateIndex( - name: "IX_BaseItems_PrimaryVersionId", - table: "BaseItems", - column: "PrimaryVersionId", - filter: "\"PrimaryVersionId\" IS NOT NULL"); - - migrationBuilder.CreateIndex( - name: "IX_BaseItems_SeasonId", - table: "BaseItems", - column: "SeasonId"); - - migrationBuilder.CreateIndex( - name: "IX_BaseItems_SeriesId", - table: "BaseItems", - column: "SeriesId"); - - migrationBuilder.CreateIndex( - name: "IX_BaseItems_SeriesName", - table: "BaseItems", - column: "SeriesName"); - migrationBuilder.CreateIndex( name: "IX_BaseItems_TopParentId_Id", table: "BaseItems", columns: new[] { "TopParentId", "Id" }); - migrationBuilder.CreateIndex( - name: "IX_BaseItems_TopParentId_IsFolder_IsVirtualItem_DateCreated", - table: "BaseItems", - columns: new[] { "TopParentId", "IsFolder", "IsVirtualItem", "DateCreated" }); - - migrationBuilder.CreateIndex( - name: "IX_BaseItems_TopParentId_MediaType_IsVirtualItem_DateCreated", - table: "BaseItems", - columns: new[] { "TopParentId", "MediaType", "IsVirtualItem", "DateCreated" }); - - migrationBuilder.CreateIndex( - name: "IX_BaseItems_TopParentId_Type_IsVirtualItem", - table: "BaseItems", - columns: new[] { "TopParentId", "Type", "IsVirtualItem" }, - filter: "\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)"); - - migrationBuilder.CreateIndex( - name: "IX_BaseItems_TopParentId_Type_IsVirtualItem_DateCreated", - table: "BaseItems", - columns: new[] { "TopParentId", "Type", "IsVirtualItem", "DateCreated" }); - - migrationBuilder.CreateIndex( - name: "IX_BaseItems_Type_CleanName", - table: "BaseItems", - columns: new[] { "Type", "CleanName" }); - migrationBuilder.CreateIndex( name: "IX_BaseItems_Type_SeriesPresentationUniqueKey_IsFolder_IsVirtu~", table: "BaseItems", columns: new[] { "Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem" }); - migrationBuilder.CreateIndex( - name: "IX_BaseItems_Type_SeriesPresentationUniqueKey_ParentIndexNumbe~", - table: "BaseItems", - columns: new[] { "Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber" }); - migrationBuilder.CreateIndex( name: "IX_BaseItems_Type_SeriesPresentationUniqueKey_PresentationUniq~", table: "BaseItems", @@ -974,11 +883,6 @@ namespace Jellyfin.Database.Providers.PostgreSQL.Migrations table: "BaseItems", columns: new[] { "Type", "TopParentId", "PresentationUniqueKey" }); - migrationBuilder.CreateIndex( - name: "IX_BaseItems_Type_TopParentId_SortName", - table: "BaseItems", - columns: new[] { "Type", "TopParentId", "SortName" }); - migrationBuilder.CreateIndex( name: "IX_BaseItems_Type_TopParentId_StartDate", table: "BaseItems", @@ -1006,6 +910,11 @@ namespace Jellyfin.Database.Providers.PostgreSQL.Migrations table: "Devices", columns: new[] { "AccessToken", "DateLastActivity" }); + migrationBuilder.CreateIndex( + name: "IX_Devices_DeviceId", + table: "Devices", + column: "DeviceId"); + migrationBuilder.CreateIndex( name: "IX_Devices_DeviceId_DateLastActivity", table: "Devices", @@ -1055,19 +964,24 @@ namespace Jellyfin.Database.Providers.PostgreSQL.Migrations column: "ItemId"); migrationBuilder.CreateIndex( - name: "IX_LinkedChildren_ChildId_ChildType", - table: "LinkedChildren", - columns: new[] { "ChildId", "ChildType" }); - - migrationBuilder.CreateIndex( - name: "IX_LinkedChildren_ParentId_ChildType", - table: "LinkedChildren", - columns: new[] { "ParentId", "ChildType" }); - - migrationBuilder.CreateIndex( - name: "IX_MediaStreamInfos_StreamType_ItemId_Language_IsExternal", + name: "IX_MediaStreamInfos_StreamIndex", table: "MediaStreamInfos", - columns: new[] { "StreamType", "ItemId", "Language", "IsExternal" }); + column: "StreamIndex"); + + migrationBuilder.CreateIndex( + name: "IX_MediaStreamInfos_StreamIndex_StreamType", + table: "MediaStreamInfos", + columns: new[] { "StreamIndex", "StreamType" }); + + migrationBuilder.CreateIndex( + name: "IX_MediaStreamInfos_StreamIndex_StreamType_Language", + table: "MediaStreamInfos", + columns: new[] { "StreamIndex", "StreamType", "Language" }); + + migrationBuilder.CreateIndex( + name: "IX_MediaStreamInfos_StreamType", + table: "MediaStreamInfos", + column: "StreamType"); migrationBuilder.CreateIndex( name: "IX_PeopleBaseItemMap_ItemId_ListOrder", @@ -1080,9 +994,9 @@ namespace Jellyfin.Database.Providers.PostgreSQL.Migrations columns: new[] { "ItemId", "SortOrder" }); migrationBuilder.CreateIndex( - name: "IX_PeopleBaseItemMap_PeopleId_ItemId", + name: "IX_PeopleBaseItemMap_PeopleId", table: "PeopleBaseItemMap", - columns: new[] { "PeopleId", "ItemId" }); + column: "PeopleId"); migrationBuilder.CreateIndex( name: "IX_Peoples_Name", @@ -1093,13 +1007,15 @@ namespace Jellyfin.Database.Providers.PostgreSQL.Migrations name: "IX_Permissions_UserId_Kind", table: "Permissions", columns: new[] { "UserId", "Kind" }, - unique: true); + unique: true, + filter: "\"UserId\" IS NOT NULL"); migrationBuilder.CreateIndex( name: "IX_Preferences_UserId_Kind", table: "Preferences", columns: new[] { "UserId", "Kind" }, - unique: true); + unique: true, + filter: "\"UserId\" IS NOT NULL"); migrationBuilder.CreateIndex( name: "IX_UserData_ItemId_UserId_IsFavorite", @@ -1122,25 +1038,9 @@ namespace Jellyfin.Database.Providers.PostgreSQL.Migrations columns: new[] { "ItemId", "UserId", "Played" }); migrationBuilder.CreateIndex( - name: "IX_UserData_UserId_IsFavorite_ItemId", + name: "IX_UserData_UserId", table: "UserData", - columns: new[] { "UserId", "IsFavorite", "ItemId" }); - - migrationBuilder.CreateIndex( - name: "IX_UserData_UserId_ItemId_LastPlayedDate", - table: "UserData", - columns: new[] { "UserId", "ItemId", "LastPlayedDate" }); - - migrationBuilder.CreateIndex( - name: "IX_UserData_UserId_Played_ItemId", - table: "UserData", - columns: new[] { "UserId", "Played", "ItemId" }); - - migrationBuilder.CreateIndex( - name: "IX_Users_NormalizedUsername", - table: "Users", - column: "NormalizedUsername", - unique: true); + column: "UserId"); migrationBuilder.CreateIndex( name: "IX_Users_Username", @@ -1206,9 +1106,6 @@ namespace Jellyfin.Database.Providers.PostgreSQL.Migrations migrationBuilder.DropTable( name: "KeyframeData"); - migrationBuilder.DropTable( - name: "LinkedChildren"); - migrationBuilder.DropTable( name: "MediaSegments"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260306000000_UpgradeToServer12Schema.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260306000000_UpgradeToServer12Schema.Designer.cs new file mode 100644 index 0000000000..497f5c4316 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260306000000_UpgradeToServer12Schema.Designer.cs @@ -0,0 +1,1771 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Jellyfin.Database.Providers.PostgreSQL.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20260306000000_UpgradeToServer12Schema")] + partial class UpgradeToServer12Schema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("EndHour") + .HasColumnType("double precision"); + + b.Property("StartHour") + .HasColumnType("double precision"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LogSeverity") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ParentItemId") + .HasColumnType("uuid"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("Index") + .HasColumnType("integer"); + + b.Property("Codec") + .HasColumnType("text"); + + b.Property("CodecTag") + .HasColumnType("text"); + + b.Property("Comment") + .HasColumnType("text"); + + b.Property("Filename") + .HasColumnType("text"); + + b.Property("MimeType") + .HasColumnType("text"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Album") + .HasColumnType("text"); + + b.Property("AlbumArtists") + .HasColumnType("text"); + + b.Property("Artists") + .HasColumnType("text"); + + b.Property("Audio") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CleanName") + .HasColumnType("text"); + + b.Property("CommunityRating") + .HasColumnType("real"); + + b.Property("CriticRating") + .HasColumnType("real"); + + b.Property("CustomRating") + .HasColumnType("text"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateLastMediaAdded") + .HasColumnType("timestamp with time zone"); + + b.Property("DateLastRefreshed") + .HasColumnType("timestamp with time zone"); + + b.Property("DateLastSaved") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeTitle") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasColumnType("text"); + + b.Property("ExternalSeriesId") + .HasColumnType("text"); + + b.Property("ExternalServiceId") + .HasColumnType("text"); + + b.Property("ExtraType") + .HasColumnType("integer"); + + b.Property("ForcedSortName") + .HasColumnType("text"); + + b.Property("Genres") + .HasColumnType("text"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("IndexNumber") + .HasColumnType("integer"); + + b.Property("InheritedParentalRatingSubValue") + .HasColumnType("integer"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("integer"); + + b.Property("IsFolder") + .HasColumnType("boolean"); + + b.Property("IsInMixedFolder") + .HasColumnType("boolean"); + + b.Property("IsLocked") + .HasColumnType("boolean"); + + b.Property("IsMovie") + .HasColumnType("boolean"); + + b.Property("IsRepeat") + .HasColumnType("boolean"); + + b.Property("IsSeries") + .HasColumnType("boolean"); + + b.Property("IsVirtualItem") + .HasColumnType("boolean"); + + b.Property("LUFS") + .HasColumnType("real"); + + b.Property("MediaType") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("NormalizationGain") + .HasColumnType("real"); + + b.Property("OfficialRating") + .HasColumnType("text"); + + b.Property("OriginalLanguage") + .HasColumnType("text"); + + b.Property("OriginalTitle") + .HasColumnType("text"); + + b.Property("Overview") + .HasColumnType("text"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("ParentIndexNumber") + .HasColumnType("integer"); + + b.Property("Path") + .HasColumnType("text"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("text"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("text"); + + b.Property("PremiereDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PresentationUniqueKey") + .HasColumnType("text"); + + b.Property("PrimaryVersionId") + .HasColumnType("uuid"); + + b.Property("ProductionLocations") + .HasColumnType("text"); + + b.Property("ProductionYear") + .HasColumnType("integer"); + + b.Property("RunTimeTicks") + .HasColumnType("bigint"); + + b.Property("SeasonId") + .HasColumnType("uuid"); + + b.Property("SeasonName") + .HasColumnType("text"); + + b.Property("SeriesId") + .HasColumnType("uuid"); + + b.Property("SeriesName") + .HasColumnType("text"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("text"); + + b.Property("ShowId") + .HasColumnType("text"); + + b.Property("Size") + .HasColumnType("bigint"); + + b.Property("SortName") + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Studios") + .HasColumnType("text"); + + b.Property("Tagline") + .HasColumnType("text"); + + b.Property("Tags") + .HasColumnType("text"); + + b.Property("TopParentId") + .HasColumnType("uuid"); + + b.Property("TotalBitrate") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UnratedType") + .HasColumnType("text"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("PrimaryVersionId") + .HasFilter("\"PrimaryVersionId\" IS NOT NULL"); + + b.HasIndex("SeasonId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("SeriesName"); + + b.HasIndex("ExtraType", "OwnerId"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "CleanName"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem") + .HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "SortName"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("TopParentId", "IsFolder", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "MediaType", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem", "DateCreated"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + IsFolder = false, + IsInMixedFolder = false, + IsLocked = false, + IsMovie = false, + IsRepeat = false, + IsSeries = false, + IsVirtualItem = false, + Name = "This is a placeholder item for UserData that has been detached from its original item", + Type = "PLACEHOLDER" + }); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Blurhash") + .HasColumnType("bytea"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("ImageType") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ItemId", "ImageType"); + + b.ToTable("BaseItemImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ItemId", "ProviderValue"); + + b.ToTable("BaseItemProviders"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ChapterIndex") + .HasColumnType("integer"); + + b.Property("ImageDateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("ImagePath") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("StartPositionTicks") + .HasColumnType("bigint"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChromecastVersion") + .HasColumnType("integer"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("boolean"); + + b.Property("IndexBy") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ScrollDirection") + .HasColumnType("integer"); + + b.Property("ShowBackdrop") + .HasColumnType("boolean"); + + b.Property("ShowSidebar") + .HasColumnType("boolean"); + + b.Property("SkipBackwardLength") + .HasColumnType("integer"); + + b.Property("SkipForwardLength") + .HasColumnType("integer"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DisplayPreferencesId") + .HasColumnType("integer"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("IndexBy") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("RememberIndexing") + .HasColumnType("boolean"); + + b.Property("RememberSorting") + .HasColumnType("boolean"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("ViewType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.HasIndex("Type", "Value") + .IsUnique(); + + b.ToTable("ItemValues"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("uuid"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.PrimitiveCollection("KeyframeTicks") + .HasColumnType("bigint[]"); + + b.Property("TotalDuration") + .HasColumnType("bigint"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("ChildId") + .HasColumnType("uuid"); + + b.Property("ChildType") + .HasColumnType("integer"); + + b.HasKey("ParentId", "SortOrder"); + + b.HasIndex("ChildId", "ChildType"); + + b.HasIndex("ParentId", "ChildType"); + + b.ToTable("LinkedChildren", (string)null); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EndTicks") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartTicks") + .HasColumnType("bigint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("StreamIndex") + .HasColumnType("integer"); + + b.Property("AspectRatio") + .HasColumnType("text"); + + b.Property("AverageFrameRate") + .HasColumnType("real"); + + b.Property("BitDepth") + .HasColumnType("integer"); + + b.Property("BitRate") + .HasColumnType("integer"); + + b.Property("BlPresentFlag") + .HasColumnType("integer"); + + b.Property("ChannelLayout") + .HasColumnType("text"); + + b.Property("Channels") + .HasColumnType("integer"); + + b.Property("Codec") + .HasColumnType("text"); + + b.Property("CodecTag") + .HasColumnType("text"); + + b.Property("CodecTimeBase") + .HasColumnType("text"); + + b.Property("ColorPrimaries") + .HasColumnType("text"); + + b.Property("ColorSpace") + .HasColumnType("text"); + + b.Property("ColorTransfer") + .HasColumnType("text"); + + b.Property("Comment") + .HasColumnType("text"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("integer"); + + b.Property("DvLevel") + .HasColumnType("integer"); + + b.Property("DvProfile") + .HasColumnType("integer"); + + b.Property("DvVersionMajor") + .HasColumnType("integer"); + + b.Property("DvVersionMinor") + .HasColumnType("integer"); + + b.Property("ElPresentFlag") + .HasColumnType("integer"); + + b.Property("Hdr10PlusPresentFlag") + .HasColumnType("boolean"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("IsAnamorphic") + .HasColumnType("boolean"); + + b.Property("IsAvc") + .HasColumnType("boolean"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("IsExternal") + .HasColumnType("boolean"); + + b.Property("IsForced") + .HasColumnType("boolean"); + + b.Property("IsHearingImpaired") + .HasColumnType("boolean"); + + b.Property("IsInterlaced") + .HasColumnType("boolean"); + + b.Property("IsOriginal") + .HasColumnType("boolean"); + + b.Property("KeyFrames") + .HasColumnType("text"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("Level") + .HasColumnType("real"); + + b.Property("NalLengthSize") + .HasColumnType("text"); + + b.Property("Path") + .HasColumnType("text"); + + b.Property("PixelFormat") + .HasColumnType("text"); + + b.Property("Profile") + .HasColumnType("text"); + + b.Property("RealFrameRate") + .HasColumnType("real"); + + b.Property("RefFrames") + .HasColumnType("integer"); + + b.Property("Rotation") + .HasColumnType("integer"); + + b.Property("RpuPresentFlag") + .HasColumnType("integer"); + + b.Property("SampleRate") + .HasColumnType("integer"); + + b.Property("StreamType") + .HasColumnType("integer"); + + b.Property("TimeBase") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamType", "ItemId", "Language", "IsExternal"); + + b.ToTable("MediaStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PersonType") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("PeopleId") + .HasColumnType("uuid"); + + b.Property("Role") + .HasColumnType("text"); + + b.Property("ListOrder") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("ItemId", "PeopleId", "Role"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.HasIndex("PeopleId", "ItemId"); + + b.ToTable("PeopleBaseItemMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique(); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("character varying(65535)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique(); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateLastActivity") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateLastActivity") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CustomName") + .HasColumnType("text"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("Width") + .HasColumnType("integer"); + + b.Property("Bandwidth") + .HasColumnType("integer"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("Interval") + .HasColumnType("integer"); + + b.Property("ThumbnailCount") + .HasColumnType("integer"); + + b.Property("TileHeight") + .HasColumnType("integer"); + + b.Property("TileWidth") + .HasColumnType("integer"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("DisplayCollectionsView") + .HasColumnType("boolean"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("boolean"); + + b.Property("EnableAutoLogin") + .HasColumnType("boolean"); + + b.Property("EnableLocalPassword") + .HasColumnType("boolean"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("boolean"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("boolean"); + + b.Property("HidePlayedInLatest") + .HasColumnType("boolean"); + + b.Property("InternalId") + .HasColumnType("bigint"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("integer"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("integer"); + + b.Property("MaxActiveSessions") + .HasColumnType("integer"); + + b.Property("MaxParentalRatingScore") + .HasColumnType("integer"); + + b.Property("MaxParentalRatingSubScore") + .HasColumnType("integer"); + + b.Property("MustUpdatePassword") + .HasColumnType("boolean"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("character varying(65535)"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("boolean"); + + b.Property("RememberAudioSelections") + .HasColumnType("boolean"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("boolean"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SubtitleMode") + .HasColumnType("integer"); + + b.Property("SyncPlayAccess") + .HasColumnType("integer"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("CustomDataKey") + .HasColumnType("text"); + + b.Property("AudioStreamIndex") + .HasColumnType("integer"); + + b.Property("IsFavorite") + .HasColumnType("boolean"); + + b.Property("LastPlayedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Likes") + .HasColumnType("boolean"); + + b.Property("PlayCount") + .HasColumnType("integer"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("bigint"); + + b.Property("Played") + .HasColumnType("boolean"); + + b.Property("Rating") + .HasColumnType("double precision"); + + b.Property("RetentionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("integer"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.HasIndex("UserId", "IsFavorite", "ItemId"); + + b.HasIndex("UserId", "ItemId", "LastPlayedDate"); + + b.HasIndex("UserId", "Played", "ItemId"); + + b.ToTable("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Parents") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("Children") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Owner") + .WithMany("Extras") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent") + .WithMany("DirectChildren") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("DirectParent"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Child") + .WithMany("LinkedChildOfEntities") + .HasForeignKey("ChildId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Parent") + .WithMany("LinkedChildEntities") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Child"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("DirectChildren"); + + b.Navigation("Extras"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LinkedChildEntities"); + + b.Navigation("LinkedChildOfEntities"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("Parents"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260306000000_UpgradeToServer12Schema.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260306000000_UpgradeToServer12Schema.cs new file mode 100644 index 0000000000..0c76bdf4d8 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260306000000_UpgradeToServer12Schema.cs @@ -0,0 +1,585 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Database.Providers.PostgreSQL.Migrations +{ + /// + public partial class UpgradeToServer12Schema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // UserId becomes non-nullable below, so the rows that never had an owner have to go first. + migrationBuilder.Sql(@"DELETE FROM ""Permissions"" WHERE ""UserId"" IS NULL;"); + migrationBuilder.Sql(@"DELETE FROM ""Preferences"" WHERE ""UserId"" IS NULL;"); + + migrationBuilder.DropIndex( + name: "IX_UserData_UserId", + table: "UserData"); + + migrationBuilder.DropIndex( + name: "IX_Preferences_UserId_Kind", + table: "Preferences"); + + migrationBuilder.DropIndex( + name: "IX_Permissions_UserId_Kind", + table: "Permissions"); + + migrationBuilder.DropIndex( + name: "IX_PeopleBaseItemMap_PeopleId", + table: "PeopleBaseItemMap"); + + migrationBuilder.DropIndex( + name: "IX_MediaStreamInfos_StreamIndex", + table: "MediaStreamInfos"); + + migrationBuilder.DropIndex( + name: "IX_MediaStreamInfos_StreamIndex_StreamType", + table: "MediaStreamInfos"); + + migrationBuilder.DropIndex( + name: "IX_MediaStreamInfos_StreamIndex_StreamType_Language", + table: "MediaStreamInfos"); + + migrationBuilder.DropIndex( + name: "IX_MediaStreamInfos_StreamType", + table: "MediaStreamInfos"); + + migrationBuilder.DropIndex( + name: "IX_Devices_DeviceId", + table: "Devices"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_Id_Type_IsFolder_IsVirtualItem", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItemProviders_ProviderId_ProviderValue_ItemId", + table: "BaseItemProviders"); + + migrationBuilder.DropIndex( + name: "IX_BaseItemImageInfos_ItemId", + table: "BaseItemImageInfos"); + + migrationBuilder.DropColumn( + name: "Preference_Preferences_Guid", + table: "Preferences"); + + migrationBuilder.DropColumn( + name: "Permission_Permissions_Guid", + table: "Permissions"); + + // ExtraIds and OriginalLanguage are unrelated columns that EF pairs up as a rename because both are + // nullable text; the extra ids are superseded by the OwnerId relation and their content must not survive. + migrationBuilder.DropColumn( + name: "ExtraIds", + table: "BaseItems"); + + migrationBuilder.AddColumn( + name: "OriginalLanguage", + table: "BaseItems", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "NormalizedUsername", + table: "Users", + type: "character varying(255)", + maxLength: 255, + nullable: false, + defaultValue: string.Empty); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "Preferences", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "Permissions", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + + migrationBuilder.AddColumn( + name: "IsOriginal", + table: "MediaStreamInfos", + type: "boolean", + nullable: false, + defaultValue: false); + + // PostgreSQL refuses an implicit text to uuid conversion, so both columns are converted with an explicit + // USING cast. Only the two shapes the cast accepts survive: 32 hexadecimal digits, or the hyphenated + // 8-4-4-4-12 form. A digit count alone is not enough, because misplaced hyphens keep the count and still + // fail the cast, which would abort the whole migration. + migrationBuilder.Sql( + """ + UPDATE "BaseItems" SET "PrimaryVersionId" = NULL + WHERE "PrimaryVersionId" IS NOT NULL + AND ("PrimaryVersionId" !~ '^([0-9a-fA-F]{32}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$' + OR replace("PrimaryVersionId", '-', '') = '00000000000000000000000000000000'); + + ALTER TABLE "BaseItems" + ALTER COLUMN "PrimaryVersionId" TYPE uuid USING "PrimaryVersionId"::uuid; + """); + + // The detached-item placeholder is cleared as well, matching the SQLite chain: leaving it in place would + // make CleanupOrphanedExtras delete an item that has a real owner relation only by coincidence. + migrationBuilder.Sql( + """ + UPDATE "BaseItems" SET "OwnerId" = NULL + WHERE "OwnerId" IS NOT NULL + AND ("OwnerId" !~ '^([0-9a-fA-F]{32}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$' + OR replace("OwnerId", '-', '') IN ('00000000000000000000000000000000', '00000000000000000000000000000001')); + + ALTER TABLE "BaseItems" + ALTER COLUMN "OwnerId" TYPE uuid USING "OwnerId"::uuid; + """); + + migrationBuilder.CreateTable( + name: "LinkedChildren", + columns: table => new + { + ParentId = table.Column(type: "uuid", nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + ChildId = table.Column(type: "uuid", nullable: false), + ChildType = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LinkedChildren", x => new { x.ParentId, x.SortOrder }); + table.ForeignKey( + name: "FK_LinkedChildren_BaseItems_ChildId", + column: x => x.ChildId, + principalTable: "BaseItems", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_LinkedChildren_BaseItems_ParentId", + column: x => x.ParentId, + principalTable: "BaseItems", + principalColumn: "Id"); + }); + + migrationBuilder.UpdateData( + table: "BaseItems", + keyColumn: "Id", + keyValue: new Guid("00000000-0000-0000-0000-000000000001"), + columns: new[] { "Name", "OwnerId", "PrimaryVersionId" }, + values: new object[] { "This is a placeholder item for UserData that has been detached from its original item", null, null }); + + migrationBuilder.CreateIndex( + name: "IX_UserData_UserId_IsFavorite_ItemId", + table: "UserData", + columns: new[] { "UserId", "IsFavorite", "ItemId" }); + + migrationBuilder.CreateIndex( + name: "IX_UserData_UserId_ItemId_LastPlayedDate", + table: "UserData", + columns: new[] { "UserId", "ItemId", "LastPlayedDate" }); + + migrationBuilder.CreateIndex( + name: "IX_UserData_UserId_Played_ItemId", + table: "UserData", + columns: new[] { "UserId", "Played", "ItemId" }); + + migrationBuilder.CreateIndex( + name: "IX_Preferences_UserId_Kind", + table: "Preferences", + columns: new[] { "UserId", "Kind" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Permissions_UserId_Kind", + table: "Permissions", + columns: new[] { "UserId", "Kind" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PeopleBaseItemMap_PeopleId_ItemId", + table: "PeopleBaseItemMap", + columns: new[] { "PeopleId", "ItemId" }); + + migrationBuilder.CreateIndex( + name: "IX_MediaStreamInfos_StreamType_ItemId_Language_IsExternal", + table: "MediaStreamInfos", + columns: new[] { "StreamType", "ItemId", "Language", "IsExternal" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_ExtraType_OwnerId", + table: "BaseItems", + columns: new[] { "ExtraType", "OwnerId" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_Name", + table: "BaseItems", + column: "Name"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_OwnerId", + table: "BaseItems", + column: "OwnerId"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_PrimaryVersionId", + table: "BaseItems", + column: "PrimaryVersionId", + filter: "\"PrimaryVersionId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_SeasonId", + table: "BaseItems", + column: "SeasonId"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_SeriesId", + table: "BaseItems", + column: "SeriesId"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_SeriesName", + table: "BaseItems", + column: "SeriesName"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_TopParentId_IsFolder_IsVirtualItem_DateCreated", + table: "BaseItems", + columns: new[] { "TopParentId", "IsFolder", "IsVirtualItem", "DateCreated" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_TopParentId_MediaType_IsVirtualItem_DateCreated", + table: "BaseItems", + columns: new[] { "TopParentId", "MediaType", "IsVirtualItem", "DateCreated" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_TopParentId_Type_IsVirtualItem", + table: "BaseItems", + columns: new[] { "TopParentId", "Type", "IsVirtualItem" }, + filter: "\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_TopParentId_Type_IsVirtualItem_DateCreated", + table: "BaseItems", + columns: new[] { "TopParentId", "Type", "IsVirtualItem", "DateCreated" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_Type_CleanName", + table: "BaseItems", + columns: new[] { "Type", "CleanName" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_Type_SeriesPresentationUniqueKey_ParentIndexNumbe~", + table: "BaseItems", + columns: new[] { "Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_Type_TopParentId_SortName", + table: "BaseItems", + columns: new[] { "Type", "TopParentId", "SortName" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItemProviders_ProviderId_ItemId_ProviderValue", + table: "BaseItemProviders", + columns: new[] { "ProviderId", "ItemId", "ProviderValue" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItemImageInfos_ItemId_ImageType", + table: "BaseItemImageInfos", + columns: new[] { "ItemId", "ImageType" }); + + migrationBuilder.CreateIndex( + name: "IX_LinkedChildren_ChildId_ChildType", + table: "LinkedChildren", + columns: new[] { "ChildId", "ChildType" }); + + migrationBuilder.CreateIndex( + name: "IX_LinkedChildren_ParentId_ChildType", + table: "LinkedChildren", + columns: new[] { "ParentId", "ChildType" }); + + // The baseline seeds the placeholder, but the repoint below and the foreign key both depend on it, so it is + // recreated if anything removed it rather than letting the migration abort. + migrationBuilder.Sql( + """ + INSERT INTO "BaseItems" ("Id", "Type", "Name", "IsMovie", "IsLocked", "IsSeries", "IsRepeat", + "IsInMixedFolder", "IsFolder", "IsVirtualItem") + VALUES ('00000000-0000-0000-0000-000000000001', 'PLACEHOLDER', + 'This is a placeholder item for UserData that has been detached from its original item', + false, false, false, false, false, false, false) + ON CONFLICT ("Id") DO NOTHING; + """); + + // Owners that no longer exist are repointed at the detached-item placeholder so the new self referencing + // foreign key holds. The CleanupOrphanedExtras routine removes the items afterwards. + migrationBuilder.Sql( + """ + UPDATE "BaseItems" + SET "OwnerId" = '00000000-0000-0000-0000-000000000001' + WHERE "OwnerId" IS NOT NULL + AND "OwnerId" NOT IN (SELECT "Id" FROM "BaseItems"); + """); + + migrationBuilder.AddForeignKey( + name: "FK_BaseItems_BaseItems_OwnerId", + table: "BaseItems", + column: "OwnerId", + principalTable: "BaseItems", + principalColumn: "Id"); + + // Those defaults only existed to fill the rows that predate the columns. The model declares none, so they + // are dropped again to leave exactly the schema a model generated migration would create. + migrationBuilder.Sql( + """ + ALTER TABLE "Users" ALTER COLUMN "NormalizedUsername" DROP DEFAULT; + ALTER TABLE "Permissions" ALTER COLUMN "UserId" DROP DEFAULT; + ALTER TABLE "Preferences" ALTER COLUMN "UserId" DROP DEFAULT; + ALTER TABLE "MediaStreamInfos" ALTER COLUMN "IsOriginal" DROP DEFAULT; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_BaseItems_BaseItems_OwnerId", + table: "BaseItems"); + + migrationBuilder.DropTable( + name: "LinkedChildren"); + + migrationBuilder.DropIndex( + name: "IX_UserData_UserId_IsFavorite_ItemId", + table: "UserData"); + + migrationBuilder.DropIndex( + name: "IX_UserData_UserId_ItemId_LastPlayedDate", + table: "UserData"); + + migrationBuilder.DropIndex( + name: "IX_UserData_UserId_Played_ItemId", + table: "UserData"); + + migrationBuilder.DropIndex( + name: "IX_Preferences_UserId_Kind", + table: "Preferences"); + + migrationBuilder.DropIndex( + name: "IX_Permissions_UserId_Kind", + table: "Permissions"); + + migrationBuilder.DropIndex( + name: "IX_PeopleBaseItemMap_PeopleId_ItemId", + table: "PeopleBaseItemMap"); + + migrationBuilder.DropIndex( + name: "IX_MediaStreamInfos_StreamType_ItemId_Language_IsExternal", + table: "MediaStreamInfos"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_ExtraType_OwnerId", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_Name", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_OwnerId", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_PrimaryVersionId", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_SeasonId", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_SeriesId", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_SeriesName", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_TopParentId_IsFolder_IsVirtualItem_DateCreated", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_TopParentId_MediaType_IsVirtualItem_DateCreated", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_TopParentId_Type_IsVirtualItem", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_TopParentId_Type_IsVirtualItem_DateCreated", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_Type_CleanName", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_Type_SeriesPresentationUniqueKey_ParentIndexNumbe~", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_Type_TopParentId_SortName", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItemProviders_ProviderId_ItemId_ProviderValue", + table: "BaseItemProviders"); + + migrationBuilder.DropIndex( + name: "IX_BaseItemImageInfos_ItemId_ImageType", + table: "BaseItemImageInfos"); + + migrationBuilder.DropColumn( + name: "NormalizedUsername", + table: "Users"); + + // Reverting the column takes the code migration that populates it with it. + migrationBuilder.Sql( + """ + DELETE FROM "__EFMigrationsHistory" + WHERE "MigrationId" = '20260522092304_UpdateNormalizedUsername'; + """); + + migrationBuilder.DropColumn( + name: "IsOriginal", + table: "MediaStreamInfos"); + + migrationBuilder.DropColumn( + name: "OriginalLanguage", + table: "BaseItems"); + + migrationBuilder.AddColumn( + name: "ExtraIds", + table: "BaseItems", + type: "text", + nullable: true); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "Preferences", + type: "uuid", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uuid"); + + migrationBuilder.AddColumn( + name: "Preference_Preferences_Guid", + table: "Preferences", + type: "uuid", + nullable: true); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "Permissions", + type: "uuid", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uuid"); + + migrationBuilder.AddColumn( + name: "Permission_Permissions_Guid", + table: "Permissions", + type: "uuid", + nullable: true); + + migrationBuilder.Sql( + """ + ALTER TABLE "BaseItems" + ALTER COLUMN "PrimaryVersionId" TYPE text USING "PrimaryVersionId"::text; + + ALTER TABLE "BaseItems" + ALTER COLUMN "OwnerId" TYPE text USING "OwnerId"::text; + """); + + migrationBuilder.UpdateData( + table: "BaseItems", + keyColumn: "Id", + keyValue: new Guid("00000000-0000-0000-0000-000000000001"), + columns: new[] { "Name", "OwnerId", "PrimaryVersionId" }, + values: new object[] { "This is a placeholder item for UserData that has been detacted from its original item", null, null }); + + migrationBuilder.CreateIndex( + name: "IX_UserData_UserId", + table: "UserData", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Preferences_UserId_Kind", + table: "Preferences", + columns: new[] { "UserId", "Kind" }, + unique: true, + filter: "\"UserId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Permissions_UserId_Kind", + table: "Permissions", + columns: new[] { "UserId", "Kind" }, + unique: true, + filter: "\"UserId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_PeopleBaseItemMap_PeopleId", + table: "PeopleBaseItemMap", + column: "PeopleId"); + + migrationBuilder.CreateIndex( + name: "IX_MediaStreamInfos_StreamIndex", + table: "MediaStreamInfos", + column: "StreamIndex"); + + migrationBuilder.CreateIndex( + name: "IX_MediaStreamInfos_StreamIndex_StreamType", + table: "MediaStreamInfos", + columns: new[] { "StreamIndex", "StreamType" }); + + migrationBuilder.CreateIndex( + name: "IX_MediaStreamInfos_StreamIndex_StreamType_Language", + table: "MediaStreamInfos", + columns: new[] { "StreamIndex", "StreamType", "Language" }); + + migrationBuilder.CreateIndex( + name: "IX_MediaStreamInfos_StreamType", + table: "MediaStreamInfos", + column: "StreamType"); + + migrationBuilder.CreateIndex( + name: "IX_Devices_DeviceId", + table: "Devices", + column: "DeviceId"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_Id_Type_IsFolder_IsVirtualItem", + table: "BaseItems", + columns: new[] { "Id", "Type", "IsFolder", "IsVirtualItem" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItemProviders_ProviderId_ProviderValue_ItemId", + table: "BaseItemProviders", + columns: new[] { "ProviderId", "ProviderValue", "ItemId" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItemImageInfos_ItemId", + table: "BaseItemImageInfos", + column: "ItemId"); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260911134055_InitialPostgreSql.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.Designer.cs similarity index 99% rename from src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260911134055_InitialPostgreSql.Designer.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.Designer.cs index a2e8b8b505..913c070736 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260911134055_InitialPostgreSql.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.Designer.cs @@ -12,8 +12,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace Jellyfin.Database.Providers.PostgreSQL.Migrations { [DbContext(typeof(JellyfinDbContext))] - [Migration("20260911134055_InitialPostgreSql")] - partial class InitialPostgreSql + [Migration("20260524120336_AddUniqueNormalizedUsernameIndex")] + partial class AddUniqueNormalizedUsernameIndex { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.cs new file mode 100644 index 0000000000..77ad14e29d --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Database.Providers.PostgreSQL.Migrations +{ + /// + public partial class AddUniqueNormalizedUsernameIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Users_NormalizedUsername", + table: "Users", + column: "NormalizedUsername", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Users_NormalizedUsername", + table: "Users"); + } + } +} diff --git a/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj b/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj index 3ad5310c6b..a96840baa1 100644 --- a/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj +++ b/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj @@ -10,6 +10,8 @@ + + all diff --git a/tests/Jellyfin.Server.Tests/Migrations/PostgreSqlMigrationOrderingTests.cs b/tests/Jellyfin.Server.Tests/Migrations/PostgreSqlMigrationOrderingTests.cs new file mode 100644 index 0000000000..8ae066a7a2 --- /dev/null +++ b/tests/Jellyfin.Server.Tests/Migrations/PostgreSqlMigrationOrderingTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.DbConfiguration; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.PostgreSQL; +using Jellyfin.Server.Migrations; +using Jellyfin.Server.Migrations.Stages; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; + +namespace Jellyfin.Server.Tests.Migrations; + +/// +/// Schema migrations and the code migrations of the CoreInitialisation stage are sorted into one list by id, so the +/// ids the PostgreSQL provider ships decide which schema those code migrations see. +/// +public class PostgreSqlMigrationOrderingTests +{ + private const string UsernameCodeMigrationId = "20260522092304_UpdateNormalizedUsername"; + private const string UsernameIndexMigrationId = "20260524120336_AddUniqueNormalizedUsernameIndex"; + + [Fact] + public void SchemaMigrations_AreOrderedBeforeTheCodeMigrationsThatNeedThem() + { + var schemaMigrationIds = PostgreSqlMigrationIds().Where(id => !string.Equals(id, UsernameIndexMigrationId, StringComparison.Ordinal)); + + foreach (var codeMigrationId in CoreInitialisationCodeMigrationIds()) + { + foreach (var schemaMigrationId in schemaMigrationIds) + { + Assert.True( + string.CompareOrdinal(schemaMigrationId, codeMigrationId) < 0, + $"Schema migration {schemaMigrationId} has to be ordered before code migration {codeMigrationId}, " + + "otherwise the code migration runs against a schema that predates it."); + } + } + } + + [Fact] + public void UniqueUsernameIndex_IsOrderedAfterTheUsernameCodeMigration() + { + Assert.Contains(UsernameIndexMigrationId, PostgreSqlMigrationIds()); + Assert.Contains(UsernameCodeMigrationId, CoreInitialisationCodeMigrationIds()); + Assert.True( + string.CompareOrdinal(UsernameIndexMigrationId, UsernameCodeMigrationId) > 0, + "The unique index can only be created once the code migration has filled in the normalized usernames."); + } + + private static IReadOnlyList PostgreSqlMigrationIds() + { + const string DummyConnectionString = "Host=localhost;Database=jellyfin;Username=postgres;Password=postgres"; + using var dataSource = new NpgsqlDataSourceBuilder(DummyConnectionString).Build(); + var optionsBuilder = new DbContextOptionsBuilder(); + var provider = new PostgreSqlDatabaseProvider(dataSource); + provider.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" }); + using var context = new JellyfinDbContext( + optionsBuilder.Options, + NullLogger.Instance, + provider, + new NoLockBehavior(NullLogger.Instance)); + + return [.. context.GetService().Migrations.Keys.OrderBy(id => id, StringComparer.Ordinal)]; + } + + private static IReadOnlyList CoreInitialisationCodeMigrationIds() + { + return [.. typeof(JellyfinMigrationAttribute).Assembly.GetTypes() + .Select(type => (Type: type, Metadata: type.GetCustomAttribute())) + .Where(candidate => candidate.Metadata?.Stage == JellyfinMigrationStageTypes.CoreInitialisation) + .Select(candidate => new CodeMigration(candidate.Type, candidate.Metadata!, null).BuildCodeMigrationId()) + .OrderBy(id => id, StringComparer.Ordinal)]; + } +} diff --git a/tests/Jellyfin.Server.Tests/Migrations/PostgreSqlUpgradeTests.cs b/tests/Jellyfin.Server.Tests/Migrations/PostgreSqlUpgradeTests.cs new file mode 100644 index 0000000000..9f7ac1481a --- /dev/null +++ b/tests/Jellyfin.Server.Tests/Migrations/PostgreSqlUpgradeTests.cs @@ -0,0 +1,569 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using DotNet.Testcontainers.Builders; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.DbConfiguration; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.PostgreSQL; +using Jellyfin.Server.Migrations.Routines; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Npgsql; +using Testcontainers.PostgreSql; +using Xunit; + +namespace Jellyfin.Server.Tests.Migrations; + +/// +/// Verifies that a PostgreSQL database created by an earlier build reaches the current schema, and that a fresh +/// install ends up with exactly the same schema. +/// +[Trait("Category", "RequiresDocker")] +public sealed class PostgreSqlUpgradeTests : IAsyncLifetime +{ + private const string BaselineMigrationId = "20260305010333_InitialPostgreSql"; + private const string SchemaUpgradeMigrationId = "20260306000000_UpgradeToServer12Schema"; + private const string UsernameIndexMigrationId = "20260524120336_AddUniqueNormalizedUsernameIndex"; + + private const string PlaceholderItemId = "00000000-0000-0000-0000-000000000001"; + private const string OwnerItemId = "11111111-1111-1111-1111-111111111111"; + private const string OwnedItemId = "22222222-2222-2222-2222-222222222222"; + private const string UnparseableOwnerItemId = "33333333-3333-3333-3333-333333333333"; + private const string DanglingOwnerItemId = "44444444-4444-4444-4444-444444444444"; + private const string MalformedOwnerItemId = "55555555-5555-5555-5555-555555555555"; + private const string PlaceholderOwnedItemId = "66666666-6666-6666-6666-666666666666"; + private const string AliceId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + private const string BobId = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; + + // Thirty-two hexadecimal digits with hyphens in the wrong places: a digit count alone accepts it, the uuid cast + // does not. + private const string MalformedGuid = "1111111-11111-1111-1111-111111111111"; + + private readonly PostgreSqlContainer _container; + + public PostgreSqlUpgradeTests() + { + _container = new PostgreSqlBuilder("postgres:16-alpine") + .WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready")) + .Build(); + } + + public async ValueTask InitializeAsync() + { + await _container.StartAsync().ConfigureAwait(false); + + // pg_isready also answers for the short-lived server the entrypoint runs while initializing the data + // directory, so wait until a real connection is accepted before handing the container to a test. + await using var dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build(); + for (var attempt = 1; ; attempt++) + { + try + { + await ExecuteAsync(dataSource, "SELECT 1", CancellationToken.None).ConfigureAwait(false); + return; + } + catch (NpgsqlException) when (attempt < 30) + { + await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + } + } + } + + public async ValueTask DisposeAsync() + { + await _container.DisposeAsync().ConfigureAwait(false); + } + + [Fact] + public async Task FreshInstall_AppliesTheWholeChain() + { + var cancellationToken = TestContext.Current.CancellationToken; + var connectionString = await CreateDatabaseAsync("fresh_install", cancellationToken); + + await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build(); + await MigrateAsync(dataSource, null, cancellationToken); + + Assert.Equal( + new[] { BaselineMigrationId, SchemaUpgradeMigrationId, UsernameIndexMigrationId }, + await AppliedMigrationsAsync(dataSource, cancellationToken)); + await AssertServer12SchemaAsync(dataSource, cancellationToken); + } + + [Fact] + public async Task LegacyDatabase_UpgradesToServer12Schema() + { + var cancellationToken = TestContext.Current.CancellationToken; + var connectionString = await CreateDatabaseAsync("legacy_upgrade", cancellationToken); + + await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build(); + await CreateLegacyDatabaseAsync(dataSource, cancellationToken); + + // The database knows only the migration the previous build shipped, and the two that carry it forward are the + // ones the startup sequence has to pick up. + Assert.Equal(new[] { BaselineMigrationId }, await AppliedMigrationsAsync(dataSource, cancellationToken)); + Assert.Equal( + new[] { SchemaUpgradeMigrationId, UsernameIndexMigrationId }, + await PendingMigrationsAsync(dataSource, cancellationToken)); + + await RunUpgradeInStartupOrderAsync(dataSource, cancellationToken); + + Assert.Empty(await PendingMigrationsAsync(dataSource, cancellationToken)); + await AssertServer12SchemaAsync(dataSource, cancellationToken); + + // The code migration that the startup sequence runs between the two schema migrations has to have filled the + // new column in, otherwise the unique index could not have been created. + Assert.Equal("ALICE", await ScalarAsync(dataSource, $"""SELECT "NormalizedUsername" FROM "Users" WHERE "Id" = '{AliceId}'""", cancellationToken)); + Assert.Equal("BOB", await ScalarAsync(dataSource, $"""SELECT "NormalizedUsername" FROM "Users" WHERE "Id" = '{BobId}'""", cancellationToken)); + + // Owner and version ids were text and are now uuid: parseable values survive, unparseable ones are cleared + // and owners that point at nothing are repointed at the placeholder so the new foreign key holds. + Assert.Equal(new Guid(OwnerItemId), await ScalarAsync(dataSource, $"""SELECT "OwnerId" FROM "BaseItems" WHERE "Id" = '{OwnedItemId}'""", cancellationToken)); + Assert.Equal(new Guid(OwnerItemId), await ScalarAsync(dataSource, $"""SELECT "PrimaryVersionId" FROM "BaseItems" WHERE "Id" = '{OwnedItemId}'""", cancellationToken)); + Assert.Null(await NullableScalarAsync(dataSource, $"""SELECT "OwnerId" FROM "BaseItems" WHERE "Id" = '{UnparseableOwnerItemId}'""", cancellationToken)); + Assert.Null(await NullableScalarAsync(dataSource, $"""SELECT "PrimaryVersionId" FROM "BaseItems" WHERE "Id" = '{UnparseableOwnerItemId}'""", cancellationToken)); + Assert.Equal(new Guid(PlaceholderItemId), await ScalarAsync(dataSource, $"""SELECT "OwnerId" FROM "BaseItems" WHERE "Id" = '{DanglingOwnerItemId}'""", cancellationToken)); + + // ExtraIds and OriginalLanguage are unrelated columns, so the extra ids must not have been carried over. + Assert.Null(await NullableScalarAsync(dataSource, $"""SELECT "OriginalLanguage" FROM "BaseItems" WHERE "Id" = '{UnparseableOwnerItemId}'""", cancellationToken)); + + // UserId is no longer nullable, so the rows that never had an owner had to be removed. + Assert.Equal(1L, await ScalarAsync(dataSource, """SELECT count(*) FROM "Permissions" """, cancellationToken)); + Assert.Equal(1L, await ScalarAsync(dataSource, """SELECT count(*) FROM "Preferences" """, cancellationToken)); + + await AssertContextMatchesSchemaAsync(dataSource, cancellationToken); + } + + [Fact] + public async Task LegacyDatabaseAndFreshInstall_EndUpWithTheModelSchema() + { + var cancellationToken = TestContext.Current.CancellationToken; + var modelConnectionString = await CreateDatabaseAsync("schema_model", cancellationToken); + var freshConnectionString = await CreateDatabaseAsync("schema_fresh", cancellationToken); + var upgradedConnectionString = await CreateDatabaseAsync("schema_upgraded", cancellationToken); + + // The model built straight from the context is the reference: comparing the two migrated databases against + // each other only proves they drifted together. + await using var modelDataSource = new NpgsqlDataSourceBuilder(modelConnectionString).Build(); + await CreateFromModelAsync(modelDataSource, cancellationToken); + + await using var freshDataSource = new NpgsqlDataSourceBuilder(freshConnectionString).Build(); + await MigrateAsync(freshDataSource, null, cancellationToken); + + await using var upgradedDataSource = new NpgsqlDataSourceBuilder(upgradedConnectionString).Build(); + await CreateLegacyDatabaseAsync(upgradedDataSource, cancellationToken); + await RunUpgradeInStartupOrderAsync(upgradedDataSource, cancellationToken); + + var modelColumns = await DescribeColumnsAsync(modelDataSource, cancellationToken); + var modelIndexes = await DescribeIndexesAsync(modelDataSource, cancellationToken); + var modelConstraints = await DescribeConstraintsAsync(modelDataSource, cancellationToken); + + Assert.Equal(modelColumns, await DescribeColumnsAsync(freshDataSource, cancellationToken)); + Assert.Equal(modelIndexes, await DescribeIndexesAsync(freshDataSource, cancellationToken)); + Assert.Equal(modelConstraints, await DescribeConstraintsAsync(freshDataSource, cancellationToken)); + + Assert.Equal(modelColumns, await DescribeColumnsAsync(upgradedDataSource, cancellationToken)); + Assert.Equal(modelIndexes, await DescribeIndexesAsync(upgradedDataSource, cancellationToken)); + Assert.Equal(modelConstraints, await DescribeConstraintsAsync(upgradedDataSource, cancellationToken)); + } + + [Fact] + public async Task LegacyDatabase_MalformedGuidIds_AreCleared() + { + var cancellationToken = TestContext.Current.CancellationToken; + var connectionString = await CreateDatabaseAsync("malformed_guid", cancellationToken); + + await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build(); + await CreateLegacyDatabaseAsync(dataSource, cancellationToken); + await InsertLegacyItemAsync(dataSource, MalformedOwnerItemId, "Malformed", MalformedGuid, MalformedGuid, cancellationToken); + + await RunUpgradeInStartupOrderAsync(dataSource, cancellationToken); + + Assert.Null(await NullableScalarAsync(dataSource, $"""SELECT "OwnerId" FROM "BaseItems" WHERE "Id" = '{MalformedOwnerItemId}'""", cancellationToken)); + Assert.Null(await NullableScalarAsync(dataSource, $"""SELECT "PrimaryVersionId" FROM "BaseItems" WHERE "Id" = '{MalformedOwnerItemId}'""", cancellationToken)); + await AssertServer12SchemaAsync(dataSource, cancellationToken); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task LegacyDatabase_UpgradesWhetherOrNotThePlaceholderItemExists(bool placeholderExists) + { + var cancellationToken = TestContext.Current.CancellationToken; + var connectionString = await CreateDatabaseAsync(placeholderExists ? "placeholder_present" : "placeholder_missing", cancellationToken); + + await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build(); + await CreateLegacyDatabaseAsync(dataSource, cancellationToken); + if (!placeholderExists) + { + await ExecuteAsync(dataSource, $"""DELETE FROM "BaseItems" WHERE "Id" = '{PlaceholderItemId}'""", cancellationToken); + } + + await RunUpgradeInStartupOrderAsync(dataSource, cancellationToken); + + // The dangling owner is repointed at the placeholder, so the foreign key only holds if the placeholder is there. + Assert.Equal(1L, await ScalarAsync(dataSource, $"""SELECT count(*) FROM "BaseItems" WHERE "Id" = '{PlaceholderItemId}'""", cancellationToken)); + Assert.Equal(new Guid(PlaceholderItemId), await ScalarAsync(dataSource, $"""SELECT "OwnerId" FROM "BaseItems" WHERE "Id" = '{DanglingOwnerItemId}'""", cancellationToken)); + await AssertServer12SchemaAsync(dataSource, cancellationToken); + } + + [Fact] + public async Task LegacyDatabase_ItemOwnedByThePlaceholder_SurvivesCleanupOrphanedExtras() + { + var cancellationToken = TestContext.Current.CancellationToken; + var connectionString = await CreateDatabaseAsync("placeholder_owner", cancellationToken); + + await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build(); + await CreateLegacyDatabaseAsync(dataSource, cancellationToken); + await InsertLegacyItemAsync(dataSource, PlaceholderOwnedItemId, "Owned by the placeholder", PlaceholderItemId, null, cancellationToken); + + await RunUpgradeInStartupOrderAsync(dataSource, cancellationToken); + + // The SQLite chain clears an owner that already is the placeholder, so the item is not mistaken for an orphan. + Assert.Null(await NullableScalarAsync(dataSource, $"""SELECT "OwnerId" FROM "BaseItems" WHERE "Id" = '{PlaceholderOwnedItemId}'""", cancellationToken)); + + var deletedItemIds = new List(); + var libraryManager = new Mock(); + libraryManager + .Setup(manager => manager.DeleteItemsUnsafeFast(It.IsAny>(), It.IsAny())) + .Callback, bool>((items, _) => deletedItemIds.AddRange(items.Select(item => item.Id))); + + await new CleanupOrphanedExtras( + Mock.Of>(), + new SingleContextFactory(dataSource), + libraryManager.Object).PerformAsync(cancellationToken); + + Assert.Contains(new Guid(DanglingOwnerItemId), deletedItemIds); + Assert.DoesNotContain(new Guid(PlaceholderOwnedItemId), deletedItemIds); + Assert.Equal(1L, await ScalarAsync(dataSource, $"""SELECT count(*) FROM "BaseItems" WHERE "Id" = '{PlaceholderOwnedItemId}'""", cancellationToken)); + } + + [Fact] + public async Task UniqueUsernameIndex_CannotBeAppliedBeforeTheUsernameCodeMigration() + { + var cancellationToken = TestContext.Current.CancellationToken; + var connectionString = await CreateDatabaseAsync("username_index_order", cancellationToken); + + await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build(); + await CreateLegacyDatabaseAsync(dataSource, cancellationToken); + + // Every existing user starts out with the same empty normalized username, so the index can only be built once + // UpdateNormalizedUsername has filled the column in. That is why the index is a migration of its own. + await Assert.ThrowsAsync(() => MigrateAsync(dataSource, null, cancellationToken)); + } + + private static JellyfinDbContext CreateContext(NpgsqlDataSource dataSource) + { + var optionsBuilder = new DbContextOptionsBuilder(); + var provider = new PostgreSqlDatabaseProvider(dataSource); + provider.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" }); + return new JellyfinDbContext( + optionsBuilder.Options, + NullLogger.Instance, + provider, + new NoLockBehavior(NullLogger.Instance)); + } + + private static async Task MigrateAsync(NpgsqlDataSource dataSource, string? targetMigration, CancellationToken cancellationToken) + { + var context = CreateContext(dataSource); + await using (context.ConfigureAwait(false)) + { + await context.GetService().MigrateAsync(targetMigration, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Creates the schema straight from the current model, without going through any migration. + /// + private static async Task CreateFromModelAsync(NpgsqlDataSource dataSource, CancellationToken cancellationToken) + { + var context = CreateContext(dataSource); + await using (context.ConfigureAwait(false)) + { + await context.Database.EnsureCreatedAsync(cancellationToken).ConfigureAwait(false); + } + } + + private static async Task> AppliedMigrationsAsync(NpgsqlDataSource dataSource, CancellationToken cancellationToken) + { + var context = CreateContext(dataSource); + await using (context.ConfigureAwait(false)) + { + return [.. await context.Database.GetAppliedMigrationsAsync(cancellationToken).ConfigureAwait(false)]; + } + } + + private static async Task> PendingMigrationsAsync(NpgsqlDataSource dataSource, CancellationToken cancellationToken) + { + var context = CreateContext(dataSource); + await using (context.ConfigureAwait(false)) + { + return [.. await context.Database.GetPendingMigrationsAsync(cancellationToken).ConfigureAwait(false)]; + } + } + + private static async Task ExecuteAsync(NpgsqlDataSource dataSource, string sql, CancellationToken cancellationToken) + { + await using var command = dataSource.CreateCommand(sql); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + private static async Task ScalarAsync(NpgsqlDataSource dataSource, string sql, CancellationToken cancellationToken) + { + var value = await NullableScalarAsync(dataSource, sql, cancellationToken).ConfigureAwait(false); + Assert.NotNull(value); + return (T)value; + } + + private static async Task NullableScalarAsync(NpgsqlDataSource dataSource, string sql, CancellationToken cancellationToken) + { + await using var command = dataSource.CreateCommand(sql); + var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + return value is null or DBNull ? null : value; + } + + private static async Task> QueryLinesAsync(NpgsqlDataSource dataSource, string sql, CancellationToken cancellationToken) + { + var lines = new List(); + await using var command = dataSource.CreateCommand(sql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + var values = new string[reader.FieldCount]; + for (var i = 0; i < reader.FieldCount; i++) + { + values[i] = await reader.IsDBNullAsync(i, cancellationToken).ConfigureAwait(false) + ? "" + : Convert.ToString(reader.GetValue(i), CultureInfo.InvariantCulture) ?? ""; + } + + lines.Add(string.Join('|', values)); + } + + return lines; + } + + private static Task> DescribeColumnsAsync(NpgsqlDataSource dataSource, CancellationToken cancellationToken) + => QueryLinesAsync( + dataSource, + """ + SELECT table_name, column_name, data_type, is_nullable, coalesce(character_maximum_length, -1), coalesce(column_default, '') + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name <> '__EFMigrationsHistory' + ORDER BY table_name, column_name + """, + cancellationToken); + + private static Task> DescribeIndexesAsync(NpgsqlDataSource dataSource, CancellationToken cancellationToken) + => QueryLinesAsync( + dataSource, + """ + SELECT tablename, indexname, indexdef + FROM pg_indexes + WHERE schemaname = 'public' AND tablename <> '__EFMigrationsHistory' + ORDER BY tablename, indexname + """, + cancellationToken); + + private static Task> DescribeConstraintsAsync(NpgsqlDataSource dataSource, CancellationToken cancellationToken) + => QueryLinesAsync( + dataSource, + """ + SELECT relation.relname, constraint_.conname, pg_get_constraintdef(constraint_.oid) + FROM pg_constraint AS constraint_ + JOIN pg_class AS relation ON relation.oid = constraint_.conrelid + JOIN pg_namespace AS namespace_ ON namespace_.oid = relation.relnamespace + WHERE namespace_.nspname = 'public' AND relation.relname <> '__EFMigrationsHistory' + ORDER BY relation.relname, constraint_.conname + """, + cancellationToken); + + /// + /// Adds one more item to a legacy database, with the owner and version ids still held as text. + /// + private static Task InsertLegacyItemAsync( + NpgsqlDataSource dataSource, + string itemId, + string name, + string? ownerId, + string? primaryVersionId, + CancellationToken cancellationToken) + => ExecuteAsync( + dataSource, + $""" + INSERT INTO "BaseItems" ("Id", "Type", "IsMovie", "IsLocked", "IsSeries", "IsRepeat", "IsInMixedFolder", + "IsFolder", "IsVirtualItem", "Name", "Path", "OwnerId", "PrimaryVersionId") + VALUES ('{itemId}', 'MediaBrowser.Controller.Entities.Video', false, false, false, false, false, false, false, + '{name}', '/media/{itemId}.mkv', {Literal(ownerId)}, {Literal(primaryVersionId)}); + """, + cancellationToken); + + private static string Literal(string? value) => value is null ? "NULL" : $"'{value}'"; + + /// + /// Builds a database that looks like one written by the previous build: only the baseline migration applied, and + /// data that only the old column types could hold. + /// + private static async Task CreateLegacyDatabaseAsync(NpgsqlDataSource dataSource, CancellationToken cancellationToken) + { + await MigrateAsync(dataSource, BaselineMigrationId, cancellationToken).ConfigureAwait(false); + + await ExecuteAsync( + dataSource, + $""" + INSERT INTO "Users" ("Id", "Username", "MustUpdatePassword", "AuthenticationProviderId", "PasswordResetProviderId", + "InvalidLoginAttemptCount", "MaxActiveSessions", "SubtitleMode", "PlayDefaultAudioTrack", "DisplayMissingEpisodes", + "DisplayCollectionsView", "EnableLocalPassword", "HidePlayedInLatest", "RememberAudioSelections", + "RememberSubtitleSelections", "EnableNextEpisodeAutoPlay", "EnableAutoLogin", "EnableUserPreferenceAccess", + "InternalId", "SyncPlayAccess", "RowVersion") + VALUES + ('{AliceId}', 'alice', false, 'provider', 'provider', 0, 0, 0, true, false, true, false, false, true, true, true, false, true, 1, 0, 0), + ('{BobId}', 'bob', false, 'provider', 'provider', 0, 0, 0, true, false, true, false, false, true, true, true, false, true, 2, 0, 0); + + INSERT INTO "BaseItems" ("Id", "Type", "IsMovie", "IsLocked", "IsSeries", "IsRepeat", "IsInMixedFolder", + "IsFolder", "IsVirtualItem", "Name", "Path", "OwnerId", "PrimaryVersionId", "ExtraIds") + VALUES + ('{OwnerItemId}', 'MediaBrowser.Controller.Entities.Movies.Movie', true, false, false, false, false, false, false, 'Owner', '/media/owner.mkv', NULL, NULL, NULL), + ('{OwnedItemId}', 'MediaBrowser.Controller.Entities.Video', false, false, false, false, false, false, false, 'Trailer', '/media/owner-trailer.mkv', '11111111111111111111111111111111', '11111111-1111-1111-1111-111111111111', NULL), + ('{UnparseableOwnerItemId}', 'MediaBrowser.Controller.Entities.Video', false, false, false, false, false, false, false, 'Junk', '/media/junk.mkv', '', '00000000-0000-0000-0000-000000000000', 'aaaa|bbbb'), + ('{DanglingOwnerItemId}', 'MediaBrowser.Controller.Entities.Video', false, false, false, false, false, false, false, 'Dangling', '/media/dangling.mkv', '99999999-9999-9999-9999-999999999999', NULL, NULL); + + INSERT INTO "Permissions" ("UserId", "Kind", "Value", "RowVersion") + VALUES ('{AliceId}', 0, true, 0), (NULL, 0, true, 0); + + INSERT INTO "Preferences" ("UserId", "Kind", "Value", "RowVersion") + VALUES ('{BobId}', 0, 'kept', 0), (NULL, 0, 'orphaned', 0); + """, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Runs the migrations in the order the startup migration service picks them: schema and code migration ids are + /// sorted into one list, which puts the username code migration between the two schema migrations. + /// + private static async Task RunUpgradeInStartupOrderAsync(NpgsqlDataSource dataSource, CancellationToken cancellationToken) + { + await MigrateAsync(dataSource, SchemaUpgradeMigrationId, cancellationToken).ConfigureAwait(false); + await new UpdateNormalizedUsername(new SingleContextFactory(dataSource)).PerformAsync(cancellationToken).ConfigureAwait(false); + await MigrateAsync(dataSource, UsernameIndexMigrationId, cancellationToken).ConfigureAwait(false); + } + + private static async Task AssertServer12SchemaAsync(NpgsqlDataSource dataSource, CancellationToken cancellationToken) + { + Assert.Equal("character varying", await ColumnTypeAsync(dataSource, "Users", "NormalizedUsername", cancellationToken).ConfigureAwait(false)); + Assert.Equal("uuid", await ColumnTypeAsync(dataSource, "BaseItems", "OwnerId", cancellationToken).ConfigureAwait(false)); + Assert.Equal("uuid", await ColumnTypeAsync(dataSource, "BaseItems", "PrimaryVersionId", cancellationToken).ConfigureAwait(false)); + Assert.Equal("text", await ColumnTypeAsync(dataSource, "BaseItems", "OriginalLanguage", cancellationToken).ConfigureAwait(false)); + Assert.Equal("boolean", await ColumnTypeAsync(dataSource, "MediaStreamInfos", "IsOriginal", cancellationToken).ConfigureAwait(false)); + Assert.Null(await ColumnTypeAsync(dataSource, "BaseItems", "ExtraIds", cancellationToken).ConfigureAwait(false)); + Assert.Null(await ColumnTypeAsync(dataSource, "Permissions", "Permission_Permissions_Guid", cancellationToken).ConfigureAwait(false)); + Assert.Null(await ColumnTypeAsync(dataSource, "Preferences", "Preference_Preferences_Guid", cancellationToken).ConfigureAwait(false)); + + Assert.Equal("NO", await ScalarAsync( + dataSource, + """SELECT is_nullable FROM information_schema.columns WHERE table_name = 'Permissions' AND column_name = 'UserId'""", + cancellationToken).ConfigureAwait(false)); + Assert.Equal("NO", await ScalarAsync( + dataSource, + """SELECT is_nullable FROM information_schema.columns WHERE table_name = 'Preferences' AND column_name = 'UserId'""", + cancellationToken).ConfigureAwait(false)); + + Assert.True(await ScalarAsync( + dataSource, + """SELECT indisunique FROM pg_index WHERE indexrelid = 'public."IX_Users_NormalizedUsername"'::regclass""", + cancellationToken).ConfigureAwait(false)); + + // The model declares no database defaults, so none may be left over from filling the new columns in. + Assert.Equal(0L, await ScalarAsync( + dataSource, + """ + SELECT count(*) FROM information_schema.columns + WHERE table_schema = 'public' AND column_default IS NOT NULL + AND (table_name, column_name) IN ( + ('Users', 'NormalizedUsername'), ('Permissions', 'UserId'), + ('Preferences', 'UserId'), ('MediaStreamInfos', 'IsOriginal')) + """, + cancellationToken).ConfigureAwait(false)); + + // Playlists may hold the same child more than once, which the primary key has to allow for. + Assert.Equal( + "ParentId,SortOrder", + await ScalarAsync( + dataSource, + """ + SELECT string_agg(attribute.attname, ',' ORDER BY key.ordinality) + FROM pg_constraint AS constraint_ + CROSS JOIN LATERAL unnest(constraint_.conkey) WITH ORDINALITY AS key(attnum, ordinality) + JOIN pg_attribute AS attribute + ON attribute.attrelid = constraint_.conrelid AND attribute.attnum = key.attnum + WHERE constraint_.conname = 'PK_LinkedChildren' + """, + cancellationToken).ConfigureAwait(false)); + + Assert.Equal(1L, await ScalarAsync( + dataSource, + """SELECT count(*) FROM pg_constraint WHERE conname = 'FK_BaseItems_BaseItems_OwnerId'""", + cancellationToken).ConfigureAwait(false)); + } + + private static async Task ColumnTypeAsync(NpgsqlDataSource dataSource, string table, string column, CancellationToken cancellationToken) + { + var value = await NullableScalarAsync( + dataSource, + $"""SELECT data_type FROM information_schema.columns WHERE table_schema = 'public' AND table_name = '{table}' AND column_name = '{column}'""", + cancellationToken).ConfigureAwait(false); + return (string?)value; + } + + /// + /// Writes and reads an entity through the context to prove the migrated schema really matches the current model. + /// + private static async Task AssertContextMatchesSchemaAsync(NpgsqlDataSource dataSource, CancellationToken cancellationToken) + { + var context = CreateContext(dataSource); + await using (context.ConfigureAwait(false)) + { + Assert.False(context.Database.HasPendingModelChanges()); + + var user = new User("carol", "provider", "provider"); + context.Users.Add(user); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + var reloaded = await context.Users.AsNoTracking() + .FirstAsync(u => u.Id.Equals(user.Id), cancellationToken) + .ConfigureAwait(false); + Assert.Equal("CAROL", reloaded.NormalizedUsername); + } + } + + private async Task CreateDatabaseAsync(string name, CancellationToken cancellationToken) + { + await using var adminDataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build(); + await ExecuteAsync(adminDataSource, $"CREATE DATABASE {name}", cancellationToken).ConfigureAwait(false); + + return new NpgsqlConnectionStringBuilder(_container.GetConnectionString()) { Database = name }.ConnectionString; + } + + private sealed class SingleContextFactory : IDbContextFactory + { + private readonly NpgsqlDataSource _dataSource; + + public SingleContextFactory(NpgsqlDataSource dataSource) + { + _dataSource = dataSource; + } + + public JellyfinDbContext CreateDbContext() => CreateContext(_dataSource); + } +}