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" },
|
{ FfmpegProbeSizeKey, "1G" },
|
||||||
{ FfmpegAnalyzeDurationKey, "200M" },
|
{ FfmpegAnalyzeDurationKey, "200M" },
|
||||||
{ BindToUnixSocketKey, bool.FalseString },
|
{ BindToUnixSocketKey, bool.FalseString },
|
||||||
{ SqliteCacheSizeKey, "20000" },
|
|
||||||
{ FfmpegSkipValidationKey, bool.FalseString },
|
{ FfmpegSkipValidationKey, bool.FalseString },
|
||||||
{ FfmpegImgExtractPerfTradeoffKey, bool.FalseString },
|
{ FfmpegImgExtractPerfTradeoffKey, bool.FalseString },
|
||||||
{ DetectNetworkChangeKey, bool.TrueString }
|
{ 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
|
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()
|
efCoreConfiguration = new DatabaseConfigurationOptions()
|
||||||
{
|
{
|
||||||
DatabaseType = "Jellyfin-SQLite",
|
DatabaseType = "Jellyfin-PostgreSQL",
|
||||||
LockingBehavior = DatabaseLockingBehaviorTypes.NoLock,
|
LockingBehavior = DatabaseLockingBehaviorTypes.NoLock,
|
||||||
PresetConfigurations =
|
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
|
new PresetConfigurationItem
|
||||||
{
|
{
|
||||||
Key = "PostgreSQL-Local",
|
Key = "PostgreSQL-Local",
|
||||||
@@ -135,7 +125,7 @@ public static class ServiceCollectionExtensions
|
|||||||
// Ensure DatabaseType is set (handle corrupted or empty configuration files)
|
// Ensure DatabaseType is set (handle corrupted or empty configuration files)
|
||||||
if (string.IsNullOrWhiteSpace(efCoreConfiguration.DatabaseType))
|
if (string.IsNullOrWhiteSpace(efCoreConfiguration.DatabaseType))
|
||||||
{
|
{
|
||||||
efCoreConfiguration.DatabaseType = "Jellyfin-SQLite";
|
efCoreConfiguration.DatabaseType = "Jellyfin-PostgreSQL";
|
||||||
configurationManager.SaveConfiguration("database", efCoreConfiguration);
|
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>
|
/// </summary>
|
||||||
public const string UnixSocketPermissionsKey = "kestrel:socketPermissions";
|
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>
|
/// <summary>
|
||||||
/// The key for a setting that indicates whether the application should detect network status change.
|
/// The key for a setting that indicates whether the application should detect network status change.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -143,12 +138,5 @@ namespace MediaBrowser.Controller.Extensions
|
|||||||
public static string? GetUnixSocketPermissions(this IConfiguration configuration)
|
public static string? GetUnixSocketPermissions(this IConfiguration configuration)
|
||||||
=> configuration[UnixSocketPermissionsKey];
|
=> 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 System.Threading.Tasks;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using Jellyfin.Database.Implementations.Entities.Security;
|
using Jellyfin.Database.Implementations.Entities.Security;
|
||||||
|
using Jellyfin.Database.Implementations.Entities.Views;
|
||||||
using Jellyfin.Database.Implementations.Interfaces;
|
using Jellyfin.Database.Implementations.Interfaces;
|
||||||
using Jellyfin.Database.Implementations.Locking;
|
using Jellyfin.Database.Implementations.Locking;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -156,6 +157,44 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public DbSet<VideoStreamDetails> VideoStreamDetails => this.Set<VideoStreamDetails>();
|
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>
|
/// <summary>
|
||||||
/// Gets the <see cref="DbSet{TEntity}"/>.
|
/// Gets the <see cref="DbSet{TEntity}"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+37
@@ -19,6 +19,7 @@ using Jellyfin.Database.Implementations;
|
|||||||
using Jellyfin.Database.Implementations.DbConfiguration;
|
using Jellyfin.Database.Implementations.DbConfiguration;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using Jellyfin.Database.Implementations.Entities.Security;
|
using Jellyfin.Database.Implementations.Entities.Security;
|
||||||
|
using Jellyfin.Database.Implementations.Entities.Views;
|
||||||
using Jellyfin.Database.Providers.Postgres.Exceptions;
|
using Jellyfin.Database.Providers.Postgres.Exceptions;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -765,6 +766,9 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
|
|||||||
|
|
||||||
// Assign entities to schemas based on their legacy database origin
|
// Assign entities to schemas based on their legacy database origin
|
||||||
AssignEntitiesToSchemas(modelBuilder);
|
AssignEntitiesToSchemas(modelBuilder);
|
||||||
|
|
||||||
|
// Register read-only view projections (PostgreSQL only)
|
||||||
|
RegisterViewMappings(modelBuilder);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -818,6 +822,39 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
|
|||||||
modelBuilder.Entity<LibraryOptionsEntity>().HasIndex(e => e.DateModified);
|
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/>
|
/// <inheritdoc/>
|
||||||
public Task RunShutdownTask(CancellationToken cancellationToken)
|
public Task RunShutdownTask(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user