diff --git a/Emby.Server.Implementations/ConfigurationOptions.cs b/Emby.Server.Implementations/ConfigurationOptions.cs index bddbbd67..bd682e6c 100644 --- a/Emby.Server.Implementations/ConfigurationOptions.cs +++ b/Emby.Server.Implementations/ConfigurationOptions.cs @@ -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 } diff --git a/Jellyfin.Api/Controllers/DatabaseViewsController.cs b/Jellyfin.Api/Controllers/DatabaseViewsController.cs new file mode 100644 index 00000000..e69ae6b2 --- /dev/null +++ b/Jellyfin.Api/Controllers/DatabaseViewsController.cs @@ -0,0 +1,302 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +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; + +/// +/// Exposes the PostgreSQL reporting views as read-only API endpoints. +/// All endpoints require administrator privileges. +/// +[Route("DatabaseViews")] +[Authorize(Policy = Policies.RequiresElevation)] +public class DatabaseViewsController : BaseJellyfinApiController +{ + private readonly IDbContextFactory _dbFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The EF Core context factory. + public DatabaseViewsController(IDbContextFactory dbFactory) + { + _dbFactory = dbFactory; + } + + /// + /// Gets a flat list of all media streams (audio + video columns merged). + /// Maps to library."MediaStreamInfos_v". + /// + /// Optional item ID filter. + /// Optional stream type filter (0=Audio, 1=Video, 2=Subtitle). + /// Record index to start at. + /// Maximum records to return (1-1000, default 100). + /// List of media stream rows. + [HttpGet("MediaStreamInfos")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> 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 results = await query + .OrderBy(x => x.ItemId) + .ThenBy(x => x.StreamIndex) + .Skip(startIndex) + .Take(limit) + .ToListAsync() + .ConfigureAwait(false); + + return Ok(results); + } + + /// + /// Gets video items joined with primary stream technical details. + /// Maps to library."VideoItems_v". + /// + /// Optional partial type filter (e.g. "Movie", "Episode"). + /// Optional minimum video height (e.g. 2160 for 4K). + /// Optional HDR format ("Dolby Vision", "HDR10+", "HDR10", "HLG", "SDR"). + /// Record index to start at. + /// Maximum records to return (1-1000, default 100). + /// List of video item rows. + [HttpGet("VideoItems")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> 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 results = await query + .OrderBy(x => x.Name) + .Skip(startIndex) + .Take(limit) + .ToListAsync() + .ConfigureAwait(false); + + return Ok(results); + } + + /// + /// Gets per-user playback history with calculated progress percentages. + /// Maps to library."UserPlaybackHistory_v". + /// + /// Optional exact username filter. + /// Optional item ID filter. + /// When true, returns only items started but not fully played. + /// Record index to start at. + /// Maximum records to return (1-1000, default 100). + /// List of playback history rows. + [HttpGet("PlaybackHistory")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> 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 results = await query + .OrderByDescending(x => x.LastPlayedDate) + .Skip(startIndex) + .Take(limit) + .ToListAsync() + .ConfigureAwait(false); + + return Ok(results); + } + + /// + /// Gets items with their external provider IDs (IMDb, TMDB, TVDB, etc.) in named columns. + /// Maps to library."ItemProviders_v". + /// + /// Optional partial type filter. + /// Optional exact IMDb ID filter. + /// Optional exact TMDB ID filter. + /// Optional exact TVDB ID filter. + /// Record index to start at. + /// Maximum records to return (1-1000, default 100). + /// List of item provider rows. + [HttpGet("ItemProviders")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> 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 results = await query + .OrderBy(x => x.Name) + .Skip(startIndex) + .Take(limit) + .ToListAsync() + .ConfigureAwait(false); + + return Ok(results); + } + + /// + /// Gets cast and crew entries linked to the items they appear in. + /// Maps to library."ItemPeople_v". + /// + /// Optional exact person name filter. + /// Optional person type filter (e.g. "Actor", "Director"). + /// Optional item ID filter. + /// Record index to start at. + /// Maximum records to return (1-1000, default 100). + /// List of item person rows. + [HttpGet("ItemPeople")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> 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 results = await query + .OrderBy(x => x.PersonName) + .ThenBy(x => x.SortOrder) + .Skip(startIndex) + .Take(limit) + .ToListAsync() + .ConfigureAwait(false); + + return Ok(results); + } + + /// + /// Gets aggregate library statistics - one row per item type. + /// Maps to library."LibrarySummary_v". + /// + /// List of library summary rows, one per item type. + [HttpGet("LibrarySummary")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> GetLibrarySummary() + { + await using var db = await _dbFactory.CreateDbContextAsync().ConfigureAwait(false); + + IEnumerable results = await db.LibrarySummaryViews + .AsNoTracking() + .OrderBy(x => x.Type) + .ToListAsync() + .ConfigureAwait(false); + + return Ok(results); + } +} diff --git a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs index 1f06f742..00f4a8bf 100644 --- a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs +++ b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs @@ -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); } diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs deleted file mode 100644 index 9d263f31..00000000 --- a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs +++ /dev/null @@ -1,237 +0,0 @@ -// -// Copyright (c) PlaceholderCompany. All rights reserved. -// - -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; - -/// -/// The migration routine for migrating the user database to EF Core. -/// -#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 _logger; - private readonly IServerApplicationPaths _paths; - private readonly IDbContextFactory _provider; - private readonly IXmlSerializer _xmlSerializer; - - /// - /// Initializes a new instance of the class. - /// - /// The logger. - /// The server application paths. - /// The database provider. - /// The xml serializer. - public MigrateUserDb( - ILogger logger, - IServerApplicationPaths paths, - IDbContextFactory provider, - IXmlSerializer xmlSerializer) - { - _logger = logger; - _paths = paths; - _provider = provider; - _xmlSerializer = xmlSerializer; - } - - /// - 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(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; } - } -} diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs b/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs deleted file mode 100644 index 1080bbd2..00000000 --- a/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs +++ /dev/null @@ -1,88 +0,0 @@ -// -// Copyright (c) PlaceholderCompany. All rights reserved. -// - -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; - -/// -/// Remove duplicate entries which were caused by a bug where a file was considered to be an "Extra" to itself. -/// -#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 _logger; - private readonly IServerApplicationPaths _paths; - - public RemoveDuplicateExtras(ILogger logger, IServerApplicationPaths paths) - { - _logger = logger; - _paths = paths; - } - - /// - 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(); - } - } -} diff --git a/Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs b/Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs deleted file mode 100644 index 46a16ce0..00000000 --- a/Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs +++ /dev/null @@ -1,78 +0,0 @@ -// -// Copyright (c) PlaceholderCompany. All rights reserved. -// - -#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 _provider; - - public ReseedFolderFlag( - IStartupLogger startupLogger, - IDbContextFactory 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); - } - } - } -} diff --git a/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs b/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs index 18f3a1cd..33420702 100644 --- a/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs +++ b/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs @@ -68,11 +68,6 @@ namespace MediaBrowser.Controller.Extensions /// public const string UnixSocketPermissionsKey = "kestrel:socketPermissions"; - /// - /// The cache size of the SQL database, see cache_size. - /// - public const string SqliteCacheSizeKey = "sqlite:cacheSize"; - /// /// The key for a setting that indicates whether the application should detect network status change. /// @@ -143,12 +138,5 @@ namespace MediaBrowser.Controller.Extensions public static string? GetUnixSocketPermissions(this IConfiguration configuration) => configuration[UnixSocketPermissionsKey]; - /// - /// Gets the cache_size from the . - /// - /// The configuration to read the setting from. - /// The sqlite cache size. - public static int? GetSqliteCacheSize(this IConfiguration configuration) - => configuration.GetValue(SqliteCacheSizeKey); } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Views/ItemPersonView.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Views/ItemPersonView.cs new file mode 100644 index 00000000..c15ff2d7 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Views/ItemPersonView.cs @@ -0,0 +1,29 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +namespace Jellyfin.Database.Implementations.Entities.Views; + +#pragma warning disable CS1591 +#pragma warning disable SA1600 + +using System; + +/// +/// Read-only projection of the library."ItemPeople_v" view. +/// All cast and crew entries linked to the items they appear in. +/// +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; } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Views/ItemProviderView.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Views/ItemProviderView.cs new file mode 100644 index 00000000..0c716598 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Views/ItemProviderView.cs @@ -0,0 +1,34 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +namespace Jellyfin.Database.Implementations.Entities.Views; + +#pragma warning disable CS1591 +#pragma warning disable SA1600 + +using System; + +/// +/// Read-only projection of library."ItemProviders_v". +/// Items with external provider IDs pivoted into named columns. +/// +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; } + /// Gets or sets remaining providers as a JSON object string. + public string? OtherProviders { get; set; } +} \ No newline at end of file diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Views/LibrarySummaryView.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Views/LibrarySummaryView.cs new file mode 100644 index 00000000..e8c62e4e --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Views/LibrarySummaryView.cs @@ -0,0 +1,33 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +namespace Jellyfin.Database.Implementations.Entities.Views; + +#pragma warning disable CS1591 +#pragma warning disable SA1600 + +/// +/// Read-only projection of the library."LibrarySummary_v" view. +/// One row per item type with aggregate statistics across the whole library. +/// +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; } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Views/MediaStreamInfoView.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Views/MediaStreamInfoView.cs new file mode 100644 index 00000000..b1c51b3f --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Views/MediaStreamInfoView.cs @@ -0,0 +1,67 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +namespace Jellyfin.Database.Implementations.Entities.Views; + +#pragma warning disable CS1591 +#pragma warning disable SA1600 + +using System; + +/// +/// Read-only projection of library."MediaStreamInfos_v". +/// Flat view of all media streams with audio- and video-specific columns merged. +/// +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; } +} \ No newline at end of file diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Views/UserPlaybackHistoryView.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Views/UserPlaybackHistoryView.cs new file mode 100644 index 00000000..b5629d17 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Views/UserPlaybackHistoryView.cs @@ -0,0 +1,39 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +namespace Jellyfin.Database.Implementations.Entities.Views; + +#pragma warning disable CS1591 +#pragma warning disable SA1600 + +using System; + +/// +/// Read-only projection of library."UserPlaybackHistory_v". +/// Per-user watch history with calculated progress percentage. +/// +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; } + /// Gets or sets playback progress 0–100. NULL when no runtime. + 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; } +} \ No newline at end of file diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Views/VideoItemView.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Views/VideoItemView.cs new file mode 100644 index 00000000..218dc683 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Views/VideoItemView.cs @@ -0,0 +1,54 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +namespace Jellyfin.Database.Implementations.Entities.Views; + +#pragma warning disable CS1591 +#pragma warning disable SA1600 + +using System; + +/// +/// Read-only projection of library."VideoItems_v". +/// Every non-folder, non-virtual item with at least one video stream. +/// +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; } + /// Gets or sets derived HDR format: "Dolby Vision", "HDR10+", "HDR10", "HLG", or "SDR". + public string? HDRFormat { get; set; } + public long? AudioTrackCount { get; set; } + public long? SubtitleTrackCount { get; set; } +} \ No newline at end of file diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs index 9b01153b..df212074 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs @@ -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 options, ILog /// public DbSet VideoStreamDetails => this.Set(); + // ── Read-only view projections (PostgreSQL only) ────────────────────── + + /// + /// Gets the flat view over MediaStreamInfos joined with audio and video detail tables. + /// Maps to library."MediaStreamInfos_v". + /// + public DbSet MediaStreamInfoViews => this.Set(); + + /// + /// Gets the view of video items joined with their primary stream details. + /// Maps to library."VideoItems_v". + /// + public DbSet VideoItemViews => this.Set(); + + /// + /// Gets the view of per-user watch history with calculated progress. + /// Maps to library."UserPlaybackHistory_v". + /// + public DbSet UserPlaybackHistoryViews => this.Set(); + + /// + /// Gets the view of items with pivoted external provider IDs. + /// Maps to library."ItemProviders_v". + /// + public DbSet ItemProviderViews => this.Set(); + + /// + /// Gets the view of cast and crew entries linked to their items. + /// Maps to library."ItemPeople_v". + /// + public DbSet ItemPersonViews => this.Set(); + + /// + /// Gets the aggregate library statistics view, one row per item type. + /// Maps to library."LibrarySummary_v". + /// + public DbSet LibrarySummaryViews => this.Set(); + /// /// Gets the . /// diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/PostgresDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/PostgresDatabaseProvider.cs index 251ac94f..05c32d58 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/PostgresDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/PostgresDatabaseProvider.cs @@ -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); } /// @@ -818,6 +822,39 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider modelBuilder.Entity().HasIndex(e => e.DateModified); } + /// + /// Registers read-only keyless view projections for PostgreSQL. + /// Each view lives in the library schema and is mapped with + /// HasNoKey().ToView() so EF Core never tries to create a table or migration for it. + /// + /// The model builder. + private static void RegisterViewMappings(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasNoKey() + .ToView("MediaStreamInfos_v", Schemas.Library); + + modelBuilder.Entity() + .HasNoKey() + .ToView("VideoItems_v", Schemas.Library); + + modelBuilder.Entity() + .HasNoKey() + .ToView("UserPlaybackHistory_v", Schemas.Library); + + modelBuilder.Entity() + .HasNoKey() + .ToView("ItemProviders_v", Schemas.Library); + + modelBuilder.Entity() + .HasNoKey() + .ToView("ItemPeople_v", Schemas.Library); + + modelBuilder.Entity() + .HasNoKey() + .ToView("LibrarySummary_v", Schemas.Library); + } + /// public Task RunShutdownTask(CancellationToken cancellationToken) {