Refactor PostgreSQL provider: multi-schema & async prep

- Refactor migrations and provider to use multiple PostgreSQL schemas, each matching a legacy SQLite database (activitylog, authentication, displaypreferences, library, users).
- All tables, foreign keys, and indexes are now schema-qualified; Down migration drops tables by schema.
- Provider ensures schemas exist before migrations; entities are mapped to correct schemas in OnModelCreating.
- Add support for max-pool-size, min-pool-size, and multiplexing connection options; update logging accordingly.
- VACUUM ANALYZE now runs per schema during scheduled optimization.
- TruncateAllTablesAsync now truncates tables with schema qualification.
- README updated with schema structure, new options, and multiplexing warnings.
- CacheDecorator now calls async repository methods using .GetAwaiter().GetResult(), with documentation.
- Lays groundwork for full async/await and multiplexing support in the database layer.
This commit is contained in:
2026-02-23 09:38:22 -05:00
parent ede6904433
commit 86883cd5c6
51 changed files with 6719 additions and 187 deletions
@@ -14,7 +14,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Jellyfin.CodeAnalysis")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+8d39eb77e6f2c21547ee919c542c5f7bf520c9a5")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+ede6904433ad169c85d6740696e10c0df47e1a13")]
[assembly: System.Reflection.AssemblyProductAttribute("Jellyfin.CodeAnalysis")]
[assembly: System.Reflection.AssemblyTitleAttribute("Jellyfin.CodeAnalysis")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
@@ -1 +1 @@
46f31a434996f3f948113e0a45d718aef3a886077a49d1b3e5b5870d402fb364
2e432f4563c02bf83f64873f7a02c00c8dd4fb81d0140d7082d0a153c870cf29
@@ -12,8 +12,16 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Create schemas to organize tables by their legacy database origin
migrationBuilder.EnsureSchema(name: "activitylog");
migrationBuilder.EnsureSchema(name: "authentication");
migrationBuilder.EnsureSchema(name: "displaypreferences");
migrationBuilder.EnsureSchema(name: "library");
migrationBuilder.EnsureSchema(name: "users");
migrationBuilder.CreateTable(
name: "ActivityLogs",
schema: "activitylog",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
@@ -35,6 +43,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "ApiKeys",
schema: "authentication",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
@@ -51,6 +60,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "BaseItems",
schema: "library",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
@@ -132,6 +142,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
table.ForeignKey(
name: "FK_BaseItems_BaseItems_ParentId",
column: x => x.ParentId,
principalSchema: "library",
principalTable: "BaseItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
@@ -139,6 +150,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "CustomItemDisplayPreferences",
schema: "displaypreferences",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
@@ -156,6 +168,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "DeviceOptions",
schema: "authentication",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
@@ -170,6 +183,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "ItemValues",
schema: "library",
columns: table => new
{
ItemValueId = table.Column<Guid>(type: "uuid", nullable: false),
@@ -184,6 +198,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "MediaSegments",
schema: "library",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
@@ -200,6 +215,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "Peoples",
schema: "library",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
@@ -213,6 +229,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "TrickplayInfos",
schema: "library",
columns: table => new
{
ItemId = table.Column<Guid>(type: "uuid", nullable: false),
@@ -231,6 +248,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "Users",
schema: "users",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
@@ -272,6 +290,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "AncestorIds",
schema: "library",
columns: table => new
{
ParentItemId = table.Column<Guid>(type: "uuid", nullable: false),
@@ -283,12 +302,14 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
table.ForeignKey(
name: "FK_AncestorIds_BaseItems_ItemId",
column: x => x.ItemId,
principalSchema: "library",
principalTable: "BaseItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AncestorIds_BaseItems_ParentItemId",
column: x => x.ParentItemId,
principalSchema: "library",
principalTable: "BaseItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
@@ -296,6 +317,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "AttachmentStreamInfos",
schema: "library",
columns: table => new
{
ItemId = table.Column<Guid>(type: "uuid", nullable: false),
@@ -312,6 +334,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
table.ForeignKey(
name: "FK_AttachmentStreamInfos_BaseItems_ItemId",
column: x => x.ItemId,
principalSchema: "library",
principalTable: "BaseItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
@@ -319,6 +342,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "BaseItemImageInfos",
schema: "library",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
@@ -336,6 +360,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
table.ForeignKey(
name: "FK_BaseItemImageInfos_BaseItems_ItemId",
column: x => x.ItemId,
principalSchema: "library",
principalTable: "BaseItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
@@ -343,6 +368,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "BaseItemMetadataFields",
schema: "library",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
@@ -354,6 +380,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
table.ForeignKey(
name: "FK_BaseItemMetadataFields_BaseItems_ItemId",
column: x => x.ItemId,
principalSchema: "library",
principalTable: "BaseItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
@@ -361,6 +388,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "BaseItemProviders",
schema: "library",
columns: table => new
{
ItemId = table.Column<Guid>(type: "uuid", nullable: false),
@@ -373,6 +401,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
table.ForeignKey(
name: "FK_BaseItemProviders_BaseItems_ItemId",
column: x => x.ItemId,
principalSchema: "library",
principalTable: "BaseItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
@@ -380,6 +409,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "BaseItemTrailerTypes",
schema: "library",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
@@ -391,6 +421,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
table.ForeignKey(
name: "FK_BaseItemTrailerTypes_BaseItems_ItemId",
column: x => x.ItemId,
principalSchema: "library",
principalTable: "BaseItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
@@ -398,6 +429,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "Chapters",
schema: "library",
columns: table => new
{
ItemId = table.Column<Guid>(type: "uuid", nullable: false),
@@ -413,6 +445,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
table.ForeignKey(
name: "FK_Chapters_BaseItems_ItemId",
column: x => x.ItemId,
principalSchema: "library",
principalTable: "BaseItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
@@ -420,6 +453,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "KeyframeData",
schema: "library",
columns: table => new
{
ItemId = table.Column<Guid>(type: "uuid", nullable: false),
@@ -432,6 +466,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
table.ForeignKey(
name: "FK_KeyframeData_BaseItems_ItemId",
column: x => x.ItemId,
principalSchema: "library",
principalTable: "BaseItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
@@ -439,6 +474,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "MediaStreamInfos",
schema: "library",
columns: table => new
{
ItemId = table.Column<Guid>(type: "uuid", nullable: false),
@@ -495,6 +531,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
table.ForeignKey(
name: "FK_MediaStreamInfos_BaseItems_ItemId",
column: x => x.ItemId,
principalSchema: "library",
principalTable: "BaseItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
@@ -502,6 +539,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "ItemValuesMap",
schema: "library",
columns: table => new
{
ItemId = table.Column<Guid>(type: "uuid", nullable: false),
@@ -513,12 +551,14 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
table.ForeignKey(
name: "FK_ItemValuesMap_BaseItems_ItemId",
column: x => x.ItemId,
principalSchema: "library",
principalTable: "BaseItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ItemValuesMap_ItemValues_ItemValueId",
column: x => x.ItemValueId,
principalSchema: "library",
principalTable: "ItemValues",
principalColumn: "ItemValueId",
onDelete: ReferentialAction.Cascade);
@@ -526,6 +566,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "PeopleBaseItemMap",
schema: "library",
columns: table => new
{
Role = table.Column<string>(type: "text", nullable: false),
@@ -540,12 +581,14 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
table.ForeignKey(
name: "FK_PeopleBaseItemMap_BaseItems_ItemId",
column: x => x.ItemId,
principalSchema: "library",
principalTable: "BaseItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PeopleBaseItemMap_Peoples_PeopleId",
column: x => x.PeopleId,
principalSchema: "library",
principalTable: "Peoples",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
@@ -553,6 +596,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "AccessSchedules",
schema: "users",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
@@ -568,6 +612,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
table.ForeignKey(
name: "FK_AccessSchedules_Users_UserId",
column: x => x.UserId,
principalSchema: "users",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
@@ -575,6 +620,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "Devices",
schema: "authentication",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
@@ -596,6 +642,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
table.ForeignKey(
name: "FK_Devices_Users_UserId",
column: x => x.UserId,
principalSchema: "users",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
@@ -603,6 +650,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "DisplayPreferences",
schema: "displaypreferences",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
@@ -627,6 +675,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
table.ForeignKey(
name: "FK_DisplayPreferences_Users_UserId",
column: x => x.UserId,
principalSchema: "users",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
@@ -634,6 +683,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "ImageInfos",
schema: "library",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
@@ -648,6 +698,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
table.ForeignKey(
name: "FK_ImageInfos_Users_UserId",
column: x => x.UserId,
principalSchema: "users",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
@@ -655,6 +706,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "ItemDisplayPreferences",
schema: "displaypreferences",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
@@ -675,6 +727,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
table.ForeignKey(
name: "FK_ItemDisplayPreferences_Users_UserId",
column: x => x.UserId,
principalSchema: "users",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
@@ -682,6 +735,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "Permissions",
schema: "users",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
@@ -698,6 +752,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
table.ForeignKey(
name: "FK_Permissions_Users_UserId",
column: x => x.UserId,
principalSchema: "users",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
@@ -705,6 +760,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "Preferences",
schema: "users",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
@@ -721,6 +777,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
table.ForeignKey(
name: "FK_Preferences_Users_UserId",
column: x => x.UserId,
principalSchema: "users",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
@@ -728,6 +785,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "UserData",
schema: "library",
columns: table => new
{
CustomDataKey = table.Column<string>(type: "text", nullable: false),
@@ -750,12 +808,14 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
table.ForeignKey(
name: "FK_UserData_BaseItems_ItemId",
column: x => x.ItemId,
principalSchema: "library",
principalTable: "BaseItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_UserData_Users_UserId",
column: x => x.UserId,
principalSchema: "users",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
@@ -763,6 +823,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateTable(
name: "HomeSection",
schema: "displaypreferences",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
@@ -777,234 +838,288 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
table.ForeignKey(
name: "FK_HomeSection_DisplayPreferences_DisplayPreferencesId",
column: x => x.DisplayPreferencesId,
principalSchema: "displaypreferences",
principalTable: "DisplayPreferences",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "BaseItems",
columns: new[] { "Id", "Album", "AlbumArtists", "Artists", "Audio", "ChannelId", "CleanName", "CommunityRating", "CriticRating", "CustomRating", "Data", "DateCreated", "DateLastMediaAdded", "DateLastRefreshed", "DateLastSaved", "DateModified", "EndDate", "EpisodeTitle", "ExternalId", "ExternalSeriesId", "ExternalServiceId", "ExtraIds", "ExtraType", "ForcedSortName", "Genres", "Height", "IndexNumber", "InheritedParentalRatingSubValue", "InheritedParentalRatingValue", "IsFolder", "IsInMixedFolder", "IsLocked", "IsMovie", "IsRepeat", "IsSeries", "IsVirtualItem", "LUFS", "MediaType", "Name", "NormalizationGain", "OfficialRating", "OriginalTitle", "Overview", "OwnerId", "ParentId", "ParentIndexNumber", "Path", "PreferredMetadataCountryCode", "PreferredMetadataLanguage", "PremiereDate", "PresentationUniqueKey", "PrimaryVersionId", "ProductionLocations", "ProductionYear", "RunTimeTicks", "SeasonId", "SeasonName", "SeriesId", "SeriesName", "SeriesPresentationUniqueKey", "ShowId", "Size", "SortName", "StartDate", "Studios", "Tagline", "Tags", "TopParentId", "TotalBitrate", "Type", "UnratedType", "Width" },
values: new object[] { new Guid("00000000-0000-0000-0000-000000000001"), null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, false, false, false, false, false, false, false, null, null, "This is a placeholder item for UserData that has been detacted from its original item", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, "PLACEHOLDER", null, null });
// Insert placeholder BaseItem for orphaned UserData
// Using raw SQL to avoid entity mapping issues with schema-qualified tables during migration
migrationBuilder.Sql(@"
INSERT INTO library.""BaseItems"" (
""Id"", ""Type"", ""Name"", ""IsFolder"", ""IsInMixedFolder"", ""IsLocked"", ""IsMovie"",
""IsRepeat"", ""IsSeries"", ""IsVirtualItem""
)
VALUES (
'00000000-0000-0000-0000-000000000001'::uuid,
'PLACEHOLDER',
'This is a placeholder item for UserData that has been detacted from its original item',
false, false, false, false, false, false, false
);
");
migrationBuilder.CreateIndex(
name: "IX_AccessSchedules_UserId",
schema: "users",
table: "AccessSchedules",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_ActivityLogs_DateCreated",
schema: "activitylog",
table: "ActivityLogs",
column: "DateCreated");
migrationBuilder.CreateIndex(
name: "IX_AncestorIds_ParentItemId",
schema: "library",
table: "AncestorIds",
column: "ParentItemId");
migrationBuilder.CreateIndex(
name: "IX_ApiKeys_AccessToken",
schema: "authentication",
table: "ApiKeys",
column: "AccessToken",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_BaseItemImageInfos_ItemId",
schema: "library",
table: "BaseItemImageInfos",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_BaseItemMetadataFields_ItemId",
schema: "library",
table: "BaseItemMetadataFields",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_BaseItemProviders_ProviderId_ProviderValue_ItemId",
schema: "library",
table: "BaseItemProviders",
columns: new[] { "ProviderId", "ProviderValue", "ItemId" });
migrationBuilder.CreateIndex(
name: "IX_BaseItems_Id_Type_IsFolder_IsVirtualItem",
schema: "library",
table: "BaseItems",
columns: new[] { "Id", "Type", "IsFolder", "IsVirtualItem" });
migrationBuilder.CreateIndex(
name: "IX_BaseItems_IsFolder_TopParentId_IsVirtualItem_PresentationUn~",
schema: "library",
table: "BaseItems",
columns: new[] { "IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated" });
migrationBuilder.CreateIndex(
name: "IX_BaseItems_MediaType_TopParentId_IsVirtualItem_PresentationU~",
schema: "library",
table: "BaseItems",
columns: new[] { "MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey" });
migrationBuilder.CreateIndex(
name: "IX_BaseItems_ParentId",
schema: "library",
table: "BaseItems",
column: "ParentId");
migrationBuilder.CreateIndex(
name: "IX_BaseItems_Path",
schema: "library",
table: "BaseItems",
column: "Path");
migrationBuilder.CreateIndex(
name: "IX_BaseItems_PresentationUniqueKey",
schema: "library",
table: "BaseItems",
column: "PresentationUniqueKey");
migrationBuilder.CreateIndex(
name: "IX_BaseItems_TopParentId_Id",
schema: "library",
table: "BaseItems",
columns: new[] { "TopParentId", "Id" });
migrationBuilder.CreateIndex(
name: "IX_BaseItems_Type_SeriesPresentationUniqueKey_IsFolder_IsVirtu~",
schema: "library",
table: "BaseItems",
columns: new[] { "Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem" });
migrationBuilder.CreateIndex(
name: "IX_BaseItems_Type_SeriesPresentationUniqueKey_PresentationUniq~",
schema: "library",
table: "BaseItems",
columns: new[] { "Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName" });
migrationBuilder.CreateIndex(
name: "IX_BaseItems_Type_TopParentId_Id",
schema: "library",
table: "BaseItems",
columns: new[] { "Type", "TopParentId", "Id" });
migrationBuilder.CreateIndex(
name: "IX_BaseItems_Type_TopParentId_IsVirtualItem_PresentationUnique~",
schema: "library",
table: "BaseItems",
columns: new[] { "Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated" });
migrationBuilder.CreateIndex(
name: "IX_BaseItems_Type_TopParentId_PresentationUniqueKey",
schema: "library",
table: "BaseItems",
columns: new[] { "Type", "TopParentId", "PresentationUniqueKey" });
migrationBuilder.CreateIndex(
name: "IX_BaseItems_Type_TopParentId_StartDate",
schema: "library",
table: "BaseItems",
columns: new[] { "Type", "TopParentId", "StartDate" });
migrationBuilder.CreateIndex(
name: "IX_BaseItemTrailerTypes_ItemId",
schema: "library",
table: "BaseItemTrailerTypes",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_CustomItemDisplayPreferences_UserId_ItemId_Client_Key",
schema: "displaypreferences",
table: "CustomItemDisplayPreferences",
columns: new[] { "UserId", "ItemId", "Client", "Key" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_DeviceOptions_DeviceId",
schema: "authentication",
table: "DeviceOptions",
column: "DeviceId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Devices_AccessToken_DateLastActivity",
schema: "authentication",
table: "Devices",
columns: new[] { "AccessToken", "DateLastActivity" });
migrationBuilder.CreateIndex(
name: "IX_Devices_DeviceId",
schema: "authentication",
table: "Devices",
column: "DeviceId");
migrationBuilder.CreateIndex(
name: "IX_Devices_DeviceId_DateLastActivity",
schema: "authentication",
table: "Devices",
columns: new[] { "DeviceId", "DateLastActivity" });
migrationBuilder.CreateIndex(
name: "IX_Devices_UserId_DeviceId",
schema: "authentication",
table: "Devices",
columns: new[] { "UserId", "DeviceId" });
migrationBuilder.CreateIndex(
name: "IX_DisplayPreferences_UserId_ItemId_Client",
schema: "displaypreferences",
table: "DisplayPreferences",
columns: new[] { "UserId", "ItemId", "Client" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_HomeSection_DisplayPreferencesId",
schema: "displaypreferences",
table: "HomeSection",
column: "DisplayPreferencesId");
migrationBuilder.CreateIndex(
name: "IX_ImageInfos_UserId",
schema: "library",
table: "ImageInfos",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ItemDisplayPreferences_UserId",
schema: "displaypreferences",
table: "ItemDisplayPreferences",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_ItemValues_Type_CleanValue",
schema: "library",
table: "ItemValues",
columns: new[] { "Type", "CleanValue" });
migrationBuilder.CreateIndex(
name: "IX_ItemValues_Type_Value",
schema: "library",
table: "ItemValues",
columns: new[] { "Type", "Value" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ItemValuesMap_ItemId",
schema: "library",
table: "ItemValuesMap",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_MediaStreamInfos_StreamIndex",
schema: "library",
table: "MediaStreamInfos",
column: "StreamIndex");
migrationBuilder.CreateIndex(
name: "IX_MediaStreamInfos_StreamIndex_StreamType",
schema: "library",
table: "MediaStreamInfos",
columns: new[] { "StreamIndex", "StreamType" });
migrationBuilder.CreateIndex(
name: "IX_MediaStreamInfos_StreamIndex_StreamType_Language",
schema: "library",
table: "MediaStreamInfos",
columns: new[] { "StreamIndex", "StreamType", "Language" });
migrationBuilder.CreateIndex(
name: "IX_MediaStreamInfos_StreamType",
schema: "library",
table: "MediaStreamInfos",
column: "StreamType");
migrationBuilder.CreateIndex(
name: "IX_PeopleBaseItemMap_ItemId_ListOrder",
schema: "library",
table: "PeopleBaseItemMap",
columns: new[] { "ItemId", "ListOrder" });
migrationBuilder.CreateIndex(
name: "IX_PeopleBaseItemMap_ItemId_SortOrder",
schema: "library",
table: "PeopleBaseItemMap",
columns: new[] { "ItemId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_PeopleBaseItemMap_PeopleId",
schema: "library",
table: "PeopleBaseItemMap",
column: "PeopleId");
migrationBuilder.CreateIndex(
name: "IX_Peoples_Name",
schema: "library",
table: "Peoples",
column: "Name");
migrationBuilder.CreateIndex(
name: "IX_Permissions_UserId_Kind",
schema: "users",
table: "Permissions",
columns: new[] { "UserId", "Kind" },
unique: true,
@@ -1012,6 +1127,7 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateIndex(
name: "IX_Preferences_UserId_Kind",
schema: "users",
table: "Preferences",
columns: new[] { "UserId", "Kind" },
unique: true,
@@ -1019,31 +1135,37 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
migrationBuilder.CreateIndex(
name: "IX_UserData_ItemId_UserId_IsFavorite",
schema: "library",
table: "UserData",
columns: new[] { "ItemId", "UserId", "IsFavorite" });
migrationBuilder.CreateIndex(
name: "IX_UserData_ItemId_UserId_LastPlayedDate",
schema: "library",
table: "UserData",
columns: new[] { "ItemId", "UserId", "LastPlayedDate" });
migrationBuilder.CreateIndex(
name: "IX_UserData_ItemId_UserId_PlaybackPositionTicks",
schema: "library",
table: "UserData",
columns: new[] { "ItemId", "UserId", "PlaybackPositionTicks" });
migrationBuilder.CreateIndex(
name: "IX_UserData_ItemId_UserId_Played",
schema: "library",
table: "UserData",
columns: new[] { "ItemId", "UserId", "Played" });
migrationBuilder.CreateIndex(
name: "IX_UserData_UserId",
schema: "library",
table: "UserData",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Users_Username",
schema: "users",
table: "Users",
column: "Username",
unique: true);
@@ -1053,94 +1175,124 @@ namespace Jellyfin.Database.Providers.Postgres.Migrations
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AccessSchedules");
name: "AccessSchedules",
schema: "users");
migrationBuilder.DropTable(
name: "ActivityLogs");
name: "ActivityLogs",
schema: "activitylog");
migrationBuilder.DropTable(
name: "AncestorIds");
name: "AncestorIds",
schema: "library");
migrationBuilder.DropTable(
name: "ApiKeys");
name: "ApiKeys",
schema: "authentication");
migrationBuilder.DropTable(
name: "AttachmentStreamInfos");
name: "AttachmentStreamInfos",
schema: "library");
migrationBuilder.DropTable(
name: "BaseItemImageInfos");
name: "BaseItemImageInfos",
schema: "library");
migrationBuilder.DropTable(
name: "BaseItemMetadataFields");
name: "BaseItemMetadataFields",
schema: "library");
migrationBuilder.DropTable(
name: "BaseItemProviders");
name: "BaseItemProviders",
schema: "library");
migrationBuilder.DropTable(
name: "BaseItemTrailerTypes");
name: "BaseItemTrailerTypes",
schema: "library");
migrationBuilder.DropTable(
name: "Chapters");
name: "Chapters",
schema: "library");
migrationBuilder.DropTable(
name: "CustomItemDisplayPreferences");
name: "CustomItemDisplayPreferences",
schema: "displaypreferences");
migrationBuilder.DropTable(
name: "DeviceOptions");
name: "DeviceOptions",
schema: "authentication");
migrationBuilder.DropTable(
name: "Devices");
name: "Devices",
schema: "authentication");
migrationBuilder.DropTable(
name: "HomeSection");
name: "HomeSection",
schema: "displaypreferences");
migrationBuilder.DropTable(
name: "ImageInfos");
name: "ImageInfos",
schema: "library");
migrationBuilder.DropTable(
name: "ItemDisplayPreferences");
name: "ItemDisplayPreferences",
schema: "displaypreferences");
migrationBuilder.DropTable(
name: "ItemValuesMap");
name: "ItemValuesMap",
schema: "library");
migrationBuilder.DropTable(
name: "KeyframeData");
name: "KeyframeData",
schema: "library");
migrationBuilder.DropTable(
name: "MediaSegments");
name: "MediaSegments",
schema: "library");
migrationBuilder.DropTable(
name: "MediaStreamInfos");
name: "MediaStreamInfos",
schema: "library");
migrationBuilder.DropTable(
name: "PeopleBaseItemMap");
name: "PeopleBaseItemMap",
schema: "library");
migrationBuilder.DropTable(
name: "Permissions");
name: "Permissions",
schema: "users");
migrationBuilder.DropTable(
name: "Preferences");
name: "Preferences",
schema: "users");
migrationBuilder.DropTable(
name: "TrickplayInfos");
name: "TrickplayInfos",
schema: "library");
migrationBuilder.DropTable(
name: "UserData");
name: "UserData",
schema: "library");
migrationBuilder.DropTable(
name: "DisplayPreferences");
name: "DisplayPreferences",
schema: "displaypreferences");
migrationBuilder.DropTable(
name: "ItemValues");
name: "ItemValues",
schema: "library");
migrationBuilder.DropTable(
name: "Peoples");
name: "Peoples",
schema: "library");
migrationBuilder.DropTable(
name: "BaseItems");
name: "BaseItems",
schema: "library");
migrationBuilder.DropTable(
name: "Users");
name: "Users",
schema: "users");
}
}
}
@@ -2,6 +2,8 @@
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
#pragma warning disable SA1201 // Elements should appear in the correct order
namespace Jellyfin.Database.Providers.Postgres;
using System;
@@ -12,6 +14,8 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Entities.Security;
using MediaBrowser.Common.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -37,6 +41,42 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
this.logger = logger;
}
/// <summary>
/// Schema names mapping to legacy database files.
/// </summary>
public static class Schemas
{
/// <summary>
/// Schema for activity log tables (legacy: activitylog.db).
/// </summary>
public const string ActivityLog = "activitylog";
/// <summary>
/// Schema for authentication tables (legacy: authentication.db).
/// </summary>
public const string Authentication = "authentication";
/// <summary>
/// Schema for display preferences tables (legacy: displaypreferences.db).
/// </summary>
public const string DisplayPreferences = "displaypreferences";
/// <summary>
/// Schema for library tables (legacy: library.db).
/// </summary>
public const string Library = "library";
/// <summary>
/// Schema for user tables (legacy: users.db).
/// </summary>
public const string Users = "users";
/// <summary>
/// Gets all schema names.
/// </summary>
public static string[] All => [ActivityLog, Authentication, DisplayPreferences, Library, Users];
}
/// <inheritdoc/>
public IDbContextFactory<JellyfinDbContext>? DbContextFactory { get; set; }
@@ -70,19 +110,24 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
Password = GetOption(customOptions, "password", e => e, () => string.Empty)!,
Pooling = GetOption(customOptions, "pooling", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), () => true),
CommandTimeout = GetOption(customOptions, "command-timeout", int.Parse, () => 30),
Timeout = GetOption(customOptions, "connection-timeout", int.Parse, () => 15)
Timeout = GetOption(customOptions, "connection-timeout", int.Parse, () => 15),
Multiplexing = GetOption(customOptions, "multiplexing", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), () => false),
MaxPoolSize = GetOption(customOptions, "max-pool-size", int.Parse, () => 100),
MinPoolSize = GetOption(customOptions, "min-pool-size", int.Parse, () => 0)
};
var connectionString = connectionBuilder.ToString();
// Log PostgreSQL connection parameters (without password)
logger.LogInformation(
"PostgreSQL connection: Host={Host}, Port={Port}, Database={Database}, Username={Username}, Pooling={Pooling}",
"PostgreSQL connection: Host={Host}, Port={Port}, Database={Database}, Username={Username}, Pooling={Pooling}, MaxPoolSize={MaxPoolSize}, Multiplexing={Multiplexing}",
connectionBuilder.Host,
connectionBuilder.Port,
connectionBuilder.Database,
connectionBuilder.Username,
connectionBuilder.Pooling);
connectionBuilder.Pooling,
connectionBuilder.MaxPoolSize,
connectionBuilder.Multiplexing);
options
.UseNpgsql(
@@ -177,6 +222,9 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
{
logger.LogInformation("PostgreSQL database '{Database}' already exists", database);
}
// Now ensure all schemas exist in the database
await EnsureSchemasExistAsync(host, port, database, username, password, timeout, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -185,14 +233,85 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
}
}
/// <summary>
/// Ensures all required schemas exist in the PostgreSQL database.
/// </summary>
private async Task EnsureSchemasExistAsync(
string host,
int port,
string database,
string username,
string password,
int timeout,
CancellationToken cancellationToken)
{
var connectionBuilder = new NpgsqlConnectionStringBuilder
{
Host = host,
Port = port,
Database = database,
Username = username,
Password = password,
Timeout = timeout,
Pooling = false
};
try
{
await using var connection = new NpgsqlConnection(connectionBuilder.ToString());
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
foreach (var schema in Schemas.All)
{
// Check if schema exists
var checkQuery = "SELECT 1 FROM information_schema.schemata WHERE schema_name = @schemaName";
await using var checkCommand = new NpgsqlCommand(checkQuery, connection);
checkCommand.Parameters.AddWithValue("schemaName", schema);
var exists = await checkCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
if (exists is null)
{
// Schema doesn't exist, create it
logger.LogInformation("Creating PostgreSQL schema '{Schema}'...", schema);
var createQuery = $"CREATE SCHEMA \"{schema}\" AUTHORIZATION \"{username}\"";
await using var createCommand = new NpgsqlCommand(createQuery, connection);
await createCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
logger.LogInformation("Schema '{Schema}' created successfully", schema);
}
else
{
logger.LogDebug("Schema '{Schema}' already exists", schema);
}
}
logger.LogInformation("All required PostgreSQL schemas are ready");
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to ensure PostgreSQL schemas exist. Error: {Message}", ex.Message);
throw;
}
}
/// <inheritdoc/>
public async Task RunScheduledOptimisation(CancellationToken cancellationToken)
{
var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
// Run PostgreSQL optimization commands
await context.Database.ExecuteSqlRawAsync("VACUUM ANALYZE", cancellationToken).ConfigureAwait(false);
// Run PostgreSQL optimization commands on all schemas
// Note: VACUUM cannot be parameterized, but schema names are from const strings, not user input
foreach (var schema in Schemas.All)
{
#pragma warning disable EF1002 // Schema names are internal constants, not user input
await context.Database.ExecuteSqlRawAsync($"VACUUM ANALYZE \"{schema}\"", cancellationToken).ConfigureAwait(false);
#pragma warning restore EF1002
logger.LogDebug("Optimized PostgreSQL schema '{Schema}'", schema);
}
logger.LogInformation("PostgreSQL database optimized successfully!");
}
}
@@ -201,6 +320,56 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
public void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.SetDefaultDateTimeKind(DateTimeKind.Utc);
// Assign entities to schemas based on their legacy database origin
AssignEntitiesToSchemas(modelBuilder);
}
/// <summary>
/// Assigns entities to PostgreSQL schemas based on their legacy database origin.
/// </summary>
/// <param name="modelBuilder">The model builder.</param>
private static void AssignEntitiesToSchemas(ModelBuilder modelBuilder)
{
// ActivityLog schema (legacy: activitylog.db)
modelBuilder.Entity<ActivityLog>().ToTable("ActivityLogs", Schemas.ActivityLog);
// Authentication schema (legacy: authentication.db)
modelBuilder.Entity<ApiKey>().ToTable("ApiKeys", Schemas.Authentication);
modelBuilder.Entity<Device>().ToTable("Devices", Schemas.Authentication);
modelBuilder.Entity<DeviceOptions>().ToTable("DeviceOptions", Schemas.Authentication);
// DisplayPreferences schema (legacy: displaypreferences.db)
modelBuilder.Entity<DisplayPreferences>().ToTable("DisplayPreferences", Schemas.DisplayPreferences);
modelBuilder.Entity<ItemDisplayPreferences>().ToTable("ItemDisplayPreferences", Schemas.DisplayPreferences);
modelBuilder.Entity<CustomItemDisplayPreferences>().ToTable("CustomItemDisplayPreferences", Schemas.DisplayPreferences);
modelBuilder.Entity<HomeSection>().ToTable("HomeSections", Schemas.DisplayPreferences);
// Users schema (legacy: users.db)
modelBuilder.Entity<User>().ToTable("Users", Schemas.Users);
modelBuilder.Entity<Permission>().ToTable("Permissions", Schemas.Users);
modelBuilder.Entity<Preference>().ToTable("Preferences", Schemas.Users);
modelBuilder.Entity<AccessSchedule>().ToTable("AccessSchedules", Schemas.Users);
// Library schema (legacy: library.db) - All remaining entities
modelBuilder.Entity<BaseItemEntity>().ToTable("BaseItems", Schemas.Library);
modelBuilder.Entity<Chapter>().ToTable("Chapters", Schemas.Library);
modelBuilder.Entity<MediaStreamInfo>().ToTable("MediaStreamInfos", Schemas.Library);
modelBuilder.Entity<AttachmentStreamInfo>().ToTable("AttachmentStreamInfos", Schemas.Library);
modelBuilder.Entity<ImageInfo>().ToTable("ImageInfos", Schemas.Library);
modelBuilder.Entity<BaseItemImageInfo>().ToTable("BaseItemImageInfos", Schemas.Library);
modelBuilder.Entity<BaseItemProvider>().ToTable("BaseItemProviders", Schemas.Library);
modelBuilder.Entity<BaseItemMetadataField>().ToTable("BaseItemMetadataFields", Schemas.Library);
modelBuilder.Entity<BaseItemTrailerType>().ToTable("BaseItemTrailerTypes", Schemas.Library);
modelBuilder.Entity<ItemValue>().ToTable("ItemValues", Schemas.Library);
modelBuilder.Entity<ItemValueMap>().ToTable("ItemValuesMap", Schemas.Library);
modelBuilder.Entity<People>().ToTable("Peoples", Schemas.Library);
modelBuilder.Entity<PeopleBaseItemMap>().ToTable("PeopleBaseItemMap", Schemas.Library);
modelBuilder.Entity<UserData>().ToTable("UserData", Schemas.Library);
modelBuilder.Entity<AncestorId>().ToTable("AncestorIds", Schemas.Library);
modelBuilder.Entity<TrickplayInfo>().ToTable("TrickplayInfos", Schemas.Library);
modelBuilder.Entity<MediaSegment>().ToTable("MediaSegments", Schemas.Library);
modelBuilder.Entity<KeyframeData>().ToTable("KeyframeData", Schemas.Library);
}
/// <inheritdoc/>
@@ -255,7 +424,20 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
var deleteQueries = new List<string>();
foreach (var tableName in tableNames)
{
deleteQueries.Add($"TRUNCATE TABLE \"{tableName}\" CASCADE;");
// Find the schema for this table
var entityType = dbContext.Model.GetEntityTypes()
.FirstOrDefault(et => et.GetTableName() == tableName);
if (entityType is not null)
{
var schema = entityType.GetSchema() ?? "public";
deleteQueries.Add($"TRUNCATE TABLE \"{schema}\".\"{tableName}\" CASCADE;");
}
else
{
// Fallback to public schema if entity not found
deleteQueries.Add($"TRUNCATE TABLE \"public\".\"{tableName}\" CASCADE;");
}
}
var deleteAllQuery = string.Join('\n', deleteQueries);
@@ -26,6 +26,8 @@ To use PostgreSQL as the database backend, configure the following options in yo
{ "Key": "username", "Value": "jellyfin" },
{ "Key": "password", "Value": "your_secure_password" },
{ "Key": "pooling", "Value": "true" },
{ "Key": "max-pool-size", "Value": "100" },
{ "Key": "min-pool-size", "Value": "0" },
{ "Key": "command-timeout", "Value": "30" },
{ "Key": "connection-timeout", "Value": "15" }
]
@@ -83,19 +85,46 @@ psql -U jellyfin -h localhost jellyfin < jellyfin_backup.sql
| username | jellyfin | Database username |
| password | (empty) | Database password |
| pooling | true | Enable connection pooling |
| max-pool-size | 100 | Maximum number of connections in the pool |
| min-pool-size | 0 | Minimum number of connections in the pool |
| command-timeout | 30 | Command timeout in seconds |
| connection-timeout | 15 | Connection timeout in seconds |
| multiplexing | false | ⚠️ **Advanced**: Enable command multiplexing (requires all async operations) |
| EnableSensitiveDataLogging | false | Enable sensitive data logging (for debugging) |
### ⚠️ Multiplexing Warning
**Multiplexing is disabled by default** because it requires all database operations to be asynchronous. Enabling multiplexing will cause errors like:
```
System.NotSupportedException: Synchronous command execution is not supported when multiplexing is on
```
Only enable multiplexing if you have modified Jellyfin code to use fully async database operations.
## Performance Tuning
For better performance, consider:
1. **Indexes**: The provider will create necessary indexes through migrations
2. **Connection Pooling**: Enabled by default
2. **Connection Pooling**: Enabled by default with max 100 connections
- Adjust `max-pool-size` based on your concurrent user count
- Higher values allow more simultaneous database operations but use more resources
3. **Vacuum**: Scheduled optimization runs `VACUUM ANALYZE` periodically
4. **PostgreSQL Configuration**: Adjust `shared_buffers`, `effective_cache_size`, etc. in postgresql.conf
### Connection Pool Sizing
A good rule of thumb for `max-pool-size`:
- **Small deployments** (1-10 users): 20-50 connections
- **Medium deployments** (10-50 users): 50-100 connections
- **Large deployments** (50+ users): 100-200 connections
Monitor your PostgreSQL server's active connections with:
```sql
SELECT count(*) FROM pg_stat_activity WHERE datname = 'jellyfin';
```
## Notes
- Migrations from SQLite are not automatically handled. You'll need to export data from SQLite and import into PostgreSQL manually.
@@ -46,7 +46,14 @@ public class CacheDecorator : IKeyframeExtractor
/// <inheritdoc />
public bool TryExtractKeyframes(Guid itemId, string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData)
{
keyframeData = _keyframeRepository.GetKeyframeData(itemId).FirstOrDefault();
// Note: This method is synchronous by interface design, but repository is async.
// Using GetAwaiter().GetResult() here is acceptable as this is called during media scanning
// which is not performance-critical. Consider making IKeyframeExtractor async in future.
keyframeData = _keyframeRepository.GetKeyframeDataAsync(itemId, CancellationToken.None)
.GetAwaiter()
.GetResult()
.FirstOrDefault();
if (keyframeData is null)
{
if (!_keyframeExtractor.TryExtractKeyframes(itemId, filePath, out var result))
@@ -57,7 +64,9 @@ public class CacheDecorator : IKeyframeExtractor
_logger.LogDebug("Successfully extracted keyframes using {ExtractorName}", _keyframeExtractorName);
keyframeData = result;
_keyframeRepository.SaveKeyframeDataAsync(itemId, keyframeData, CancellationToken.None).GetAwaiter().GetResult();
_keyframeRepository.SaveKeyframeDataAsync(itemId, keyframeData, CancellationToken.None)
.GetAwaiter()
.GetResult();
}
return true;