aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs9
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113203012_ChangeOwnerIdToGuid.cs55
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs81
3 files changed, 95 insertions, 50 deletions
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
index 0a72287ba9..6efced6556 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
@@ -45,6 +45,15 @@ public interface IJellyfinDatabaseProvider
Task RunScheduledOptimisation(CancellationToken cancellationToken);
/// <summary>
+ /// If supported this should refresh the query planner statistics, e.g. after a library scan changed the data.
+ /// Unlike <see cref="RunScheduledOptimisation(CancellationToken)"/> it should not reclaim space, so that it stays
+ /// cheap enough to run after every scan.
+ /// </summary>
+ /// <param name="cancellationToken">The token to abort the operation.</param>
+ /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+ Task RefreshStatistics(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ /// <summary>
/// If supported this should perform any actions that are required on stopping the jellyfin server. This runs
/// against a deadline imposed by the service manager, so unlike
/// <see cref="RunScheduledOptimisation(CancellationToken)"/> it should only do work whose cost does not grow with
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113203012_ChangeOwnerIdToGuid.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113203012_ChangeOwnerIdToGuid.cs
index 379da0e9be..8c03347736 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113203012_ChangeOwnerIdToGuid.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113203012_ChangeOwnerIdToGuid.cs
@@ -33,12 +33,24 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations
-- deletes an item: reattach it to the placeholder item instead of letting the
-- FK_UserData_BaseItems_ItemId cascade wipe it. The placeholder can only hold one row
-- per (UserId, CustomDataKey), so resolve collisions before repointing anything.
+ DROP TABLE IF EXISTS "DoomedUserDataKeys";
+ CREATE TEMPORARY TABLE "DoomedUserDataKeys" (
+ "UserId" TEXT NOT NULL,
+ "CustomDataKey" TEXT NOT NULL,
+ PRIMARY KEY ("UserId", "CustomDataKey"));
+
+ -- Collect the colliding keys up front: correlating against "UserData" directly makes
+ -- the delete below re-scan every row of that user once per placeholder row.
+ INSERT OR IGNORE INTO "DoomedUserDataKeys" ("UserId", "CustomDataKey")
+ SELECT Doomed."UserId", Doomed."CustomDataKey"
+ FROM "UserData" AS Doomed
+ INNER JOIN "OrphanedBaseItemIds" AS Orphan ON Orphan."Id" = Doomed."ItemId";
+
DELETE FROM "UserData"
WHERE "ItemId" = '00000000-0000-0000-0000-000000000001'
AND EXISTS (
SELECT 1
- FROM "UserData" AS Doomed
- INNER JOIN "OrphanedBaseItemIds" AS Orphan ON Orphan."Id" = Doomed."ItemId"
+ FROM "DoomedUserDataKeys" AS Doomed
WHERE Doomed."UserId" = "UserData"."UserId"
AND Doomed."CustomDataKey" = "UserData"."CustomDataKey");
@@ -63,6 +75,7 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations
DELETE FROM "BaseItems" WHERE "Id" IN (SELECT "Id" FROM "OrphanedBaseItemIds");
+ DROP TABLE "DoomedUserDataKeys";
DROP TABLE "OrphanedBaseItemIds";
""");
@@ -106,41 +119,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations
columns: new[] { "BaseItemEntityId", "Name", "OwnerId" },
values: new object[] { null, "This is a placeholder item for UserData that has been detached from its original item", null });
- migrationBuilder.CreateIndex(
- name: "IX_BaseItems_BaseItemEntityId",
- table: "BaseItems",
- column: "BaseItemEntityId");
-
- migrationBuilder.CreateIndex(
- name: "IX_BaseItems_ExtraType",
- table: "BaseItems",
- column: "ExtraType");
-
- migrationBuilder.CreateIndex(
- name: "IX_BaseItems_ExtraType_OwnerId",
- table: "BaseItems",
- columns: new[] { "ExtraType", "OwnerId" });
-
- migrationBuilder.CreateIndex(
- name: "IX_BaseItems_OwnerId",
- table: "BaseItems",
- column: "OwnerId");
-
- migrationBuilder.CreateIndex(
- name: "IX_BaseItems_TopParentId_IsFolder_IsVirtualItem_DateCreated",
- table: "BaseItems",
- columns: new[] { "TopParentId", "IsFolder", "IsVirtualItem", "DateCreated" });
-
- migrationBuilder.CreateIndex(
- name: "IX_BaseItems_TopParentId_MediaType_IsVirtualItem_DateCreated",
- table: "BaseItems",
- columns: new[] { "TopParentId", "MediaType", "IsVirtualItem", "DateCreated" });
-
- migrationBuilder.CreateIndex(
- name: "IX_BaseItems_TopParentId_Type_IsVirtualItem_DateCreated",
- table: "BaseItems",
- columns: new[] { "TopParentId", "Type", "IsVirtualItem", "DateCreated" });
-
+ // No CreateIndex calls here on purpose: AddForeignKey rebuilds BaseItems on SQLite and
+ // recreates every index of the target model afterwards, so building them first only
+ // pays for a full index pass that the rebuild immediately throws away.
migrationBuilder.AddForeignKey(
name: "FK_BaseItems_BaseItems_BaseItemEntityId",
table: "BaseItems",
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
index 3330b64b69..a3fbe42e02 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
@@ -110,6 +110,35 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
}
/// <inheritdoc/>
+ public async Task RefreshStatistics(CancellationToken cancellationToken)
+ {
+ if (DbContextFactory is null)
+ {
+ return;
+ }
+
+ var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (context.ConfigureAwait(false))
+ {
+ await context.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (!await HasLibraryItemsAsync(context, cancellationToken).ConfigureAwait(false))
+ {
+ return;
+ }
+
+ _logger.LogInformation("Analyzing jellyfin.db");
+ await AnalyzeAsync(context, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ await context.Database.CloseConnectionAsync().ConfigureAwait(false);
+ }
+ }
+ }
+
+ /// <inheritdoc/>
public void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.SetDefaultDateTimeKind(DateTimeKind.Utc);
@@ -161,14 +190,11 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
try
{
long? tempStore;
- long? analysisLimit;
var pragmaCommand = context.Database.GetDbConnection().CreateCommand();
await using (pragmaCommand.ConfigureAwait(false))
{
pragmaCommand.CommandText = "PRAGMA temp_store";
tempStore = await ReadPragmaValueAsync(pragmaCommand, cancellationToken).ConfigureAwait(false);
- pragmaCommand.CommandText = "PRAGMA analysis_limit";
- analysisLimit = await ReadPragmaValueAsync(pragmaCommand, cancellationToken).ConfigureAwait(false);
}
await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
@@ -192,19 +218,15 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
}
}
- await context.Database.ExecuteSqlRawAsync("PRAGMA analysis_limit=0", cancellationToken).ConfigureAwait(false);
- try
+ // Statistics taken while the library is empty make the planner treat every table as one row and
+ // pick full scans once it fills up; no statistics at all plan far better until there is data.
+ if (await HasLibraryItemsAsync(context, cancellationToken).ConfigureAwait(false))
{
- await context.Database.ExecuteSqlRawAsync("ANALYZE", cancellationToken).ConfigureAwait(false);
+ await AnalyzeAsync(context, cancellationToken).ConfigureAwait(false);
}
- finally
+ else
{
- if (analysisLimit is not null)
- {
- await context.Database.ExecuteSqlRawAsync(
- FormattableString.Invariant($"PRAGMA analysis_limit={analysisLimit.Value}"),
- CancellationToken.None).ConfigureAwait(false);
- }
+ _logger.LogInformation("Not analyzing jellyfin.db, the library holds no items yet");
}
await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
@@ -217,6 +239,39 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
}
}
+ private static Task<bool> HasLibraryItemsAsync(JellyfinDbContext context, CancellationToken cancellationToken)
+ {
+ // Folders and the seeded placeholder exist before any library has been scanned.
+ return context.BaseItems.AnyAsync(e => !e.IsFolder && e.Type != "PLACEHOLDER", cancellationToken);
+ }
+
+ private static async Task AnalyzeAsync(JellyfinDbContext context, CancellationToken cancellationToken)
+ {
+ long? analysisLimit;
+ var pragmaCommand = context.Database.GetDbConnection().CreateCommand();
+ await using (pragmaCommand.ConfigureAwait(false))
+ {
+ pragmaCommand.CommandText = "PRAGMA analysis_limit";
+ analysisLimit = await ReadPragmaValueAsync(pragmaCommand, cancellationToken).ConfigureAwait(false);
+ }
+
+ await context.Database.ExecuteSqlRawAsync("PRAGMA analysis_limit=0", cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await context.Database.ExecuteSqlRawAsync("ANALYZE", cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ // The connection goes back to the pool, so hand it over the way it was handed to us.
+ if (analysisLimit is not null)
+ {
+ await context.Database.ExecuteSqlRawAsync(
+ FormattableString.Invariant($"PRAGMA analysis_limit={analysisLimit.Value}"),
+ CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+ }
+
private static async Task<long?> ReadPragmaValueAsync(DbCommand command, CancellationToken cancellationToken)
{
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);