From eaf0a1094a1d285a996f6d4e8be88bc1cfbb4fe4 Mon Sep 17 00:00:00 2001 From: BORNIOS Date: Sat, 17 Oct 2026 15:25:36 -0700 Subject: [PATCH 1/3] fix: reparar fechas de bibliotecas y busqueda sin distinguir mayusculas - Nueva migracion que normaliza DateCreated no finito o minimo en BaseItems. - Normalizacion de los centinelas DateTime y DateTimeOffset antes de escribir en PostgreSQL. - Busqueda de titulos con ILIKE en Name, OriginalTitle y SortName. - SortName incluido en la busqueda rapida y en su indice GIN trigram. - Pruebas de normalizacion de fechas y de reescritura de busquedas. --- ..._NormalizeInvalidBaseItemDates.Designer.cs | 1774 +++++++++++++++++ ...015234720_NormalizeInvalidBaseItemDates.cs | 28 + .../Data/PostgresDatabaseProvider.cs | 5 +- .../DateTimeKindNormalizingInterceptor.cs | 16 +- .../Services/InstantSearchService.cs | 3 + ...ostgresCaseInsensitiveSearchInterceptor.cs | 51 + README.en.md | 5 +- README.md | 5 +- ...DateTimeKindNormalizingInterceptorTests.cs | 26 +- ...esCaseInsensitiveSearchInterceptorTests.cs | 41 + 10 files changed, 1938 insertions(+), 16 deletions(-) create mode 100644 Jellyfin.Database.Providers.Postgres/Data/Migrations/20261015234720_NormalizeInvalidBaseItemDates.Designer.cs create mode 100644 Jellyfin.Database.Providers.Postgres/Data/Migrations/20261015234720_NormalizeInvalidBaseItemDates.cs create mode 100644 Jellyfin.Database.Providers.Postgres/Services/PostgresCaseInsensitiveSearchInterceptor.cs create mode 100644 Tests/Jellyfin.Database.Providers.Postgres.Tests/PostgresCaseInsensitiveSearchInterceptorTests.cs diff --git a/Jellyfin.Database.Providers.Postgres/Data/Migrations/20261015234720_NormalizeInvalidBaseItemDates.Designer.cs b/Jellyfin.Database.Providers.Postgres/Data/Migrations/20261015234720_NormalizeInvalidBaseItemDates.Designer.cs new file mode 100644 index 0000000..a896fe9 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Data/Migrations/20261015234720_NormalizeInvalidBaseItemDates.Designer.cs @@ -0,0 +1,1774 @@ +// +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.Postgres.Data.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20261015234720_NormalizeInvalidBaseItemDates")] + partial class NormalizeInvalidBaseItemDates + { + /// + 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("NormalizedUsername") + .IsUnique(); + + 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/Jellyfin.Database.Providers.Postgres/Data/Migrations/20261015234720_NormalizeInvalidBaseItemDates.cs b/Jellyfin.Database.Providers.Postgres/Data/Migrations/20261015234720_NormalizeInvalidBaseItemDates.cs new file mode 100644 index 0000000..53ed641 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Data/Migrations/20261015234720_NormalizeInvalidBaseItemDates.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Database.Providers.Postgres.Data.Migrations +{ + /// + public partial class NormalizeInvalidBaseItemDates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + UPDATE "BaseItems" + SET "DateCreated" = TIMESTAMPTZ '1970-01-01 00:00:00+00' + WHERE NOT isfinite("DateCreated") + OR "DateCreated" = TIMESTAMPTZ '0001-01-01 00:00:00+00'; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Reverting these client-unsafe sentinel values would reintroduce Android TV crashes. + } + } +} diff --git a/Jellyfin.Database.Providers.Postgres/Data/PostgresDatabaseProvider.cs b/Jellyfin.Database.Providers.Postgres/Data/PostgresDatabaseProvider.cs index ae9463d..129d45e 100644 --- a/Jellyfin.Database.Providers.Postgres/Data/PostgresDatabaseProvider.cs +++ b/Jellyfin.Database.Providers.Postgres/Data/PostgresDatabaseProvider.cs @@ -28,7 +28,7 @@ namespace Jellyfin.Database.Providers.Postgres; public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider { // ── Fields: readonly first (SA1214), then mutable (SA1201: fields before constructor) ─── - // 9 GIN trigram indexes for sub-15ms instant search (pg_trgm required) + // 10 GIN trigram indexes for sub-15ms instant search (pg_trgm required) // All use partial indexes (WHERE col IS NOT NULL) to avoid indexing NULL rows. private static readonly (string Name, string Sql)[] GinIndexDefinitions = { @@ -36,6 +36,8 @@ private static readonly (string Name, string Sql)[] GinIndexDefinitions = """CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_BaseItems_Name_gin_trgm" ON "BaseItems" USING gin ("Name" gin_trgm_ops) WHERE "Name" IS NOT NULL;"""), ("IX_BaseItems_OriginalTitle_gin_trgm", """CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_BaseItems_OriginalTitle_gin_trgm" ON "BaseItems" USING gin ("OriginalTitle" gin_trgm_ops) WHERE "OriginalTitle" IS NOT NULL;"""), + ("IX_BaseItems_SortName_gin_trgm", + """CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_BaseItems_SortName_gin_trgm" ON "BaseItems" USING gin ("SortName" gin_trgm_ops) WHERE "SortName" IS NOT NULL;"""), ("IX_BaseItems_Album_gin_trgm", """CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_BaseItems_Album_gin_trgm" ON "BaseItems" USING gin ("Album" gin_trgm_ops) WHERE "Album" IS NOT NULL;"""), ("IX_BaseItems_Artists_gin_trgm", @@ -192,6 +194,7 @@ public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOpt // This must precede HomeQueryCacheInterceptor: the cache buffers a miss by executing // the command itself, before subsequent command interceptors get a chance to run. new DateTimeKindNormalizingInterceptor(), + new PostgresCaseInsensitiveSearchInterceptor(), new HomeQueryCacheInterceptor(_logger), new UpsertConflictInterceptor(), new ItemValueReuseInterceptor(), diff --git a/Jellyfin.Database.Providers.Postgres/Services/DateTimeKindNormalizingInterceptor.cs b/Jellyfin.Database.Providers.Postgres/Services/DateTimeKindNormalizingInterceptor.cs index 7f90f26..d4eb6de 100644 --- a/Jellyfin.Database.Providers.Postgres/Services/DateTimeKindNormalizingInterceptor.cs +++ b/Jellyfin.Database.Providers.Postgres/Services/DateTimeKindNormalizingInterceptor.cs @@ -33,9 +33,8 @@ namespace Jellyfin.Database.Providers.Postgres.Services; /// public sealed class DateTimeKindNormalizingInterceptor : DbCommandInterceptor { - // Android's java.time cannot represent DateTime.MinValue after a timezone conversion. Npgsql also - // maps this sentinel to PostgreSQL -infinity by default, which can later reach clients as an invalid - // ISO date. Preserve a valid, explicit sentinel instead. + // Android's java.time cannot represent the extreme DateTime sentinels after timezone conversion. + // Npgsql can map them to PostgreSQL infinities, which can reach clients as invalid ISO dates. private static readonly DateTime ClientSafeSentinelUtc = DateTime.UnixEpoch; /// @@ -113,7 +112,7 @@ private static void NormalizeParameters(DbCommand command) } else if (param.Value is DateTimeOffset offset) { - param.Value = offset.ToUniversalTime(); + param.Value = NormalizeDateTimeOffset(offset); } else if (param.Value is DateTime[] dateTimes) { @@ -123,14 +122,14 @@ private static void NormalizeParameters(DbCommand command) } else if (param.Value is DateTimeOffset[] offsets) { - param.Value = Array.ConvertAll(offsets, static offset => offset.ToUniversalTime()); + param.Value = Array.ConvertAll(offsets, NormalizeDateTimeOffset); } } } private static DateTime NormalizeDateTime(DateTime value) { - if (value == DateTime.MinValue) + if (value == DateTime.MinValue || value == DateTime.MaxValue) { return ClientSafeSentinelUtc; } @@ -142,4 +141,9 @@ private static DateTime NormalizeDateTime(DateTime value) _ => DateTime.SpecifyKind(value, DateTimeKind.Utc), }; } + + private static DateTimeOffset NormalizeDateTimeOffset(DateTimeOffset value) + => value == DateTimeOffset.MinValue || value == DateTimeOffset.MaxValue + ? DateTimeOffset.UnixEpoch + : value.ToUniversalTime(); } diff --git a/Jellyfin.Database.Providers.Postgres/Services/InstantSearchService.cs b/Jellyfin.Database.Providers.Postgres/Services/InstantSearchService.cs index f8a8f02..af8a3d6 100644 --- a/Jellyfin.Database.Providers.Postgres/Services/InstantSearchService.cs +++ b/Jellyfin.Database.Providers.Postgres/Services/InstantSearchService.cs @@ -109,6 +109,7 @@ private async Task> SearchWithTrgmAsync( GREATEST( word_similarity(@term, coalesce("Name", '')), word_similarity(@term, coalesce("OriginalTitle",'')), + word_similarity(@term, coalesce("SortName", '')), word_similarity(@term, coalesce("Album", '')), word_similarity(@term, coalesce("Artists", '')), word_similarity(@term, coalesce("SeriesName", '')) @@ -118,6 +119,7 @@ private async Task> SearchWithTrgmAsync( AND ( "Name" ILIKE @like OR "OriginalTitle" ILIKE @like + OR "SortName" ILIKE @like OR "Album" ILIKE @like OR "Artists" ILIKE @like OR "SeriesName" ILIKE @like @@ -157,6 +159,7 @@ CASE WHEN lower("Name") = lower(@term) THEN 1.0 ELSE 0.5 END AS relevance AND ( "Name" ILIKE @like OR "OriginalTitle" ILIKE @like + OR "SortName" ILIKE @like OR "Album" ILIKE @like OR "Artists" ILIKE @like OR "SeriesName" ILIKE @like diff --git a/Jellyfin.Database.Providers.Postgres/Services/PostgresCaseInsensitiveSearchInterceptor.cs b/Jellyfin.Database.Providers.Postgres/Services/PostgresCaseInsensitiveSearchInterceptor.cs new file mode 100644 index 0000000..2f5221f --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/PostgresCaseInsensitiveSearchInterceptor.cs @@ -0,0 +1,51 @@ +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// +/// Makes Jellyfin's built-in BaseItems title searches case-insensitive on PostgreSQL. +/// +internal sealed class PostgresCaseInsensitiveSearchInterceptor : DbCommandInterceptor +{ + private static readonly Regex LikeOperator = new( + @"\bLIKE\b", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + /// + public override InterceptionResult ReaderExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + RewriteSearch(command); + return result; + } + + /// + public override ValueTask> ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + RewriteSearch(command); + return ValueTask.FromResult(result); + } + + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "Jellyfin constructs the EF query with parameterized search terms; this only changes the LIKE operator.")] + private static void RewriteSearch(DbCommand command) + { + var sql = command.CommandText; + if (sql.Contains("FROM \"BaseItems\"", System.StringComparison.Ordinal) + && sql.Contains("\"CleanName\"", System.StringComparison.Ordinal) + && sql.Contains("\"OriginalTitle\"", System.StringComparison.Ordinal)) + { + command.CommandText = LikeOperator.Replace(sql, "ILIKE"); + } + } +} diff --git a/README.en.md b/README.en.md index e991d01..52ec5c6 100644 --- a/README.en.md +++ b/README.en.md @@ -45,10 +45,10 @@ automatic health check, scheduled maintenance and optional JellyTrend integratio ## ✨ Features - 🐘 **PostgreSQL as native backend** — EF Core + Npgsql, no Jellyfin core changes required. -- 🔍 **Near-instant search** (< 15 ms typical) with GIN trigram index (`pg_trgm`); automatic fallback to `ILIKE`. +- 🔍 **Case-insensitive search** across display, original and sort titles; fast search uses a GIN trigram index (`pg_trgm`). - 🔄 **Bidirectional migration** — SQLite → PostgreSQL and PostgreSQL → SQLite without external tools. - 🩺 **Automatic health check** at startup: diagnoses bloat, invalid indexes, drifted sequences and slow queries. -- 🛡️ **Error prevention** — EF Core interceptors for upserts, DB error logging and `DateTime.Kind` normalization. +- 🛡️ **Error prevention** — EF Core interceptors for upserts, logging, Android TV-safe UTC dates and invalid-sentinel repair. - 🔧 **Automatic optimization** — GIN indexes CONCURRENTLY, autovacuum tuning on critical tables. - 💾 **Scheduled backups** with `pg_dump`, optional ZIP compression and restore from the UI. - 🔐 **Private-schema host** — a reusable contract through which other plugins persist only their own data, without credentials or access to Jellyfin's `public` schema. @@ -274,6 +274,7 @@ When GIN indexes are active, the plugin exposes its own search endpoint that byp - Typical latency **< 15 ms** on medium and large libraries (depends on hardware and client). - Searches by item name, path and type. +- Matches display, original and sort titles without case sensitivity. - **Automatic fallback** to `ILIKE` if GIN indexes don't exist yet. - Activated from **Maintenance → Apply optimizations**. diff --git a/README.md b/README.md index 586dac7..ba55cb0 100644 --- a/README.md +++ b/README.md @@ -45,10 +45,10 @@ health check automático, mantenimiento programado e integración opcional con J ## ✨ Características - 🐘 **PostgreSQL como backend nativo** — EF Core + Npgsql, sin tocar el core de Jellyfin. -- 🔍 **Búsqueda casi instantánea** (< 15 ms típico) con índice GIN trigram (`pg_trgm`); fallback automático a `ILIKE`. +- 🔍 **Búsqueda sin distinguir mayúsculas** por título visible, original y de orden; búsqueda rápida con índice GIN trigram (`pg_trgm`). - 🔄 **Migración bidireccional** — SQLite → PostgreSQL y PostgreSQL → SQLite sin herramientas externas. - 🩺 **Health Check automático** al arrancar: diagnostica bloat, índices inválidos, secuencias desfasadas y queries lentas. -- 🛡️ **Prevención de errores** — interceptores EF Core para upserts, logging de errores DB y normalización de `DateTime.Kind`. +- 🛡️ **Prevención de errores** — interceptores EF Core para upserts, logging, fechas UTC compatibles con Android TV y reparación de sentinels inválidos. - 🔧 **Optimización automática** — índices GIN CONCURRENTLY, tuning de autovacuum en tablas críticas. - 💾 **Backups programados** con `pg_dump`, compresión ZIP opcional y restore desde la UI. - 🔐 **Host de esquemas privados** — contrato reutilizable para que otros plugins persistan únicamente sus propios datos, sin credenciales ni acceso al esquema `public` de Jellyfin. @@ -277,6 +277,7 @@ Cuando los índices GIN están activos, el plugin expone un endpoint propio que - Latencia típica **< 15 ms** en bibliotecas medianas y grandes (depende del hardware y del cliente). - Búsqueda por nombre, path y tipo de ítem. +- Coincidencias sin distinguir mayúsculas/minúsculas en título visible, título original y título para ordenar. - **Fallback automático** a `ILIKE` si los índices GIN aún no existen. - Se activa en **Mantenimiento → Aplicar optimizaciones**. diff --git a/Tests/Jellyfin.Database.Providers.Postgres.Tests/DateTimeKindNormalizingInterceptorTests.cs b/Tests/Jellyfin.Database.Providers.Postgres.Tests/DateTimeKindNormalizingInterceptorTests.cs index cdbfb8b..8036ac6 100644 --- a/Tests/Jellyfin.Database.Providers.Postgres.Tests/DateTimeKindNormalizingInterceptorTests.cs +++ b/Tests/Jellyfin.Database.Providers.Postgres.Tests/DateTimeKindNormalizingInterceptorTests.cs @@ -39,15 +39,31 @@ public void TimestampArrayParametersAreNormalisedToUtc() Assert.Equal(local.ToUniversalTime(), values[0]); } - /// Missing-date sentinels must remain representable after a client timezone conversion. + /// Extreme date sentinels must remain representable after a client timezone conversion. [Fact] - public void DateTimeMinimumIsReplacedWithClientSafeUtcSentinel() + public void DateTimeExtremesAreReplacedWithClientSafeUtcSentinel() { - var command = new NpgsqlCommand("SELECT @date"); - command.Parameters.AddWithValue("date", DateTime.MinValue); + var command = new NpgsqlCommand("SELECT @minimum, @maximum"); + command.Parameters.AddWithValue("minimum", DateTime.MinValue); + command.Parameters.AddWithValue("maximum", DateTime.MaxValue); new DateTimeKindNormalizingInterceptor().ReaderExecuting(command, null!, default); - Assert.Equal(DateTime.UnixEpoch, Assert.IsType(command.Parameters["date"].Value)); + Assert.Equal(DateTime.UnixEpoch, Assert.IsType(command.Parameters["minimum"].Value)); + Assert.Equal(DateTime.UnixEpoch, Assert.IsType(command.Parameters["maximum"].Value)); + } + + /// Extreme offset sentinels must also be converted before reaching Npgsql. + [Fact] + public void DateTimeOffsetExtremesAreReplacedWithClientSafeUtcSentinel() + { + var command = new NpgsqlCommand("SELECT @minimum, @maximum"); + command.Parameters.AddWithValue("minimum", DateTimeOffset.MinValue); + command.Parameters.AddWithValue("maximum", DateTimeOffset.MaxValue); + + new DateTimeKindNormalizingInterceptor().ReaderExecuting(command, null!, default); + + Assert.Equal(DateTimeOffset.UnixEpoch, Assert.IsType(command.Parameters["minimum"].Value)); + Assert.Equal(DateTimeOffset.UnixEpoch, Assert.IsType(command.Parameters["maximum"].Value)); } } diff --git a/Tests/Jellyfin.Database.Providers.Postgres.Tests/PostgresCaseInsensitiveSearchInterceptorTests.cs b/Tests/Jellyfin.Database.Providers.Postgres.Tests/PostgresCaseInsensitiveSearchInterceptorTests.cs new file mode 100644 index 0000000..640ce9b --- /dev/null +++ b/Tests/Jellyfin.Database.Providers.Postgres.Tests/PostgresCaseInsensitiveSearchInterceptorTests.cs @@ -0,0 +1,41 @@ +using Jellyfin.Database.Providers.Postgres.Services; +using Npgsql; +using Xunit; + +namespace Jellyfin.Database.Providers.Postgres.Tests; + +/// Verifies PostgreSQL adapts Jellyfin's case-sensitive search operators. +public sealed class PostgresCaseInsensitiveSearchInterceptorTests +{ + [Fact] + public void JellyfinTitleSearchUsesCaseInsensitiveLikeForAllTitleFields() + { + var command = new NpgsqlCommand( + """ + SELECT "Id" FROM "BaseItems" AS b + WHERE b."CleanName" LIKE @clean + OR b."OriginalTitle" LIKE @original + OR b."SortName" LIKE @sort + OR b."Name" ILIKE @display + """); + + new PostgresCaseInsensitiveSearchInterceptor().ReaderExecuting(command, null!, default); + + Assert.Contains("b.\"CleanName\" ILIKE @clean", command.CommandText, StringComparison.Ordinal); + Assert.Contains("b.\"OriginalTitle\" ILIKE @original", command.CommandText, StringComparison.Ordinal); + Assert.Contains("b.\"SortName\" ILIKE @sort", command.CommandText, StringComparison.Ordinal); + Assert.Contains("b.\"Name\" ILIKE @display", command.CommandText, StringComparison.Ordinal); + Assert.DoesNotContain("IILIKE", command.CommandText, StringComparison.Ordinal); + } + + [Fact] + public void DoesNotRewriteLikeOutsideJellyfinTitleSearch() + { + const string Sql = """SELECT "Id" FROM "BaseItems" WHERE "Path" LIKE @path"""; + var command = new NpgsqlCommand(Sql); + + new PostgresCaseInsensitiveSearchInterceptor().ReaderExecuting(command, null!, default); + + Assert.Equal(Sql, command.CommandText); + } +} From cd9d62e2149465519016d1f9c1bb367800c216f9 Mon Sep 17 00:00:00 2001 From: BORNIOS Date: Sat, 17 Oct 2026 15:25:37 -0700 Subject: [PATCH 2/3] fix: evitar el cierre de Android TV y el 500 al abrir artistas - La cache de consultas bufferiza las columnas de fecha como UTC y el lector restaura DateTimeKind: el cliente Android recibia la fecha sin zona horaria, la sustituia por su valor minimo y fallaba al convertirla. - Las filas duplicadas se resuelven con ON CONFLICT ... DO UPDATE sobre la clave primaria en lugar de DO NOTHING, que informa 0 filas afectadas y EF Core interpreta como DbUpdateConcurrencyException. - El proveedor fija QuerySplittingBehavior en SingleQuery, igual que el proveedor SQLite de Jellyfin, y deja de llenar el log con MultipleCollectionIncludeWarning. - Pruebas de regresion de los tres casos y ajuste de las pruebas de reescritura SQL. --- Directory.Build.props | 6 +- .../Data/PostgresDatabaseProvider.cs | 39 ++++-- .../Services/HomeQueryCacheInterceptor.cs | 55 +++++++-- .../Services/UpsertConflictInterceptor.cs | 49 ++++++-- README.en.md | 12 ++ README.md | 12 ++ .../HomeQueryCacheDateTimeKindTests.cs | 95 +++++++++++++++ .../PostgresProviderOptionsTests.cs | 53 +++++++++ .../SqlRewriteTests.cs | 31 +++-- .../UpsertConflictIntegrationTests.cs | 112 ++++++++++++++++++ build.yaml | 2 +- manifest.json | 8 ++ 12 files changed, 430 insertions(+), 44 deletions(-) create mode 100644 Tests/Jellyfin.Database.Providers.Postgres.Tests/HomeQueryCacheDateTimeKindTests.cs create mode 100644 Tests/Jellyfin.Database.Providers.Postgres.Tests/PostgresProviderOptionsTests.cs create mode 100644 Tests/Jellyfin.Database.Providers.Postgres.Tests/UpsertConflictIntegrationTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index ae9e57d..c4d21e0 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 3.0.3.0 - 3.0.3.0 - 3.0.3.0 + 3.0.4.0 + 3.0.4.0 + 3.0.4.0 diff --git a/Jellyfin.Database.Providers.Postgres/Data/PostgresDatabaseProvider.cs b/Jellyfin.Database.Providers.Postgres/Data/PostgresDatabaseProvider.cs index 129d45e..94d9d2c 100644 --- a/Jellyfin.Database.Providers.Postgres/Data/PostgresDatabaseProvider.cs +++ b/Jellyfin.Database.Providers.Postgres/Data/PostgresDatabaseProvider.cs @@ -18,6 +18,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Npgsql; +using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure; namespace Jellyfin.Database.Providers.Postgres; @@ -171,17 +172,7 @@ public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOpt options .UseNpgsql( tunedConnStr, - npgsql => - { - npgsql.MigrationsAssembly(MigrationAssemblyResolver.EnsureRegistered()); - - // Set the timeout on the provider as well, so it does not depend solely on the - // connection string (Jellyfin's startup code migrations need a long one). - if (commandTimeout > 0) - { - npgsql.CommandTimeout(commandTimeout); - } - }) + npgsql => ConfigureNpgsql(npgsql, commandTimeout)) // Direct EF Core diagnostics are reliable even though Jellyfin's ILoggerFactory is constructed // before plugins. Command lifecycle messages are intentionally excluded: the interceptor records // slow commands and failures with richer, redacted SQL context in Postgres-*.log. @@ -854,4 +845,30 @@ await checkCmd.ExecuteScalarAsync(ct).ConfigureAwait(false), logger.LogDebug(ex, "Could not activate pg_stat_statements — skipping."); } } + + /// + /// Applies the provider options that do not depend on the connection string. + /// + /// The provider options builder. + /// Command timeout in seconds; ignored when not positive. + internal static void ConfigureNpgsql(NpgsqlDbContextOptionsBuilder npgsql, int commandTimeout) + { + ArgumentNullException.ThrowIfNull(npgsql); + + npgsql.MigrationsAssembly(MigrationAssemblyResolver.EnsureRegistered()); + + // Set the timeout on the provider as well, so it does not depend solely on the + // connection string (Jellyfin's startup code migrations need a long one). + if (commandTimeout > 0) + { + npgsql.CommandTimeout(commandTimeout); + } + + // Jellyfin's item queries project several collection navigations (providers, images, user data, + // links). While QuerySplittingBehavior is left unset, EF Core logs MultipleCollectionIncludeWarning + // for every compiled shape, which floods the server log. Pinning the behaviour keeps the current + // SingleQuery execution — the one Jellyfin's own SQLite provider ends up using — and removes the + // warning at its source. + npgsql.UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery); + } } diff --git a/Jellyfin.Database.Providers.Postgres/Services/HomeQueryCacheInterceptor.cs b/Jellyfin.Database.Providers.Postgres/Services/HomeQueryCacheInterceptor.cs index 03b302c..c58ddc0 100644 --- a/Jellyfin.Database.Providers.Postgres/Services/HomeQueryCacheInterceptor.cs +++ b/Jellyfin.Database.Providers.Postgres/Services/HomeQueryCacheInterceptor.cs @@ -696,7 +696,12 @@ private static async Task MaterializeTypedAsync(DbDataReader reader, return table; } - private static DataTable MaterializeTyped(DbDataReader reader) + /// + /// Copies every row of into a typed table that can be replayed later. + /// + /// The command reader to drain. + /// The buffered table. + internal static DataTable MaterializeTyped(DbDataReader reader) { var table = CreateTypedTable(reader); var values = new object[reader.FieldCount]; @@ -716,10 +721,17 @@ private static DataTable MaterializeTyped(DbDataReader reader) /// safely read an integer projection as a . Jellyfin's search /// query relies on that provider conversion. /// - private static NpgsqlCompatibleDataTableReader CreateBufferedReader(DataTable table) + /// The buffered table to replay. + /// A reader over the buffered rows. + internal static DbDataReader CreateBufferedReader(DataTable table) => new NpgsqlCompatibleDataTableReader(table.CreateDataReader()); - private static DataTable CreateTypedTable(DbDataReader reader) + /// + /// Creates an empty table whose columns use the CLR types the command returns. + /// + /// The command reader that defines the schema. + /// The empty, typed table. + internal static DataTable CreateTypedTable(DbDataReader reader) { var table = new DataTable(); var names = new HashSet(StringComparer.Ordinal); @@ -732,7 +744,16 @@ private static DataTable CreateTypedTable(DbDataReader reader) names.Add(name); } - table.Columns.Add(name, reader.GetFieldType(ordinal)); + var column = table.Columns.Add(name, reader.GetFieldType(ordinal)); + + // A DataColumn defaults to DataSetDateTime.UnspecifiedLocal, which discards DateTimeKind.Utc + // while copying the row. Jellyfin writes such a value without a timezone offset (its converter + // only appends 'Z' when the milliseconds are zero) and the Android client cannot parse that, + // replacing the date with LocalDateTime.MIN and crashing on the next navigation. + if (column.DataType == typeof(DateTime)) + { + column.DateTimeMode = DataSetDateTime.Utc; + } } return table; @@ -975,9 +996,18 @@ public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int public override string GetString(int ordinal) => GetFieldValue(ordinal); - public override object GetValue(int ordinal) => _inner.GetValue(ordinal); + public override object GetValue(int ordinal) => RestoreUtcKind(_inner.GetValue(ordinal)); - public override int GetValues(object[] values) => _inner.GetValues(values); + public override int GetValues(object[] values) + { + var count = _inner.GetValues(values); + for (var i = 0; i < count; i++) + { + values[i] = RestoreUtcKind(values[i]); + } + + return count; + } public override bool IsDBNull(int ordinal) => _inner.IsDBNull(ordinal); @@ -995,7 +1025,7 @@ public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int public override T GetFieldValue(int ordinal) { - var value = _inner.GetValue(ordinal); + var value = RestoreUtcKind(_inner.GetValue(ordinal)); if (value is T typed) { return typed; @@ -1023,6 +1053,17 @@ public override T GetFieldValue(int ordinal) public override Task GetFieldValueAsync(int ordinal, CancellationToken cancellationToken) => Task.FromResult(GetFieldValue(ordinal)); + /// + /// Last line of defence for timestamps read from PostgreSQL: every Jellyfin date column is + /// timestamp with time zone, which Npgsql hands back with . + /// A value that arrives here without a kind lost it while being buffered, and passing it on + /// makes Jellyfin emit an offset-less date that the Android client cannot parse. + /// + private static object RestoreUtcKind(object value) + => value is DateTime { Kind: DateTimeKind.Unspecified } dateTime + ? DateTime.SpecifyKind(dateTime, DateTimeKind.Utc) + : value; + protected override void Dispose(bool disposing) { if (disposing) diff --git a/Jellyfin.Database.Providers.Postgres/Services/UpsertConflictInterceptor.cs b/Jellyfin.Database.Providers.Postgres/Services/UpsertConflictInterceptor.cs index 17af9ee..3c4144b 100644 --- a/Jellyfin.Database.Providers.Postgres/Services/UpsertConflictInterceptor.cs +++ b/Jellyfin.Database.Providers.Postgres/Services/UpsertConflictInterceptor.cs @@ -71,6 +71,23 @@ public sealed partial class UpsertConflictInterceptor : DbCommandInterceptor "BaseItemMetadataFields", ]; + // Primary key of every table rewritten here. The conflict clause has to name it: an INSERT that is + // skipped reports zero affected rows, and EF Core turns a row count below the expected one into a + // DbUpdateConcurrencyException. A no-op DO UPDATE reports one row for both outcomes, so a duplicate + // still behaves like the SQLite write path (keep the row that was already there) without failing. + private static readonly Dictionary PrimaryKeys = new(StringComparer.Ordinal) + { + [ItemValuesTable] = ["Type", "Value"], + ["\"BaseItems\""] = ["Id"], + ["\"BaseItemProviders\""] = ["ItemId", "ProviderId"], + ["\"BaseItemImageInfos\""] = ["Id"], + ["\"BaseItemMetadataFields\""] = ["Id", "ItemId"], + ["\"BaseItemTrailerTypes\""] = ["Id", "ItemId"], + ["\"AncestorIds\""] = ["ItemId", "ParentItemId"], + ["\"ItemValuesMap\""] = ["ItemValueId", "ItemId"], + ["\"PeopleBaseItemMap\""] = ["ItemId", "PeopleId", "Role"], + }; + /// public override InterceptionResult NonQueryExecuting( DbCommand command, @@ -221,13 +238,8 @@ private static void ApplyRewrite(DbCommand command, string sql) return match.Value; } - // ItemValues needs the column pair named: the fresh random ItemValueId never collides, so a - // clause without a target would never fire and the duplicate (Type, Value) would still fail. - var clause = isItemValue - ? "\nON CONFLICT (\"Type\", \"Value\") DO NOTHING" - : "\nON CONFLICT DO NOTHING"; - var stmt = match.Value.TrimEnd(); + var clause = ConflictClause(tableName); return stmt.EndsWith(';') ? stmt[..^1] + clause + ";" : stmt + clause; @@ -255,7 +267,7 @@ private static void ApplyRewrite(DbCommand command, string sql) return match.Value; } - var suffix = match.Groups["conflict"].Success ? "\nON CONFLICT DO NOTHING" : string.Empty; + var suffix = match.Groups["conflict"].Success ? ConflictClause($"\"{table}\"") : string.Empty; return string.Concat( "INSERT INTO \"", table, @@ -291,7 +303,7 @@ private static void ApplyRewrite(DbCommand command, string sql) itemId, ", ", itemValueId, - " WHERE EXISTS (SELECT 1 FROM \"ItemValues\" WHERE \"ItemValueId\" = ", + " WHERE EXISTS (SELECT 1 FROM \"ItemValues\" AS iv WHERE iv.\"ItemValueId\" = ", itemValueId, ")"); }); @@ -300,6 +312,25 @@ private static void ApplyRewrite(DbCommand command, string sql) command.CommandText = rewritten; } + /// + /// Builds the conflict clause appended to a rewritten INSERT. + /// + /// Quoted table name that was inserted into. + /// The ON CONFLICT clause, ending before the statement terminator. + private static string ConflictClause(string tableName) + { + if (!PrimaryKeys.TryGetValue(tableName, out var columns)) + { + return "\nON CONFLICT DO NOTHING"; + } + + // The update assigns each key column to its own incoming value, so the row is left exactly as it + // was while PostgreSQL still reports one affected row. + var target = string.Join(", ", columns.Select(static column => $"\"{column}\"")); + var assignments = string.Join(", ", columns.Select(static column => $"\"{column}\" = EXCLUDED.\"{column}\"")); + return string.Concat("\nON CONFLICT (", target, ") DO UPDATE SET ", assignments); + } + /// /// Matches a single INSERT statement targeting a quoted table name, /// including its VALUES clause and trailing semicolon. @@ -334,7 +365,7 @@ private static void ApplyRewrite(DbCommand command, string sql) /// EF parameters rather than parsed as SQL expressions. /// [GeneratedRegex( - @"INSERT\s+INTO\s+""(?[^""]+)""\s*\((?[^)]*)\)\s*VALUES\s*\((?[^;]*)\)\s*(?ON\s+CONFLICT\s+DO\s+NOTHING)?\s*;", + @"INSERT\s+INTO\s+""(?
[^""]+)""\s*\((?[^)]*)\)\s*VALUES\s*\((?[^;]*)\)\s*(?ON\s+CONFLICT[^;]*)?\s*;", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex BaseItemDependentInsertRegex(); diff --git a/README.en.md b/README.en.md index 52ec5c6..0ba9429 100644 --- a/README.en.md +++ b/README.en.md @@ -387,6 +387,18 @@ No. It is a server-side endpoint. The plugin UI uses it internally when PostgreS Earlier versions used the global `Npgsql.EnableLegacyTimestampBehavior` switch, which forced UTC on all reads. The current interceptor normalizes timezone-less write parameters without changing reads and replaces `DateTime.MinValue` with a client-safe UTC value before Npgsql represents it as `-infinity`. +
+Why did Android TV crash when opening some libraries? + +The query cache buffered result sets in a `DataTable` whose date columns dropped `DateTimeKind.Utc` (the default mode is `UnspecifiedLocal`). Jellyfin then serialized the date without a zone offset (`2026-04-18T18:49:38.113835`) and the Android client could not parse it: it substituted `LocalDateTime.MIN` and failed converting it. Columns are now created with `DateTimeMode = Utc` and the reader restores the `Kind` as a second safeguard. Only dates with non-zero milliseconds were affected, which is why some libraries opened and others did not. +
+ +
+Why did opening an artist or person page return 500? + +A metadata refresh inserts the item's dependent rows again (providers, images, locked fields) even when they already exist. The plugin rewrote those inserts as `ON CONFLICT DO NOTHING`, which avoids the duplicate key error but reports **zero affected rows**, and EF Core turns that into `DbUpdateConcurrencyException`. The conflict is now resolved with a no-op update on the primary key: the stored row is kept and the statement counts as one row, so the save completes. +
+ --- ## 🤝 Community diff --git a/README.md b/README.md index ba55cb0..e558eff 100644 --- a/README.md +++ b/README.md @@ -390,6 +390,18 @@ No. Es un endpoint de servidor. La UI del plugin lo usa internamente cuando Post Las versiones anteriores usaban el switch global `Npgsql.EnableLegacyTimestampBehavior`, que forzaba UTC en todas las lecturas. El interceptor actual normaliza parámetros de escritura sin zona horaria sin alterar las lecturas y convierte `DateTime.MinValue` a un valor UTC seguro para clientes antes de que Npgsql lo represente como `-infinity`. +
+¿Por qué Android TV se cerraba al abrir algunas bibliotecas? + +El caché de consultas bufferizaba los resultados en un `DataTable` cuyas columnas de fecha descartaban `DateTimeKind.Utc` (el modo por defecto es `UnspecifiedLocal`). Jellyfin serializa entonces la fecha sin offset (`2026-04-18T18:49:38.113835`) y el cliente Android no puede leerla: la sustituye por `LocalDateTime.MIN` y falla al convertirla. Las columnas se crean ahora con `DateTimeMode = Utc` y el lector restaura el `Kind` como segunda barrera. Solo afectaba a fechas con milisegundos distintos de cero, por eso unas bibliotecas abrían y otras no. +
+ +
+¿Por qué fallaba con 500 al abrir la ficha de un artista o persona? + +La actualización de metadatos vuelve a insertar las filas hijas del ítem (proveedores, imágenes, campos bloqueados) aunque ya existan. El plugin las convertía en `ON CONFLICT DO NOTHING`, que evita el error de clave duplicada pero informa **0 filas afectadas**, y EF Core interpreta eso como `DbUpdateConcurrencyException`. Ahora el conflicto se resuelve con una actualización sin cambios sobre la clave primaria: la fila guardada se conserva y la operación cuenta como una fila, así que el guardado termina bien. +
+ --- ## 🤝 Comunidad diff --git a/Tests/Jellyfin.Database.Providers.Postgres.Tests/HomeQueryCacheDateTimeKindTests.cs b/Tests/Jellyfin.Database.Providers.Postgres.Tests/HomeQueryCacheDateTimeKindTests.cs new file mode 100644 index 0000000..e7c9c16 --- /dev/null +++ b/Tests/Jellyfin.Database.Providers.Postgres.Tests/HomeQueryCacheDateTimeKindTests.cs @@ -0,0 +1,95 @@ +using System.Data; +using Jellyfin.Database.Providers.Postgres.Services; +using Xunit; + +namespace Jellyfin.Database.Providers.Postgres.Tests; + +/// +/// Buffering a result set must not cost the of a timestamp. +/// +/// +/// A of type defaults to +/// , which drops while the row +/// is copied. Jellyfin then serialises the value without a timezone offset whenever the milliseconds are +/// not zero, and the Android client cannot parse that: it substitutes LocalDateTime.MIN for the date +/// and crashes on the next navigation. Every Jellyfin date column is timestamp with time zone, so the +/// buffered reader must always hand the values back as UTC. +/// +public sealed class HomeQueryCacheDateTimeKindTests +{ + private static readonly DateTime Instant = new DateTime(2026, 4, 18, 18, 49, 38, DateTimeKind.Utc).AddTicks(1138350); + + [Fact] + public void TypedTableKeepsDateTimesAsUtc() + { + var table = HomeQueryCacheInterceptor.CreateTypedTable(CreateSource().CreateDataReader()); + + var column = Assert.Single(table.Columns.Cast(), c => c.DataType == typeof(DateTime)); + Assert.Equal(DataSetDateTime.Utc, column.DateTimeMode); + } + + [Fact] + public void BufferedResultKeepsUtcKindAndInstant() + { + var table = HomeQueryCacheInterceptor.MaterializeTyped(CreateSource().CreateDataReader()); + + using var reader = HomeQueryCacheInterceptor.CreateBufferedReader(table); + Assert.True(reader.Read()); + + var viaGetDateTime = reader.GetDateTime(0); + Assert.Equal(DateTimeKind.Utc, viaGetDateTime.Kind); + Assert.Equal(Instant, viaGetDateTime); + + var viaGetValue = Assert.IsType(reader.GetValue(0)); + Assert.Equal(DateTimeKind.Utc, viaGetValue.Kind); + Assert.Equal(Instant, viaGetValue); + + var viaGetFieldValue = reader.GetFieldValue(0); + Assert.Equal(DateTimeKind.Utc, viaGetFieldValue.Kind); + Assert.Equal(Instant, viaGetFieldValue); + } + + [Fact] + public void BufferedResultReturnsUtcKindEvenForTablesBuiltWithoutUtcMode() + { + // Simulates a buffered table created outside CreateTypedTable: the default column mode already + // stripped the kind before the reader sees the row. + var table = new DataTable(); + table.Columns.Add("DateCreated", typeof(DateTime)); + table.Rows.Add(new object[] { Instant }); + + var stored = Assert.IsType(table.Rows[0][0]); + Assert.Equal(DateTimeKind.Unspecified, stored.Kind); + + using var reader = HomeQueryCacheInterceptor.CreateBufferedReader(table); + Assert.True(reader.Read()); + + Assert.Equal(DateTimeKind.Utc, reader.GetFieldValue(0).Kind); + Assert.Equal(Instant, reader.GetFieldValue(0)); + } + + [Fact] + public void NullableDateTimesAreBufferedAsUtc() + { + var table = HomeQueryCacheInterceptor.MaterializeTyped(CreateSource().CreateDataReader()); + + using var reader = HomeQueryCacheInterceptor.CreateBufferedReader(table); + Assert.True(reader.Read()); + + var values = new object[reader.FieldCount]; + Assert.Equal(reader.FieldCount, reader.GetValues(values)); + + Assert.Equal(DateTimeKind.Utc, Assert.IsType(values[0]).Kind); + Assert.DoesNotContain(values, value => value is DateTime { Kind: DateTimeKind.Unspecified }); + } + + private static DataTable CreateSource() + { + // Emulates what Npgsql returns for "timestamp with time zone": a DateTime with Kind=Utc. + var source = new DataTable(); + var column = source.Columns.Add("DateCreated", typeof(DateTime)); + column.DateTimeMode = DataSetDateTime.Utc; + source.Rows.Add(new object[] { Instant }); + return source; + } +} diff --git a/Tests/Jellyfin.Database.Providers.Postgres.Tests/PostgresProviderOptionsTests.cs b/Tests/Jellyfin.Database.Providers.Postgres.Tests/PostgresProviderOptionsTests.cs new file mode 100644 index 0000000..1584a2c --- /dev/null +++ b/Tests/Jellyfin.Database.Providers.Postgres.Tests/PostgresProviderOptionsTests.cs @@ -0,0 +1,53 @@ +using Jellyfin.Database.Providers.Postgres.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Xunit; + +namespace Jellyfin.Database.Providers.Postgres.Tests; + +/// +/// Provider options that are applied regardless of the connection string. +/// +public sealed class PostgresProviderOptionsTests +{ + /// + /// Jellyfin's item queries project several collection navigations, and EF Core logs + /// MultipleCollectionIncludeWarning for every compiled shape while the splitting behaviour is + /// left unset. It must be explicit: this keeps SingleQuery execution (Jellyfin's default) and stops + /// the warning at its source instead of hiding it with ConfigureWarnings. + /// + [Fact] + public void QuerySplittingBehaviourIsExplicitSoEfCoreDoesNotWarnOnEveryCompiledShape() + { + var options = BuildOptions(commandTimeout: 0); + + Assert.Equal(QuerySplittingBehavior.SingleQuery, ExtractRelationalOptions(options).QuerySplittingBehavior); + } + + [Fact] + public void CommandTimeoutIsOnlyAppliedWhenPositive() + { + Assert.Null(ExtractRelationalOptions(BuildOptions(commandTimeout: 0)).CommandTimeout); + Assert.Equal(300, ExtractRelationalOptions(BuildOptions(commandTimeout: 300)).CommandTimeout); + } + + [Fact] + public void MigrationsAssemblyIsAlwaysConfigured() + { + var migrationsAssembly = ExtractRelationalOptions(BuildOptions(commandTimeout: 0)).MigrationsAssembly; + + Assert.False(string.IsNullOrWhiteSpace(migrationsAssembly)); + } + + private static DbContextOptions BuildOptions(int commandTimeout) + { + var builder = new DbContextOptionsBuilder(); + builder.UseNpgsql( + "Host=localhost;Port=5432;Database=jellyfin;Username=jellyfin", + npgsql => PostgresDatabaseProvider.ConfigureNpgsql(npgsql, commandTimeout)); + return builder.Options; + } + + private static RelationalOptionsExtension ExtractRelationalOptions(DbContextOptions options) + => options.Extensions.OfType().Single(); +} diff --git a/Tests/Jellyfin.Database.Providers.Postgres.Tests/SqlRewriteTests.cs b/Tests/Jellyfin.Database.Providers.Postgres.Tests/SqlRewriteTests.cs index 568a781..fd06e53 100644 --- a/Tests/Jellyfin.Database.Providers.Postgres.Tests/SqlRewriteTests.cs +++ b/Tests/Jellyfin.Database.Providers.Postgres.Tests/SqlRewriteTests.cs @@ -83,11 +83,14 @@ private static DbCommand Rewrite(string sql) /// imagenes a medio crear). Ignorarlo es lo que hace SQLite y deja la fila que ya existia. /// [Fact] - public void DuplicateBaseItemInsertIsIgnored() + public void DuplicateBaseItemInsertKeepsTheStoredRowAndReportsOneRow() { var command = RewriteUpsert("""INSERT INTO "BaseItems" ("Id", "Name") VALUES (@p0, @p1);"""); - Assert.Contains("ON CONFLICT DO NOTHING", command.CommandText, StringComparison.Ordinal); + // DO NOTHING would report zero affected rows and EF Core reports that as a concurrency failure, + // so the stored row is kept through a no-op update instead. + Assert.Contains("ON CONFLICT (\"Id\") DO UPDATE SET \"Id\" = EXCLUDED.\"Id\"", command.CommandText, StringComparison.Ordinal); + Assert.DoesNotContain("DO NOTHING", command.CommandText, StringComparison.Ordinal); } /// @@ -150,7 +153,7 @@ public void DuplicateItemValueInsertNamesTheUniquePairAsConflictTarget() { var command = RewriteUpsert("""INSERT INTO "ItemValues" ("ItemValueId", "CleanValue", "Type", "Value") VALUES (@p0, @p1, @p2, @p3);"""); - Assert.Contains("""ON CONFLICT ("Type", "Value") DO NOTHING""", command.CommandText, StringComparison.Ordinal); + Assert.Contains("ON CONFLICT (\"Type\", \"Value\") DO UPDATE SET \"Type\" = EXCLUDED.\"Type\"", command.CommandText, StringComparison.Ordinal); } /// @@ -170,10 +173,13 @@ public void MappingInsertBecomesConditionalWhenValuesTravelInTheSameBatch(string """); Assert.Contains( - """SELECT @p4, @p0 WHERE EXISTS (SELECT 1 FROM "ItemValues" WHERE "ItemValueId" = @p0)""", + """SELECT @p4, @p0 WHERE EXISTS (SELECT 1 FROM "ItemValues" AS iv WHERE iv."ItemValueId" = @p0)""", + command.CommandText, + StringComparison.Ordinal); + Assert.Contains( + """ON CONFLICT ("ItemValueId", "ItemId") DO UPDATE SET""", command.CommandText, StringComparison.Ordinal); - Assert.Contains("ON CONFLICT DO NOTHING", command.CommandText, StringComparison.Ordinal); } /// @@ -186,7 +192,7 @@ public void MappingInsertAloneKeepsItsShape() var command = RewriteUpsert("""INSERT INTO "ItemValuesMap" ("ItemId", "ItemValueId") VALUES (@p0, @p1);"""); Assert.DoesNotContain("EXISTS", command.CommandText, StringComparison.Ordinal); - Assert.Contains("ON CONFLICT DO NOTHING", command.CommandText, StringComparison.Ordinal); + Assert.Contains("""ON CONFLICT ("ItemValueId", "ItemId") DO UPDATE SET""", command.CommandText, StringComparison.Ordinal); } /// @@ -195,19 +201,18 @@ public void MappingInsertAloneKeepsItsShape() /// The parent is present for ordinary inserts, so this leaves the normal path unchanged. /// [Theory] - [InlineData("BaseItemImageInfos")] - [InlineData("BaseItemProviders")] - public void DependentBaseItemInsertIsConditionalOnTheParentStillExisting(string table) + [InlineData("""INSERT INTO "BaseItemImageInfos" ("Id", "ItemId", "Path") VALUES (@p0, @p1, @p2);""", "BaseItemImageInfos", "@p1")] + [InlineData("""INSERT INTO "BaseItemProviders" ("ItemId", "ProviderId", "ProviderValue") VALUES (@p0, @p1, @p2);""", "BaseItemProviders", "@p0")] + public void DependentBaseItemInsertIsConditionalOnTheParentStillExisting(string sql, string table, string itemIdParameter) { - var command = RewriteUpsert( - $"INSERT INTO \"{table}\" (\"Id\", \"ItemId\", \"Value\") VALUES (@p0, @p1, @p2);"); + var command = RewriteUpsert(sql); Assert.Contains($"INSERT INTO \"{table}\"", command.CommandText, StringComparison.Ordinal); Assert.Contains( - "WHERE EXISTS (SELECT 1 FROM \"BaseItems\" WHERE \"Id\" = @p1)", + $"WHERE EXISTS (SELECT 1 FROM \"BaseItems\" WHERE \"Id\" = {itemIdParameter})", command.CommandText, StringComparison.Ordinal); - Assert.Contains("ON CONFLICT DO NOTHING", command.CommandText, StringComparison.Ordinal); + Assert.Contains("ON CONFLICT (", command.CommandText, StringComparison.Ordinal); } /// Reescribir dos veces no puede cambiar la sentencia: un comando reintentado fallaria. diff --git a/Tests/Jellyfin.Database.Providers.Postgres.Tests/UpsertConflictIntegrationTests.cs b/Tests/Jellyfin.Database.Providers.Postgres.Tests/UpsertConflictIntegrationTests.cs new file mode 100644 index 0000000..2a85577 --- /dev/null +++ b/Tests/Jellyfin.Database.Providers.Postgres.Tests/UpsertConflictIntegrationTests.cs @@ -0,0 +1,112 @@ +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Postgres.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Database.Providers.Postgres.Tests; + +/// +/// A duplicate row must behave like Jellyfin's SQLite write path: keep what is stored and count as a +/// successful save. +/// +/// +/// The conflict rewrite exists because Jellyfin inserts an item's dependent rows again for an item that +/// already exists. Skipping the insert with ON CONFLICT DO NOTHING fixes the unique violation but +/// reports zero affected rows, and EF Core turns a row count below the expected one into +/// DbUpdateConcurrencyException — the 500 a client sees when it opens an artist page. The rewrite +/// therefore performs a no-op update so both outcomes report one row. +/// +public sealed class UpsertConflictIntegrationTests +{ + private const string SkipReason = "POSTGRES_TEST_CONNECTION is not set; skipping PostgreSQL integration tests."; + + [SkippableFact] + public async Task DuplicateProviderInsertKeepsTheStoredRowWithoutFailingTheSave() + { + Skip.IfNot(TestEnvironment.PostgresAvailable, SkipReason); + await using var scratch = await TestEnvironment.ScratchDatabaseScope.CreateAsync(); + + var id = Guid.NewGuid(); + using (var seed = CreateContext(scratch.Database.ConnectionString)) + { + await seed.Database.MigrateAsync(); + seed.BaseItems.Add(new BaseItemEntity { Id = id, Type = "Person", Name = "Artista" }); + seed.BaseItemProviders.Add(new BaseItemProvider + { + ItemId = id, + ProviderId = "Tmdb", + ProviderValue = "123", + Item = null!, + }); + await seed.SaveChangesAsync(); + } + + // ItemPersistenceService adds the providers again when the item it refreshes already exists. + using (var writer = CreateContext(scratch.Database.ConnectionString, new UpsertConflictInterceptor())) + { + writer.BaseItemProviders.Add(new BaseItemProvider + { + ItemId = id, + ProviderId = "Tmdb", + ProviderValue = "123", + Item = null!, + }); + + await writer.SaveChangesAsync(); + } + + using var verify = CreateContext(scratch.Database.ConnectionString); + Assert.Equal( + "123", + await verify.BaseItemProviders + .Where(provider => provider.ItemId == id) + .Select(provider => provider.ProviderValue) + .SingleAsync()); + } + + [SkippableFact] + public async Task DuplicateMetadataFieldInsertKeepsTheStoredRowWithoutFailingTheSave() + { + Skip.IfNot(TestEnvironment.PostgresAvailable, SkipReason); + await using var scratch = await TestEnvironment.ScratchDatabaseScope.CreateAsync(); + + var id = Guid.NewGuid(); + using (var seed = CreateContext(scratch.Database.ConnectionString)) + { + await seed.Database.MigrateAsync(); + seed.BaseItems.Add(new BaseItemEntity { Id = id, Type = "Person", Name = "Artista" }); + seed.BaseItemMetadataFields.Add(new BaseItemMetadataField { Id = 1, ItemId = id, Item = null! }); + await seed.SaveChangesAsync(); + } + + using (var writer = CreateContext(scratch.Database.ConnectionString, new UpsertConflictInterceptor())) + { + writer.BaseItemMetadataFields.Add(new BaseItemMetadataField { Id = 1, ItemId = id, Item = null! }); + + await writer.SaveChangesAsync(); + } + + using var verify = CreateContext(scratch.Database.ConnectionString); + Assert.Equal(1, await verify.BaseItemMetadataFields.CountAsync(field => field.ItemId == id)); + } + + private static JellyfinDbContext CreateContext(string connectionString, params IInterceptor[] interceptors) + { + var builder = new DbContextOptionsBuilder() + .UseNpgsql(connectionString, pg => pg.MigrationsAssembly(typeof(PostgresDatabaseProvider).Assembly.FullName)); + if (interceptors.Length > 0) + { + builder.AddInterceptors(interceptors); + } + + return new JellyfinDbContext( + builder.Options, + NullLogger.Instance, + new PostgresDatabaseProvider(null, null), + new NoLockBehavior(NullLogger.Instance)); + } +} diff --git a/build.yaml b/build.yaml index b4dce89..ea9625c 100644 --- a/build.yaml +++ b/build.yaml @@ -1,7 +1,7 @@ --- name: "PostgreSQL Database Provider" guid: "a2b5f3e8-4c1d-4f7a-9e6b-8d0c2f1a3b5e" -version: "3.0.3.0" +version: "3.0.4.0" targetAbi: "12.1.0.0" framework: "net10.0" overview: "Use PostgreSQL as Jellyfin's database backend" diff --git a/manifest.json b/manifest.json index 180cb0a..54a1c82 100644 --- a/manifest.json +++ b/manifest.json @@ -63,6 +63,14 @@ "sourceUrl": "https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/releases/download/v3.0.3/Jellyfin.Database.Providers.Postgres-3.0.3.0.zip", "checksum": "36870e4156d00bc0ae86f11a7ad7a4f9", "timestamp": "2026-09-30T22:36:31Z" + }, + { + "version": "3.0.4.0", + "changelog": "## Español\n\n### 3.0.4 — Fechas, búsqueda y guardado\n\n- Corrige el cierre de Jellyfin Android TV al abrir bibliotecas: el caché de consultas descartaba `DateTimeKind.Utc` al bufferizar y el cliente recibía fechas sin zona horaria, que sustituía por su valor mínimo y hacían fallar la conversión. Las columnas de fecha se bufferizan como UTC y el lector restaura el `Kind` como segunda barrera.\n- Nueva migración que normaliza fechas no finitas o mínimas ya guardadas en `BaseItems`, y normalización de los centinelas `DateTime` y `DateTimeOffset` antes de escribir.\n- La búsqueda deja de distinguir mayúsculas y minúsculas e incluye título visible, título original y título para ordenar, con índice GIN trigram sobre `SortName`.\n- Corrige el error 500 al abrir la ficha de un artista o persona: las filas duplicadas ya no se resuelven con `ON CONFLICT DO NOTHING` (que informa 0 filas afectadas) sino con una actualización sin cambios sobre la clave primaria, que conserva la fila guardada y cuenta como una fila.\n- El proveedor fija `QuerySplittingBehavior` en `SingleQuery`, igual que el proveedor SQLite de Jellyfin, y deja de llenar el log con `MultipleCollectionIncludeWarning`.\n\nRequiere Jellyfin 12.1.x, PostgreSQL 16 o posterior y .NET 10.\n\n---\n\n## English\n\n### 3.0.4 — Dates, search, and saving\n\n- Fixes Jellyfin Android TV crashing when a library is opened: the query cache dropped `DateTimeKind.Utc` while buffering, so the client received dates without a time zone, replaced them with its minimum value, and failed converting it. Date columns are buffered as UTC and the reader restores the `Kind` as a second safeguard.\n- New migration that normalizes non-finite or minimum dates already stored in `BaseItems`, plus normalization of `DateTime` and `DateTimeOffset` sentinels before writing.\n- Search is no longer case-sensitive and now covers display, original, and sort titles, with a trigram GIN index on `SortName`.\n- Fixes the 500 returned when opening an artist or person page: duplicate rows are no longer resolved with `ON CONFLICT DO NOTHING` (zero affected rows) but with a no-op update on the primary key, which keeps the stored row and counts as one row.\n- The provider pins `QuerySplittingBehavior` to `SingleQuery`, matching Jellyfin's SQLite provider, and stops flooding the log with `MultipleCollectionIncludeWarning`.\n\nRequires Jellyfin 12.1.x, PostgreSQL 16 or newer, and .NET 10.", + "targetAbi": "12.1.0.0", + "sourceUrl": "https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/releases/download/v3.0.4/Jellyfin.Database.Providers.Postgres-3.0.4.0.zip", + "checksum": "", + "timestamp": "2026-10-17T22:07:21Z" } ] } From c22c9abc0bcccdc71339912fbec944ca185c009d Mon Sep 17 00:00:00 2001 From: BORNIOS Date: Sat, 17 Oct 2026 15:42:53 -0700 Subject: [PATCH 3/3] test: estabilizar la prueba de prefetch de la pagina siguiente El prefetch es best effort y comparte un unico slot en todo el proceso, asi que puede descartarse cuando el runner esta cargado o coincide con otra prueba. La prueba repite el escenario en lugar de depender de un unico intento. --- .../HomeQueryCacheTests.cs | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/Tests/Jellyfin.Database.Providers.Postgres.Tests/HomeQueryCacheTests.cs b/Tests/Jellyfin.Database.Providers.Postgres.Tests/HomeQueryCacheTests.cs index dbd86a9..d9cfd12 100644 --- a/Tests/Jellyfin.Database.Providers.Postgres.Tests/HomeQueryCacheTests.cs +++ b/Tests/Jellyfin.Database.Providers.Postgres.Tests/HomeQueryCacheTests.cs @@ -20,6 +20,10 @@ public sealed class HomeQueryCacheTests { private const string SkipReason = "POSTGRES_TEST_CONNECTION is not set; skipping PostgreSQL integration tests."; + // The next-page prefetch is best effort and shares one slot across the process, so it can be dropped + // legitimately. Repeating the scenario keeps the assertion strict without depending on timing. + private const int PrefetchAttempts = 5; + /// /// A batch that inserts an item value inside a transaction and reads it back used to leave that read in /// the cache. When the batch was rolled back (for example because another statement failed), the next @@ -154,15 +158,26 @@ SELECT md5('home-prefetch-' || value::text)::uuid, 'Movie', false, false, false, using var context = CreateContext(scratch.Database.ConnectionString, interceptor); const string Sql = "SELECT \"Id\" AS \"Value\" FROM \"BaseItems\" ORDER BY \"Name\" LIMIT @limit OFFSET @offset"; - var firstPage = await context.Database.SqlQueryRaw(Sql, new NpgsqlParameter { ParameterName = "limit", Value = 100 }, new NpgsqlParameter { ParameterName = "offset", Value = 0 }).ToListAsync(); - Assert.Equal(100, firstPage.Count); - - for (var attempt = 0; attempt < 50 && HomeQueryCacheInterceptor.PrefetchCompletedCount == 0; attempt++) + var prefetched = false; + for (var attempt = 0; attempt < PrefetchAttempts && !prefetched; attempt++) { - await Task.Delay(50); + HomeQueryCacheInterceptor.Purge(); + + var firstPage = await context.Database.SqlQueryRaw(Sql, new NpgsqlParameter { ParameterName = "limit", Value = 100 }, new NpgsqlParameter { ParameterName = "offset", Value = 0 }).ToListAsync(); + Assert.Equal(100, firstPage.Count); + + // The prefetch gives up after its own budget, so poll a little longer than that. + for (var waited = 0; waited < 60 && HomeQueryCacheInterceptor.PrefetchCompletedCount == 0; waited++) + { + await Task.Delay(50); + } + + prefetched = HomeQueryCacheInterceptor.PrefetchCompletedCount == 1; } - Assert.Equal(1, HomeQueryCacheInterceptor.PrefetchCompletedCount); + Assert.True( + prefetched, + $"The next-page prefetch never completed in {PrefetchAttempts} attempts; it is skipped when the shared slot is busy."); var secondPage = await context.Database.SqlQueryRaw(Sql, new NpgsqlParameter { ParameterName = "limit", Value = 100 }, new NpgsqlParameter { ParameterName = "offset", Value = 100 }).ToListAsync(); Assert.Equal(100, secondPage.Count);