Refactor database configuration and migrations for PostgreSQL support

- Updated default database configuration to use PostgreSQL instead of SQLite when no database.xml exists.
- Removed obsolete migration routines: MigrateUserDb, RemoveDuplicateExtras, ReseedFolderFlag.
- Introduced new read-only view projections for PostgreSQL: ItemPersonView, ItemProviderView, LibrarySummaryView, MediaStreamInfoView, UserPlaybackHistoryView, VideoItemView.
- Registered view mappings in PostgresDatabaseProvider to ensure proper handling of read-only views.
- Cleaned up unused SQLite cache size configuration in ConfigurationExtensions.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-30 18:47:48 -04:00
parent 9fc10226c9
commit e1f7a4bee9
15 changed files with 637 additions and 429 deletions
@@ -18,7 +18,6 @@ namespace Emby.Server.Implementations
{ FfmpegProbeSizeKey, "1G" },
{ FfmpegAnalyzeDurationKey, "200M" },
{ BindToUnixSocketKey, bool.FalseString },
{ SqliteCacheSizeKey, "20000" },
{ FfmpegSkipValidationKey, bool.FalseString },
{ FfmpegImgExtractPerfTradeoffKey, bool.FalseString },
{ DetectNetworkChangeKey, bool.TrueString }
@@ -0,0 +1,302 @@
// <copyright file="DatabaseViewsController.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Api.Controllers;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities.Views;
using MediaBrowser.Common.Api;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Exposes the PostgreSQL reporting views as read-only API endpoints.
/// All endpoints require administrator privileges.
/// </summary>
[Route("DatabaseViews")]
[Authorize(Policy = Policies.RequiresElevation)]
public class DatabaseViewsController : BaseJellyfinApiController
{
private readonly IDbContextFactory<JellyfinDbContext> _dbFactory;
/// <summary>
/// Initializes a new instance of the <see cref="DatabaseViewsController"/> class.
/// </summary>
/// <param name="dbFactory">The EF Core context factory.</param>
public DatabaseViewsController(IDbContextFactory<JellyfinDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
/// <summary>
/// Gets a flat list of all media streams (audio + video columns merged).
/// Maps to library."MediaStreamInfos_v".
/// </summary>
/// <param name="itemId">Optional item ID filter.</param>
/// <param name="streamType">Optional stream type filter (0=Audio, 1=Video, 2=Subtitle).</param>
/// <param name="startIndex">Record index to start at.</param>
/// <param name="limit">Maximum records to return (1-1000, default 100).</param>
/// <returns>List of media stream rows.</returns>
[HttpGet("MediaStreamInfos")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<MediaStreamInfoView>>> GetMediaStreamInfos(
[FromQuery] Guid? itemId = null,
[FromQuery] int? streamType = null,
[FromQuery] int startIndex = 0,
[FromQuery] int limit = 100)
{
limit = Math.Clamp(limit, 1, 1000);
await using var db = await _dbFactory.CreateDbContextAsync().ConfigureAwait(false);
var query = db.MediaStreamInfoViews.AsNoTracking();
if (itemId.HasValue)
{
var id = itemId.Value;
query = query.Where(x => x.ItemId.HasValue && x.ItemId.Value.Equals(id));
}
if (streamType.HasValue)
{
query = query.Where(x => x.StreamType == streamType.Value);
}
IEnumerable<MediaStreamInfoView> results = await query
.OrderBy(x => x.ItemId)
.ThenBy(x => x.StreamIndex)
.Skip(startIndex)
.Take(limit)
.ToListAsync()
.ConfigureAwait(false);
return Ok(results);
}
/// <summary>
/// Gets video items joined with primary stream technical details.
/// Maps to library."VideoItems_v".
/// </summary>
/// <param name="type">Optional partial type filter (e.g. "Movie", "Episode").</param>
/// <param name="minHeight">Optional minimum video height (e.g. 2160 for 4K).</param>
/// <param name="hdrFormat">Optional HDR format ("Dolby Vision", "HDR10+", "HDR10", "HLG", "SDR").</param>
/// <param name="startIndex">Record index to start at.</param>
/// <param name="limit">Maximum records to return (1-1000, default 100).</param>
/// <returns>List of video item rows.</returns>
[HttpGet("VideoItems")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<VideoItemView>>> GetVideoItems(
[FromQuery] string? type = null,
[FromQuery] int? minHeight = null,
[FromQuery] string? hdrFormat = null,
[FromQuery] int startIndex = 0,
[FromQuery] int limit = 100)
{
limit = Math.Clamp(limit, 1, 1000);
await using var db = await _dbFactory.CreateDbContextAsync().ConfigureAwait(false);
var query = db.VideoItemViews.AsNoTracking();
if (!string.IsNullOrEmpty(type))
{
query = query.Where(x => x.Type != null && x.Type.Contains(type));
}
if (minHeight.HasValue)
{
query = query.Where(x => x.Height >= minHeight.Value);
}
if (!string.IsNullOrEmpty(hdrFormat))
{
query = query.Where(x => x.HDRFormat == hdrFormat);
}
IEnumerable<VideoItemView> results = await query
.OrderBy(x => x.Name)
.Skip(startIndex)
.Take(limit)
.ToListAsync()
.ConfigureAwait(false);
return Ok(results);
}
/// <summary>
/// Gets per-user playback history with calculated progress percentages.
/// Maps to library."UserPlaybackHistory_v".
/// </summary>
/// <param name="username">Optional exact username filter.</param>
/// <param name="itemId">Optional item ID filter.</param>
/// <param name="inProgressOnly">When true, returns only items started but not fully played.</param>
/// <param name="startIndex">Record index to start at.</param>
/// <param name="limit">Maximum records to return (1-1000, default 100).</param>
/// <returns>List of playback history rows.</returns>
[HttpGet("PlaybackHistory")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<UserPlaybackHistoryView>>> GetPlaybackHistory(
[FromQuery] string? username = null,
[FromQuery] Guid? itemId = null,
[FromQuery] bool inProgressOnly = false,
[FromQuery] int startIndex = 0,
[FromQuery] int limit = 100)
{
limit = Math.Clamp(limit, 1, 1000);
await using var db = await _dbFactory.CreateDbContextAsync().ConfigureAwait(false);
var query = db.UserPlaybackHistoryViews.AsNoTracking();
if (!string.IsNullOrEmpty(username))
{
query = query.Where(x => x.Username == username);
}
if (itemId.HasValue)
{
var id = itemId.Value;
query = query.Where(x => x.ItemId.HasValue && x.ItemId.Value.Equals(id));
}
if (inProgressOnly)
{
query = query.Where(x => x.Played != true && x.ProgressPct > 0);
}
IEnumerable<UserPlaybackHistoryView> results = await query
.OrderByDescending(x => x.LastPlayedDate)
.Skip(startIndex)
.Take(limit)
.ToListAsync()
.ConfigureAwait(false);
return Ok(results);
}
/// <summary>
/// Gets items with their external provider IDs (IMDb, TMDB, TVDB, etc.) in named columns.
/// Maps to library."ItemProviders_v".
/// </summary>
/// <param name="type">Optional partial type filter.</param>
/// <param name="imdbId">Optional exact IMDb ID filter.</param>
/// <param name="tmdbId">Optional exact TMDB ID filter.</param>
/// <param name="tvdbId">Optional exact TVDB ID filter.</param>
/// <param name="startIndex">Record index to start at.</param>
/// <param name="limit">Maximum records to return (1-1000, default 100).</param>
/// <returns>List of item provider rows.</returns>
[HttpGet("ItemProviders")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<ItemProviderView>>> GetItemProviders(
[FromQuery] string? type = null,
[FromQuery] string? imdbId = null,
[FromQuery] string? tmdbId = null,
[FromQuery] string? tvdbId = null,
[FromQuery] int startIndex = 0,
[FromQuery] int limit = 100)
{
limit = Math.Clamp(limit, 1, 1000);
await using var db = await _dbFactory.CreateDbContextAsync().ConfigureAwait(false);
var query = db.ItemProviderViews.AsNoTracking();
if (!string.IsNullOrEmpty(type))
{
query = query.Where(x => x.Type != null && x.Type.Contains(type));
}
if (!string.IsNullOrEmpty(imdbId))
{
query = query.Where(x => x.ImdbId == imdbId);
}
if (!string.IsNullOrEmpty(tmdbId))
{
query = query.Where(x => x.TmdbId == tmdbId);
}
if (!string.IsNullOrEmpty(tvdbId))
{
query = query.Where(x => x.TvdbId == tvdbId);
}
IEnumerable<ItemProviderView> results = await query
.OrderBy(x => x.Name)
.Skip(startIndex)
.Take(limit)
.ToListAsync()
.ConfigureAwait(false);
return Ok(results);
}
/// <summary>
/// Gets cast and crew entries linked to the items they appear in.
/// Maps to library."ItemPeople_v".
/// </summary>
/// <param name="personName">Optional exact person name filter.</param>
/// <param name="personType">Optional person type filter (e.g. "Actor", "Director").</param>
/// <param name="itemId">Optional item ID filter.</param>
/// <param name="startIndex">Record index to start at.</param>
/// <param name="limit">Maximum records to return (1-1000, default 100).</param>
/// <returns>List of item person rows.</returns>
[HttpGet("ItemPeople")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<ItemPersonView>>> GetItemPeople(
[FromQuery] string? personName = null,
[FromQuery] string? personType = null,
[FromQuery] Guid? itemId = null,
[FromQuery] int startIndex = 0,
[FromQuery] int limit = 100)
{
limit = Math.Clamp(limit, 1, 1000);
await using var db = await _dbFactory.CreateDbContextAsync().ConfigureAwait(false);
var query = db.ItemPersonViews.AsNoTracking();
if (!string.IsNullOrEmpty(personName))
{
query = query.Where(x => x.PersonName == personName);
}
if (!string.IsNullOrEmpty(personType))
{
query = query.Where(x => x.PersonType == personType);
}
if (itemId.HasValue)
{
var id = itemId.Value;
query = query.Where(x => x.ItemId.HasValue && x.ItemId.Value.Equals(id));
}
IEnumerable<ItemPersonView> results = await query
.OrderBy(x => x.PersonName)
.ThenBy(x => x.SortOrder)
.Skip(startIndex)
.Take(limit)
.ToListAsync()
.ConfigureAwait(false);
return Ok(results);
}
/// <summary>
/// Gets aggregate library statistics - one row per item type.
/// Maps to library."LibrarySummary_v".
/// </summary>
/// <returns>List of library summary rows, one per item type.</returns>
[HttpGet("LibrarySummary")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<LibrarySummaryView>>> GetLibrarySummary()
{
await using var db = await _dbFactory.CreateDbContextAsync().ConfigureAwait(false);
IEnumerable<LibrarySummaryView> results = await db.LibrarySummaryViews
.AsNoTracking()
.OrderBy(x => x.Type)
.ToListAsync()
.ConfigureAwait(false);
return Ok(results);
}
}
@@ -99,23 +99,13 @@ public static class ServiceCollectionExtensions
}
else
{
// when nothing is setup via new Database configuration, fallback to SQLite with default settings.
// PostgreSQL-only build: default to PostgreSQL when no database.xml exists.
efCoreConfiguration = new DatabaseConfigurationOptions()
{
DatabaseType = "Jellyfin-SQLite",
DatabaseType = "Jellyfin-PostgreSQL",
LockingBehavior = DatabaseLockingBehaviorTypes.NoLock,
PresetConfigurations =
[
new PresetConfigurationItem
{
Key = "SQLite",
Value = new PresetDatabaseConfiguration
{
DatabaseType = "Jellyfin-SQLite",
LockingBehavior = DatabaseLockingBehaviorTypes.NoLock,
Description = "Default SQLite database configuration. Database stored in data directory."
}
},
new PresetConfigurationItem
{
Key = "PostgreSQL-Local",
@@ -135,7 +125,7 @@ public static class ServiceCollectionExtensions
// Ensure DatabaseType is set (handle corrupted or empty configuration files)
if (string.IsNullOrWhiteSpace(efCoreConfiguration.DatabaseType))
{
efCoreConfiguration.DatabaseType = "Jellyfin-SQLite";
efCoreConfiguration.DatabaseType = "Jellyfin-PostgreSQL";
configurationManager.SaveConfiguration("database", efCoreConfiguration);
}
@@ -1,237 +0,0 @@
// <copyright file="MigrateUserDb.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Server.Migrations.Routines;
using System;
using System.IO;
using Emby.Server.Implementations.Data;
using Jellyfin.Data;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions.Json;
using Jellyfin.Server.Implementations.Users;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.Users;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using JsonSerializer = System.Text.Json.JsonSerializer;
/// <summary>
/// The migration routine for migrating the user database to EF Core.
/// </summary>
#pragma warning disable CS0618 // Type or member is obsolete
[JellyfinMigration("2025-04-20T10:00:00", nameof(MigrateUserDb), "5C4B82A2-F053-4009-BD05-B6FCAD82F14C", RequiresSqlite = true)]
public class MigrateUserDb : IMigrationRoutine
#pragma warning restore CS0618 // Type or member is obsolete
{
private const string DbFilename = "users.db";
private readonly ILogger<MigrateUserDb> _logger;
private readonly IServerApplicationPaths _paths;
private readonly IDbContextFactory<JellyfinDbContext> _provider;
private readonly IXmlSerializer _xmlSerializer;
/// <summary>
/// Initializes a new instance of the <see cref="MigrateUserDb"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="paths">The server application paths.</param>
/// <param name="provider">The database provider.</param>
/// <param name="xmlSerializer">The xml serializer.</param>
public MigrateUserDb(
ILogger<MigrateUserDb> logger,
IServerApplicationPaths paths,
IDbContextFactory<JellyfinDbContext> provider,
IXmlSerializer xmlSerializer)
{
_logger = logger;
_paths = paths;
_provider = provider;
_xmlSerializer = xmlSerializer;
}
/// <inheritdoc/>
public void Perform()
{
var dataPath = _paths.DataPath;
var userDbPath = Path.Combine(dataPath, DbFilename);
if (!File.Exists(userDbPath))
{
_logger.LogWarning("{UserDbPath} doesn't exist, nothing to migrate", userDbPath);
return;
}
_logger.LogInformation("Migrating the user database may take a while, do not stop Jellyfin.");
using (var connection = new SqliteConnection($"Filename={userDbPath}"))
{
connection.Open();
var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='LocalUsersv2';");
foreach (var row in tableQuery)
{
if (row.GetInt32(0) == 0)
{
_logger.LogWarning("Table 'LocalUsersv2' doesn't exist in {UserDbPath}, nothing to migrate", userDbPath);
return;
}
}
using var dbContext = _provider.CreateDbContext();
var queryResult = connection.Query("SELECT * FROM LocalUsersv2");
dbContext.RemoveRange(dbContext.Users);
dbContext.SaveChanges();
foreach (var entry in queryResult)
{
UserMockup? mockup = JsonSerializer.Deserialize<UserMockup>(entry.GetStream(2), JsonDefaults.Options);
if (mockup is null)
{
continue;
}
var userDataDir = Path.Combine(_paths.UserConfigurationDirectoryPath, mockup.Name);
var configPath = Path.Combine(userDataDir, "config.xml");
var config = File.Exists(configPath)
? (UserConfiguration?)_xmlSerializer.DeserializeFromFile(typeof(UserConfiguration), configPath) ?? new UserConfiguration()
: new UserConfiguration();
var policyPath = Path.Combine(userDataDir, "policy.xml");
var policy = File.Exists(policyPath)
? (UserPolicy?)_xmlSerializer.DeserializeFromFile(typeof(UserPolicy), policyPath) ?? new UserPolicy()
: new UserPolicy();
policy.AuthenticationProviderId = policy.AuthenticationProviderId?.Replace(
"Emby.Server.Implementations.Library",
"Jellyfin.Server.Implementations.Users",
StringComparison.Ordinal)
?? typeof(DefaultAuthenticationProvider).FullName;
policy.PasswordResetProviderId = typeof(DefaultPasswordResetProvider).FullName;
int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch
{
-1 => null,
0 => 3,
_ => policy.LoginAttemptsBeforeLockout
};
var user = new User(mockup.Name, policy.AuthenticationProviderId!, policy.PasswordResetProviderId!)
{
Id = entry.GetGuid(1),
InternalId = entry.GetInt64(0),
MaxParentalRatingScore = policy.MaxParentalRating,
MaxParentalRatingSubScore = null,
EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess,
RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit,
InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount,
LoginAttemptsBeforeLockout = maxLoginAttempts,
SubtitleMode = config.SubtitleMode,
HidePlayedInLatest = config.HidePlayedInLatest,
EnableLocalPassword = config.EnableLocalPassword,
PlayDefaultAudioTrack = config.PlayDefaultAudioTrack,
DisplayCollectionsView = config.DisplayCollectionsView,
DisplayMissingEpisodes = config.DisplayMissingEpisodes,
AudioLanguagePreference = config.AudioLanguagePreference,
RememberAudioSelections = config.RememberAudioSelections,
EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay,
RememberSubtitleSelections = config.RememberSubtitleSelections,
SubtitleLanguagePreference = config.SubtitleLanguagePreference,
Password = mockup.Password,
LastLoginDate = mockup.LastLoginDate,
LastActivityDate = mockup.LastActivityDate
};
if (mockup.ImageInfos.Length > 0)
{
ItemImageInfo info = mockup.ImageInfos[0];
user.ProfileImage = new ImageInfo(info.Path)
{
LastModified = info.DateModified
};
}
user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator);
user.SetPermission(PermissionKind.IsHidden, policy.IsHidden);
user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled);
user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl);
user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess);
user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement);
user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess);
user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback);
user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding);
user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding);
user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion);
user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading);
user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding);
user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion);
user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels);
user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices);
user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders);
user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers);
user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing);
user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding);
user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing);
user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement);
foreach (var policyAccessSchedule in policy.AccessSchedules)
{
user.AccessSchedules.Add(policyAccessSchedule);
}
user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags);
user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels);
user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices);
user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders);
user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews);
user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders);
user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes);
user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
dbContext.Users.Add(user);
}
dbContext.SaveChanges();
}
try
{
File.Move(Path.Combine(dataPath, DbFilename), Path.Combine(dataPath, DbFilename + ".old"));
var journalPath = Path.Combine(dataPath, DbFilename + "-journal");
if (File.Exists(journalPath))
{
File.Move(journalPath, Path.Combine(dataPath, DbFilename + ".old-journal"));
}
}
catch (IOException e)
{
_logger.LogError(e, "Error renaming legacy user database to 'users.db.old'");
}
}
#nullable disable
internal class UserMockup
{
public string Password { get; set; }
public string EasyPassword { get; set; }
public DateTime? LastLoginDate { get; set; }
public DateTime? LastActivityDate { get; set; }
public string Name { get; set; }
public ItemImageInfo[] ImageInfos { get; set; }
}
}
@@ -1,88 +0,0 @@
// <copyright file="RemoveDuplicateExtras.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Server.Migrations.Routines;
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using Emby.Server.Implementations.Data;
using MediaBrowser.Controller;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
/// <summary>
/// Remove duplicate entries which were caused by a bug where a file was considered to be an "Extra" to itself.
/// </summary>
#pragma warning disable CS0618 // Type or member is obsolete
[JellyfinMigration("2025-04-20T08:00:00", nameof(RemoveDuplicateExtras), "ACBE17B7-8435-4A83-8B64-6FCF162CB9BD", RequiresSqlite = true)]
internal class RemoveDuplicateExtras : IMigrationRoutine
#pragma warning restore CS0618 // Type or member is obsolete
{
private const string DbFilename = "library.db";
private readonly ILogger<RemoveDuplicateExtras> _logger;
private readonly IServerApplicationPaths _paths;
public RemoveDuplicateExtras(ILogger<RemoveDuplicateExtras> logger, IServerApplicationPaths paths)
{
_logger = logger;
_paths = paths;
}
/// <inheritdoc/>
public void Perform()
{
var dataPath = _paths.DataPath;
var dbPath = Path.Combine(dataPath, DbFilename);
// Skip this migration if using PostgreSQL or if library.db doesn't exist
if (!File.Exists(dbPath))
{
_logger.LogInformation("SQLite library.db not found, skipping SQLite-specific migration.");
return;
}
using var connection = new SqliteConnection($"Filename={dbPath}");
connection.Open();
using (var transaction = connection.BeginTransaction())
{
// Query the database for the ids of duplicate extras
var queryResult = connection.Query("SELECT t1.Path FROM TypedBaseItems AS t1, TypedBaseItems AS t2 WHERE t1.Path=t2.Path AND t1.Type!=t2.Type AND t1.Type='MediaBrowser.Controller.Entities.Video'");
var bads = string.Join(", ", queryResult.Select(x => x.GetString(0)));
// Do nothing if no duplicate extras were detected
if (bads.Length == 0)
{
_logger.LogInformation("No duplicate extras detected, skipping migration.");
return;
}
// Back up the database before deleting any entries
for (int i = 1; ; i++)
{
var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", dbPath, i);
if (!File.Exists(bakPath))
{
try
{
File.Copy(dbPath, bakPath);
_logger.LogInformation("Library database backed up to {BackupPath}", bakPath);
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath);
throw;
}
}
}
// Delete all duplicate extras
_logger.LogInformation("Removing found duplicated extras for the following items: {DuplicateExtras}", bads);
connection.Execute("DELETE FROM TypedBaseItems WHERE rowid IN (SELECT t1.rowid FROM TypedBaseItems AS t1, TypedBaseItems AS t2 WHERE t1.Path=t2.Path AND t1.Type!=t2.Type AND t1.Type='MediaBrowser.Controller.Entities.Video')");
transaction.Commit();
}
}
}
@@ -1,78 +0,0 @@
// <copyright file="ReseedFolderFlag.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
#pragma warning disable RS0030 // Do not use banned APIs
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations.Data;
using Jellyfin.Database.Implementations;
using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Controller;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
[JellyfinMigration("2025-07-30T21:50:00", nameof(ReseedFolderFlag), RequiresSqlite = true)]
[JellyfinMigrationBackup(JellyfinDb = true)]
internal class ReseedFolderFlag : IAsyncMigrationRoutine
{
private const string DbFilename = "library.db.old";
private readonly IStartupLogger _logger;
private readonly IServerApplicationPaths _paths;
private readonly IDbContextFactory<JellyfinDbContext> _provider;
public ReseedFolderFlag(
IStartupLogger<MigrateLibraryDb> startupLogger,
IDbContextFactory<JellyfinDbContext> provider,
IServerApplicationPaths paths)
{
_logger = startupLogger;
_provider = provider;
_paths = paths;
}
internal static bool RerunGuardFlag { get; set; } = false;
public async Task PerformAsync(CancellationToken cancellationToken)
{
if (RerunGuardFlag)
{
_logger.LogInformation("Migration is skipped because it does not apply.");
return;
}
_logger.LogInformation("Migrating the IsFolder flag from library.db.old may take a while, do not stop Jellyfin.");
var dataPath = _paths.DataPath;
var libraryDbPath = Path.Combine(dataPath, DbFilename);
if (!File.Exists(libraryDbPath))
{
_logger.LogError("Cannot migrate IsFolder flag from {LibraryDb} as it does not exist. This migration expects the MigrateLibraryDb to run first.", libraryDbPath);
return;
}
var dbContext = await _provider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
using var connection = new SqliteConnection($"Filename={libraryDbPath};Mode=ReadOnly");
var queryResult = connection.Query(
"""
SELECT guid FROM TypedBaseItems
WHERE IsFolder = true
""")
.Select(entity => entity.GetGuid(0))
.ToList();
_logger.LogInformation("Migrating the IsFolder flag for {Count} items.", queryResult.Count);
foreach (var id in queryResult)
{
await dbContext.BaseItems.Where(e => e.Id == id).ExecuteUpdateAsync(e => e.SetProperty(f => f.IsFolder, true), cancellationToken).ConfigureAwait(false);
}
}
}
}
@@ -68,11 +68,6 @@ namespace MediaBrowser.Controller.Extensions
/// </summary>
public const string UnixSocketPermissionsKey = "kestrel:socketPermissions";
/// <summary>
/// The cache size of the SQL database, see cache_size.
/// </summary>
public const string SqliteCacheSizeKey = "sqlite:cacheSize";
/// <summary>
/// The key for a setting that indicates whether the application should detect network status change.
/// </summary>
@@ -143,12 +138,5 @@ namespace MediaBrowser.Controller.Extensions
public static string? GetUnixSocketPermissions(this IConfiguration configuration)
=> configuration[UnixSocketPermissionsKey];
/// <summary>
/// Gets the cache_size from the <see cref="IConfiguration" />.
/// </summary>
/// <param name="configuration">The configuration to read the setting from.</param>
/// <returns>The sqlite cache size.</returns>
public static int? GetSqliteCacheSize(this IConfiguration configuration)
=> configuration.GetValue<int?>(SqliteCacheSizeKey);
}
}
@@ -0,0 +1,29 @@
// <copyright file="ItemPersonView.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Database.Implementations.Entities.Views;
#pragma warning disable CS1591
#pragma warning disable SA1600
using System;
/// <summary>
/// Read-only projection of the library."ItemPeople_v" view.
/// All cast and crew entries linked to the items they appear in.
/// </summary>
public class ItemPersonView
{
public Guid? ItemId { get; set; }
public string? ItemType { get; set; }
public string? ItemName { get; set; }
public string? SeriesName { get; set; }
public int? ProductionYear { get; set; }
public Guid? PersonId { get; set; }
public string? PersonName { get; set; }
public string? PersonType { get; set; }
public string? Role { get; set; }
public int? SortOrder { get; set; }
public int? ListOrder { get; set; }
}
@@ -0,0 +1,34 @@
// <copyright file="ItemProviderView.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Database.Implementations.Entities.Views;
#pragma warning disable CS1591
#pragma warning disable SA1600
using System;
/// <summary>
/// Read-only projection of library."ItemProviders_v".
/// Items with external provider IDs pivoted into named columns.
/// </summary>
public class ItemProviderView
{
public Guid? ItemId { get; set; }
public string? Type { get; set; }
public string? Name { get; set; }
public string? OriginalTitle { get; set; }
public int? ProductionYear { get; set; }
public string? SeriesName { get; set; }
public int? EpisodeNumber { get; set; }
public int? SeasonNumber { get; set; }
public string? ImdbId { get; set; }
public string? TmdbId { get; set; }
public string? TvdbId { get; set; }
public string? TvRageId { get; set; }
public string? MusicBrainzAlbumId { get; set; }
public string? MusicBrainzArtistId { get; set; }
/// <summary>Gets or sets remaining providers as a JSON object string.</summary>
public string? OtherProviders { get; set; }
}
@@ -0,0 +1,33 @@
// <copyright file="LibrarySummaryView.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Database.Implementations.Entities.Views;
#pragma warning disable CS1591
#pragma warning disable SA1600
/// <summary>
/// Read-only projection of the library."LibrarySummary_v" view.
/// One row per item type with aggregate statistics across the whole library.
/// </summary>
public class LibrarySummaryView
{
public string? Type { get; set; }
public long? ItemCount { get; set; }
public long? WithYear { get; set; }
public int? OldestYear { get; set; }
public int? NewestYear { get; set; }
public decimal? AvgCommunityRating { get; set; }
public decimal? TotalRunTimeTicks { get; set; }
public decimal? TotalRuntimeHours { get; set; }
public decimal? TotalSizeBytes { get; set; }
public decimal? TotalSizeGB { get; set; }
public long? Count4K { get; set; }
public long? Count1080p { get; set; }
public long? Count720p { get; set; }
public long? CountSD { get; set; }
public long? CountDolbyVision { get; set; }
public long? CountHDR10Plus { get; set; }
public long? CountHDR10 { get; set; }
}
@@ -0,0 +1,67 @@
// <copyright file="MediaStreamInfoView.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Database.Implementations.Entities.Views;
#pragma warning disable CS1591
#pragma warning disable SA1600
using System;
/// <summary>
/// Read-only projection of library."MediaStreamInfos_v".
/// Flat view of all media streams with audio- and video-specific columns merged.
/// </summary>
public class MediaStreamInfoView
{
public Guid? ItemId { get; set; }
public int? StreamIndex { get; set; }
public int? StreamType { get; set; }
public string? Codec { get; set; }
public string? Language { get; set; }
public string? Profile { get; set; }
public string? Path { get; set; }
public int? BitRate { get; set; }
public bool? IsDefault { get; set; }
public bool? IsForced { get; set; }
public bool? IsExternal { get; set; }
public float? Level { get; set; }
public string? CodecTag { get; set; }
public string? Comment { get; set; }
public bool? IsAvc { get; set; }
public string? Title { get; set; }
public string? TimeBase { get; set; }
public string? CodecTimeBase { get; set; }
// audio-specific
public string? ChannelLayout { get; set; }
public int? Channels { get; set; }
public int? SampleRate { get; set; }
public bool? IsHearingImpaired { get; set; }
// video-specific
public string? AspectRatio { get; set; }
public bool? IsInterlaced { get; set; }
public int? Height { get; set; }
public int? Width { get; set; }
public float? AverageFrameRate { get; set; }
public float? RealFrameRate { get; set; }
public string? PixelFormat { get; set; }
public int? BitDepth { get; set; }
public bool? IsAnamorphic { get; set; }
public int? RefFrames { get; set; }
public string? NalLengthSize { get; set; }
public string? ColorPrimaries { get; set; }
public string? ColorSpace { get; set; }
public string? ColorTransfer { get; set; }
public int? DvVersionMajor { get; set; }
public int? DvVersionMinor { get; set; }
public int? DvProfile { get; set; }
public int? DvLevel { get; set; }
public int? RpuPresentFlag { get; set; }
public int? ElPresentFlag { get; set; }
public int? BlPresentFlag { get; set; }
public int? DvBlSignalCompatibilityId { get; set; }
public int? Rotation { get; set; }
public string? KeyFrames { get; set; }
public bool? Hdr10PlusPresentFlag { get; set; }
}
@@ -0,0 +1,39 @@
// <copyright file="UserPlaybackHistoryView.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Database.Implementations.Entities.Views;
#pragma warning disable CS1591
#pragma warning disable SA1600
using System;
/// <summary>
/// Read-only projection of library."UserPlaybackHistory_v".
/// Per-user watch history with calculated progress percentage.
/// </summary>
public class UserPlaybackHistoryView
{
public string? Username { get; set; }
public Guid? UserId { get; set; }
public string? ItemName { get; set; }
public string? ItemType { get; set; }
public string? SeriesName { get; set; }
public string? SeasonName { get; set; }
public int? EpisodeNumber { get; set; }
public int? SeasonNumber { get; set; }
public int? ProductionYear { get; set; }
public long? RunTimeTicks { get; set; }
public Guid? ItemId { get; set; }
public bool? Played { get; set; }
public int? PlayCount { get; set; }
public DateTimeOffset? LastPlayedDate { get; set; }
public long? PlaybackPositionTicks { get; set; }
/// <summary>Gets or sets playback progress 0100. NULL when no runtime.</summary>
public decimal? ProgressPct { get; set; }
public bool? IsFavorite { get; set; }
public double? Rating { get; set; }
public int? AudioStreamIndex { get; set; }
public int? SubtitleStreamIndex { get; set; }
}
@@ -0,0 +1,54 @@
// <copyright file="VideoItemView.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Database.Implementations.Entities.Views;
#pragma warning disable CS1591
#pragma warning disable SA1600
using System;
/// <summary>
/// Read-only projection of library."VideoItems_v".
/// Every non-folder, non-virtual item with at least one video stream.
/// </summary>
public class VideoItemView
{
public Guid? ItemId { get; set; }
public string? Type { get; set; }
public string? Name { get; set; }
public string? OriginalTitle { get; set; }
public int? ProductionYear { get; set; }
public string? OfficialRating { get; set; }
public float? CommunityRating { get; set; }
public long? RunTimeTicks { get; set; }
public string? Path { get; set; }
public DateTimeOffset? DateCreated { get; set; }
public DateTimeOffset? DateModified { get; set; }
public string? SeriesName { get; set; }
public string? SeasonName { get; set; }
public int? EpisodeNumber { get; set; }
public int? SeasonNumber { get; set; }
public int? TotalBitrate { get; set; }
public long? Size { get; set; }
public string? VideoCodec { get; set; }
public string? VideoProfile { get; set; }
public int? Width { get; set; }
public int? Height { get; set; }
public float? AverageFrameRate { get; set; }
public int? BitDepth { get; set; }
public string? PixelFormat { get; set; }
public string? ColorSpace { get; set; }
public string? ColorTransfer { get; set; }
public string? ColorPrimaries { get; set; }
public bool? IsInterlaced { get; set; }
public bool? IsAnamorphic { get; set; }
public string? AspectRatio { get; set; }
public bool? IsDolbyVision { get; set; }
public bool? IsHDR10Plus { get; set; }
/// <summary>Gets or sets derived HDR format: "Dolby Vision", "HDR10+", "HDR10", "HLG", or "SDR".</summary>
public string? HDRFormat { get; set; }
public long? AudioTrackCount { get; set; }
public long? SubtitleTrackCount { get; set; }
}
@@ -14,6 +14,7 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Entities.Security;
using Jellyfin.Database.Implementations.Entities.Views;
using Jellyfin.Database.Implementations.Interfaces;
using Jellyfin.Database.Implementations.Locking;
using Microsoft.EntityFrameworkCore;
@@ -156,6 +157,44 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
/// </summary>
public DbSet<VideoStreamDetails> VideoStreamDetails => this.Set<VideoStreamDetails>();
// ── Read-only view projections (PostgreSQL only) ──────────────────────
/// <summary>
/// Gets the flat view over MediaStreamInfos joined with audio and video detail tables.
/// Maps to library."MediaStreamInfos_v".
/// </summary>
public DbSet<MediaStreamInfoView> MediaStreamInfoViews => this.Set<MediaStreamInfoView>();
/// <summary>
/// Gets the view of video items joined with their primary stream details.
/// Maps to library."VideoItems_v".
/// </summary>
public DbSet<VideoItemView> VideoItemViews => this.Set<VideoItemView>();
/// <summary>
/// Gets the view of per-user watch history with calculated progress.
/// Maps to library."UserPlaybackHistory_v".
/// </summary>
public DbSet<UserPlaybackHistoryView> UserPlaybackHistoryViews => this.Set<UserPlaybackHistoryView>();
/// <summary>
/// Gets the view of items with pivoted external provider IDs.
/// Maps to library."ItemProviders_v".
/// </summary>
public DbSet<ItemProviderView> ItemProviderViews => this.Set<ItemProviderView>();
/// <summary>
/// Gets the view of cast and crew entries linked to their items.
/// Maps to library."ItemPeople_v".
/// </summary>
public DbSet<ItemPersonView> ItemPersonViews => this.Set<ItemPersonView>();
/// <summary>
/// Gets the aggregate library statistics view, one row per item type.
/// Maps to library."LibrarySummary_v".
/// </summary>
public DbSet<LibrarySummaryView> LibrarySummaryViews => this.Set<LibrarySummaryView>();
/// <summary>
/// Gets the <see cref="DbSet{TEntity}"/>.
/// </summary>
@@ -19,6 +19,7 @@ using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Entities.Security;
using Jellyfin.Database.Implementations.Entities.Views;
using Jellyfin.Database.Providers.Postgres.Exceptions;
using MediaBrowser.Common.Configuration;
using Microsoft.EntityFrameworkCore;
@@ -765,6 +766,9 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
// Assign entities to schemas based on their legacy database origin
AssignEntitiesToSchemas(modelBuilder);
// Register read-only view projections (PostgreSQL only)
RegisterViewMappings(modelBuilder);
}
/// <summary>
@@ -818,6 +822,39 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
modelBuilder.Entity<LibraryOptionsEntity>().HasIndex(e => e.DateModified);
}
/// <summary>
/// Registers read-only keyless view projections for PostgreSQL.
/// Each view lives in the <c>library</c> schema and is mapped with
/// <c>HasNoKey().ToView()</c> so EF Core never tries to create a table or migration for it.
/// </summary>
/// <param name="modelBuilder">The model builder.</param>
private static void RegisterViewMappings(ModelBuilder modelBuilder)
{
modelBuilder.Entity<MediaStreamInfoView>()
.HasNoKey()
.ToView("MediaStreamInfos_v", Schemas.Library);
modelBuilder.Entity<VideoItemView>()
.HasNoKey()
.ToView("VideoItems_v", Schemas.Library);
modelBuilder.Entity<UserPlaybackHistoryView>()
.HasNoKey()
.ToView("UserPlaybackHistory_v", Schemas.Library);
modelBuilder.Entity<ItemProviderView>()
.HasNoKey()
.ToView("ItemProviders_v", Schemas.Library);
modelBuilder.Entity<ItemPersonView>()
.HasNoKey()
.ToView("ItemPeople_v", Schemas.Library);
modelBuilder.Entity<LibrarySummaryView>()
.HasNoKey()
.ToView("LibrarySummary_v", Schemas.Library);
}
/// <inheritdoc/>
public Task RunShutdownTask(CancellationToken cancellationToken)
{