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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -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; }
|
||||
}
|
||||
+34
@@ -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; }
|
||||
}
|
||||
+33
@@ -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; }
|
||||
}
|
||||
+67
@@ -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; }
|
||||
}
|
||||
+39
@@ -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 0–100. 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; }
|
||||
}
|
||||
+54
@@ -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>
|
||||
|
||||
+37
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user