1 Commits

Author SHA1 Message Date
wjones 77e30685bb Complete multi-instance support: Phases 3–6 & deployment
- Implements Phases 3–6: session isolation, cache coordination, primary election, and file system monitor coordination for Jellyfin with PostgreSQL.
- Adds new database entities (Instance, DistributedLock, FileSystemChange) and EF model configurations.
- Includes SQL migration scripts and EF migration for all required tables, columns, and helper functions.
- Updates Device entity and JellyfinDbContext for multi-instance tracking.
- Integrates new DI services for instance registry, distributed locks, cache coordinator, and primary election.
- Adds publishing profiles (Win/Linux/FrameworkDependent) and automation script for deployment.
- Extensive documentation for architecture, setup, and publishing.
- All changes are backward compatible and build successfully.
2026-03-05 16:10:26 -05:00
439 changed files with 59871 additions and 9929829 deletions
-16
View File
@@ -7,11 +7,7 @@
[Rr]elease/
[Bb]in/
[Oo]bj/
.idea/
*.lscache
.github/
.github
/.vs/Jellyfin
/.vs
**/obj/
@@ -24,7 +20,6 @@
*.pdb
bin/
obj/
*.lscache
# Centralized lib output folder
/lib/
@@ -38,14 +33,3 @@ wwwroot/*
Properties/PublishProfiles/
**/PublishProfiles/
**/Properties/PublishProfiles/
# Ignore schema directories (database schema dumps)
**/schema/
schema/
[Rr]eports/
# Ignore .idea IDE directory
**/.idea/
.idea/
**/scenario.json
scenario.json
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>
+2 -3
View File
@@ -1,7 +1,6 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<NoWarn>$(NoWarn);NU1510</NoWarn>
</PropertyGroup>
<!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
<ItemGroup Label="Package Dependencies">
@@ -52,7 +51,7 @@
<PackageVersion Include="Microsoft.Extensions.Options" Version="11.0.0-preview.1.26104.118" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="MimeTypes" Version="2.5.2" />
<PackageVersion Include="Morestachio" Version="5.0.1.670" />
<PackageVersion Include="Morestachio" Version="5.0.1.631" />
<PackageVersion Include="Moq" Version="4.18.4" />
<PackageVersion Include="NEbml" Version="1.1.0.5" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
@@ -91,4 +90,4 @@
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.61" />
<PackageVersion Include="xunit" Version="2.9.3" />
</ItemGroup>
</Project>
</Project>
+2 -2
View File
@@ -20,7 +20,7 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<WarningsAsErrors></WarningsAsErrors>
<EnforceCodeStyleInBuild>false</EnforceCodeStyleInBuild>
<NoWarn>$(NoWarn);IDE0065;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517;CA1865;CA1869;CA1716;CA2101;SA1127;CA1845;CA1859;CA2007</NoWarn>
<NoWarn>$(NoWarn);IDE0065</NoWarn>
</PropertyGroup>
<PropertyGroup Condition=" '$(Stability)'=='Unstable'">
@@ -62,7 +62,7 @@
</ItemGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);NETSDK1057;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517;CA1865;CA1869;CA1716;CA2101;SA1127;CA1845;CA1859;CA2007</NoWarn>
<NoWarn>$(NoWarn);NETSDK1057</NoWarn>
</PropertyGroup>
</Project>
-1
View File
@@ -3,7 +3,6 @@
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
<PropertyGroup>
<ProjectGuid>{89AB4548-770D-41FD-A891-8DAFF44F452C}</ProjectGuid>
<NoWarn>$(NoWarn);CA1062;CA1031;CA1848;CA2253;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517;CA1865;CA1869;CA1716;CA2101;SA1127;CA1845;CA1859;CA2007</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -10,7 +10,6 @@ namespace Emby.Server.Implementations.AppBase
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Events;
@@ -307,32 +306,6 @@ namespace Emby.Server.Implementations.AppBase
private object LoadConfiguration(string path, Type configurationType)
{
// Try JSON first, then fallback to XML for backward compatibility
var jsonPath = Path.ChangeExtension(path, ".json");
// Try loading from .json if it exists
if (File.Exists(jsonPath))
{
try
{
var method = typeof(ConfigurationHelper).GetMethod(
nameof(ConfigurationHelper.GetJsonConfiguration),
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public);
if (method is not null)
{
var genericMethod = method.MakeGenericMethod(configurationType);
return genericMethod.Invoke(null, new object[] { jsonPath })
?? throw new InvalidOperationException("JSON deserialization returned null.");
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error loading JSON configuration file: {Path}", jsonPath);
}
}
// Fall back to XML for backward compatibility
try
{
if (File.Exists(path))
@@ -6,8 +6,6 @@ namespace Emby.Server.Implementations.AppBase
{
using System;
using System.IO;
using System.Text.Json;
using Jellyfin.Extensions.Json;
using MediaBrowser.Model.Serialization;
/// <summary>
@@ -56,54 +54,10 @@ namespace Emby.Server.Implementations.AppBase
Directory.CreateDirectory(directory);
// Save it after load in case we got new items
#pragma warning disable IDE0063 // Use simple 'using' statement
using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None))
{
fs.Write(newBytes);
}
#pragma warning restore IDE0063 // Use simple 'using' statement
}
return configuration;
}
/// <summary>
/// Reads a JSON configuration file from the file system using System.Text.Json.
/// It will immediately re-serialize and save if new serialization data is available due to property changes.
/// </summary>
/// <typeparam name="T">The type of configuration to deserialize.</typeparam>
/// <param name="path">The path to the JSON configuration file.</param>
/// <returns>The deserialized configuration object.</returns>
public static T GetJsonConfiguration<T>(string path)
where T : class, new()
{
T configuration;
byte[]? buffer = null;
// Use try/catch to avoid the extra file system lookup using File.Exists
try
{
buffer = File.ReadAllBytes(path);
configuration = JsonSerializer.Deserialize<T>(buffer, JsonDefaults.Options) ?? new T();
}
catch (Exception)
{
// If file doesn't exist or deserialization fails, create a new instance with defaults
configuration = new T();
}
// Re-serialize to ensure proper formatting and any new defaults are captured
byte[] newBytes = JsonSerializer.SerializeToUtf8Bytes(configuration, JsonDefaults.Options);
// If the file didn't exist before, or if something has changed, re-save
if (buffer is null || !newBytes.AsSpan().SequenceEqual(buffer))
{
var directory = Path.GetDirectoryName(path) ?? throw new ArgumentException($"Provided path ({path}) is not valid.", nameof(path));
Directory.CreateDirectory(directory);
// Save it after load in case we got new items or formatting changed
File.WriteAllBytes(path, newBytes);
}
return configuration;
+77 -31
View File
@@ -16,6 +16,7 @@ using System.Linq;
using System.Net;
using System.Reflection;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
using Emby.Naming.Common;
using Emby.Photos;
@@ -409,7 +410,7 @@ namespace Emby.Server.Implementations
/// Runs the startup tasks.
/// </summary>
/// <returns><see cref="Task" />.</returns>
public Task RunStartupTasksAsync()
public async Task RunStartupTasksAsync()
{
Logger.LogInformation("Running startup tasks");
@@ -426,10 +427,64 @@ namespace Emby.Server.Implementations
}
Logger.LogInformation("ServerId: {ServerId}", SystemId);
// Register this instance in multi-instance mode
await RegisterInstanceAsync().ConfigureAwait(false);
// Start cache coordinator for multi-instance cache invalidation
await StartCacheCoordinatorAsync().ConfigureAwait(false);
Logger.LogInformation("Core startup complete");
CoreStartupHasCompleted = true;
}
return Task.CompletedTask;
private async Task RegisterInstanceAsync()
{
try
{
var instanceRegistry = Resolve<Jellyfin.Server.Implementations.Clustering.IInstanceRegistry>();
var networkConfig = ConfigurationManager.GetNetworkConfiguration();
var hostname = Environment.MachineName;
var processId = Environment.ProcessId;
var httpPort = networkConfig.InternalHttpPort;
var httpsPort = networkConfig.InternalHttpsPort > 0 ? networkConfig.InternalHttpsPort : (int?)null;
var version = ApplicationVersionString;
await instanceRegistry.RegisterInstanceAsync(
hostname,
processId,
httpPort,
httpsPort,
version,
capabilities: null,
CancellationToken.None).ConfigureAwait(false);
Logger.LogInformation(
"Instance registered: {InstanceId} on {Hostname}:{HttpPort}",
instanceRegistry.CurrentInstanceId,
hostname,
httpPort);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to register instance in database");
}
}
private async Task StartCacheCoordinatorAsync()
{
try
{
var cacheCoordinator = Resolve<Jellyfin.Server.Implementations.Clustering.ICacheCoordinator>();
await cacheCoordinator.StartAsync(CancellationToken.None).ConfigureAwait(false);
Logger.LogInformation("Cache coordinator started for multi-instance cache synchronization");
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to start cache coordinator");
}
}
/// <inheritdoc/>
@@ -492,7 +547,16 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton(NetManager);
serviceCollection.AddSingleton<ITaskManager, TaskManager>();
// Register the actual TaskManager
serviceCollection.AddSingleton<TaskManager>();
// Wrap it with primary instance checking decorator
serviceCollection.AddSingleton<ITaskManager>(provider =>
{
var taskManager = provider.GetRequiredService<TaskManager>();
var primaryElection = provider.GetRequiredService<Jellyfin.Server.Implementations.Clustering.IPrimaryElectionService>();
var logger = provider.GetRequiredService<ILogger<Jellyfin.Server.Implementations.Clustering.PrimaryInstanceTaskManager>>();
return new Jellyfin.Server.Implementations.Clustering.PrimaryInstanceTaskManager(taskManager, primaryElection, logger);
});
serviceCollection.AddSingleton(_xmlSerializer);
@@ -517,7 +581,6 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<IMediaAttachmentRepository, MediaAttachmentRepository>();
serviceCollection.AddSingleton<IMediaStreamRepository, MediaStreamRepository>();
serviceCollection.AddSingleton<IKeyframeRepository, KeyframeRepository>();
serviceCollection.AddSingleton<ILibraryOptionsRepository, LibraryOptionsRepository>();
serviceCollection.AddSingleton<IItemTypeLookup, ItemTypeLookup>();
serviceCollection.AddSingleton<IMediaEncoder, MediaBrowser.MediaEncoding.Encoder.MediaEncoder>();
@@ -555,6 +618,16 @@ namespace Emby.Server.Implementations
serviceCollection.AddTransient(provider => new Lazy<ILiveTvManager>(provider.GetRequiredService<ILiveTvManager>));
serviceCollection.AddSingleton<IDtoService, DtoService>();
// Multi-instance support services
serviceCollection.AddSingleton<Jellyfin.Server.Implementations.Clustering.IInstanceRegistry, Jellyfin.Server.Implementations.Clustering.InstanceRegistry>();
serviceCollection.AddSingleton<Jellyfin.Server.Implementations.Clustering.IDistributedLockManager, Jellyfin.Server.Implementations.Clustering.DistributedLockManager>();
serviceCollection.AddSingleton<Jellyfin.Server.Implementations.Clustering.IPostgresNotificationListener, Jellyfin.Server.Implementations.Clustering.PostgresNotificationListener>();
serviceCollection.AddSingleton<Jellyfin.Server.Implementations.Clustering.ICacheCoordinator, Jellyfin.Server.Implementations.Clustering.CacheCoordinator>();
serviceCollection.AddSingleton<Jellyfin.Server.Implementations.Clustering.IPrimaryElectionService, Jellyfin.Server.Implementations.Clustering.PrimaryElectionService>();
serviceCollection.AddHostedService(provider => (Jellyfin.Server.Implementations.Clustering.PrimaryElectionService)provider.GetRequiredService<Jellyfin.Server.Implementations.Clustering.IPrimaryElectionService>());
serviceCollection.AddSingleton<Jellyfin.Server.Implementations.Clustering.IFileSystemChangeProcessor, Jellyfin.Server.Implementations.Clustering.FileSystemChangeProcessor>();
serviceCollection.AddHostedService(provider => (Jellyfin.Server.Implementations.Clustering.FileSystemChangeProcessor)provider.GetRequiredService<Jellyfin.Server.Implementations.Clustering.IFileSystemChangeProcessor>());
serviceCollection.AddSingleton<ISessionManager, SessionManager>();
serviceCollection.AddSingleton<ICollectionManager, CollectionManager>();
@@ -601,37 +674,11 @@ namespace Emby.Server.Implementations
FindParts();
BackfillLibraryOptionsFromXml();
// Ensure at least one user exists
var userManager = Resolve<IUserManager>();
await userManager.InitializeAsync().ConfigureAwait(false);
}
private void BackfillLibraryOptionsFromXml()
{
var defaultUserViewsPath = ConfigurationManager.ApplicationPaths.DefaultUserViewsPath;
if (!Directory.Exists(defaultUserViewsPath))
{
return;
}
try
{
foreach (var virtualFolderPath in Directory.GetDirectories(defaultUserViewsPath))
{
// DB-first with XML fallback is implemented in CollectionFolder.GetLibraryOptions.
// Calling this at startup backfills existing options.xml rows without waiting for first user read.
_ = CollectionFolder.GetLibraryOptions(virtualFolderPath);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error backfilling library options from {DefaultUserViewsPath}", defaultUserViewsPath);
}
}
private X509Certificate2 GetCertificate(string path, string password)
{
if (string.IsNullOrWhiteSpace(path))
@@ -685,7 +732,6 @@ namespace Emby.Server.Implementations
BaseItem.UserDataManager = Resolve<IUserDataManager>();
CollectionFolder.XmlSerializer = _xmlSerializer;
CollectionFolder.ApplicationHost = this;
CollectionFolder.LibraryOptionsRepository = Resolve<ILibraryOptionsRepository>();
Folder.UserViewManager = Resolve<IUserViewManager>();
Folder.CollectionManager = Resolve<ICollectionManager>();
Folder.LimitedConcurrencyLibraryScheduler = Resolve<ILimitedConcurrencyLibraryScheduler>();
@@ -18,6 +18,7 @@ namespace Emby.Server.Implementations
{ FfmpegProbeSizeKey, "1G" },
{ FfmpegAnalyzeDurationKey, "200M" },
{ BindToUnixSocketKey, bool.FalseString },
{ SqliteCacheSizeKey, "20000" },
{ FfmpegSkipValidationKey, bool.FalseString },
{ FfmpegImgExtractPerfTradeoffKey, bool.FalseString },
{ DetectNetworkChangeKey, bool.TrueString }
@@ -108,15 +108,14 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
// Use the execution strategy to handle transactional consistency and retries
// This is required for PostgreSQL NpgsqlRetryingExecutionStrategy
var executionStrategy = context.Database.CreateExecutionStrategy();
await executionStrategy.ExecuteAsync(async () =>
var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using (transaction.ConfigureAwait(false))
{
await context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
subProgress.Report(50);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
subProgress.Report(100);
}).ConfigureAwait(false);
}
}
progress.Report(100);
@@ -2,7 +2,6 @@
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517;NU1510</NoWarn>
<ProjectGuid>{E383961B-9356-4D5D-8233-9A1079D03055}</ProjectGuid>
</PropertyGroup>
@@ -25,9 +24,10 @@
<ItemGroup>
<PackageReference Include="BitFaster.Caching" />
<PackageReference Include="DiscUtils.Udf" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="prometheus-net.DotNetRuntime" />
<PackageReference Include="DotNet.Glob" />
@@ -38,14 +38,12 @@
</ItemGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<TargetFramework>net11.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<CodeAnalysisTreatWarningsAsErrors>false</CodeAnalysisTreatWarningsAsErrors>
</PropertyGroup>
@@ -22,15 +22,6 @@ namespace Emby.Server.Implementations.HttpServer
/// <summary>
/// Class WebSocketConnection.
/// Represents an authenticated WebSocket connection to a Jellyfin client.
///
/// Authentication is performed during connection establishment in WebSocketManager.
/// The client must provide a valid API token via one of these methods:
/// - Query parameter: ws://server:8096/socket?api_key=TOKEN
/// - Authorization header: MediaBrowser Token="..."
/// - Legacy headers: X-Emby-Token or X-MediaBrowser-Token
///
/// Once established, AuthorizationInfo contains the authenticated user/device information.
/// </summary>
public class WebSocketConnection : IWebSocketConnection
{
@@ -56,7 +47,7 @@ namespace Emby.Server.Implementations.HttpServer
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="socket">The socket.</param>
/// <param name="authorizationInfo">The authorization information containing authenticated user/device details.</param>
/// <param name="authorizationInfo">The authorization information.</param>
/// <param name="remoteEndPoint">The remote end point.</param>
public WebSocketConnection(
ILogger<WebSocketConnection> logger,
@@ -18,25 +18,6 @@ using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.HttpServer
{
/// <summary>
/// Manages WebSocket connections with authentication support.
///
/// Clients should authenticate when connecting to WebSocket endpoints by providing an API token
/// through one of these methods:
///
/// 1. Query String Parameter (Recommended):
/// - URL: ws://jellyfin-server:8096/socket?api_key=YOUR_API_TOKEN
/// - JavaScript: const ws = new WebSocket(`ws://jellyfin-server:8096/socket?api_key=${token}`);
///
/// 2. Query String Parameter (Legacy):
/// - URL: ws://jellyfin-server:8096/socket?ApiKey=YOUR_API_TOKEN
///
/// 3. Authorization Header:
/// - Header: MediaBrowser Device="...", Token="YOUR_API_TOKEN"
///
/// The API token can be obtained from the server's API key management interface or
/// generated for specific clients/devices.
/// </summary>
public class WebSocketManager : IWebSocketManager
{
private readonly IWebSocketListener[] _webSocketListeners;
@@ -1223,26 +1223,29 @@ namespace Emby.Server.Implementations.Library
private async Task RunPostScanTasks(IProgress<double> progress, CancellationToken cancellationToken)
{
var tasks = PostScanTasks.ToList();
var numComplete = 0;
var numTasks = tasks.Count;
if (numTasks == 0)
foreach (var task in tasks)
{
_itemRepository.UpdateInheritedValues();
progress.Report(100);
return;
}
// Prevent access to modified closure
var currentNumComplete = numComplete;
var progressValues = new double[numTasks];
async Task RunOneTask(ILibraryPostScanTask task, int taskIndex)
{
_logger.LogDebug("Running post-scan task {0}", task.GetType().Name);
var innerProgress = new Progress<double>(pct =>
{
progressValues[taskIndex] = pct;
progress.Report(progressValues.Average());
double innerPercent = pct;
innerPercent /= 100;
innerPercent += currentNumComplete;
innerPercent /= numTasks;
innerPercent *= 100;
progress.Report(innerPercent);
});
_logger.LogDebug("Running post-scan task {0}", task.GetType().Name);
try
{
await task.Run(innerProgress, cancellationToken).ConfigureAwait(false);
@@ -1257,12 +1260,12 @@ namespace Emby.Server.Implementations.Library
_logger.LogError(ex, "Error running post-scan task");
}
progressValues[taskIndex] = 100;
progress.Report(progressValues.Average());
numComplete++;
double percent = numComplete;
percent /= numTasks;
progress.Report(percent * 100);
}
await Task.WhenAll(tasks.Select((task, i) => RunOneTask(task, i))).ConfigureAwait(false);
_itemRepository.UpdateInheritedValues();
progress.Report(100);
@@ -1994,7 +1997,7 @@ namespace Emby.Server.Implementations.Library
/// <inheritdoc />
public void CreateItems(IReadOnlyList<BaseItem> items, BaseItem? parent, CancellationToken cancellationToken)
{
_itemRepository.SaveItemsAsync(items, cancellationToken).GetAwaiter().GetResult();
_itemRepository.SaveItems(items, cancellationToken);
foreach (var item in items)
{
@@ -2162,7 +2165,7 @@ namespace Emby.Server.Implementations.Library
item.DateLastSaved = DateTime.UtcNow;
}
await _itemRepository.SaveItemsAsync(items, cancellationToken).ConfigureAwait(false);
_itemRepository.SaveItems(items, cancellationToken);
if (parent is Folder folder)
{
@@ -1,177 +0,0 @@
// <copyright file="LibraryOptionsRepository.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
#nullable disable
namespace Emby.Server.Implementations.Library;
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions.Json;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
/// <summary>
/// Repository for persisting collection-folder library options in the database.
/// </summary>
public class LibraryOptionsRepository : ILibraryOptionsRepository
{
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IServerApplicationHost _appHost;
private readonly ILogger<LibraryOptionsRepository> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="LibraryOptionsRepository"/> class.
/// </summary>
/// <param name="dbProvider">The db context factory.</param>
/// <param name="appHost">The application host.</param>
/// <param name="logger">The logger.</param>
public LibraryOptionsRepository(
IDbContextFactory<JellyfinDbContext> dbProvider,
IServerApplicationHost appHost,
ILogger<LibraryOptionsRepository> logger)
{
_dbProvider = dbProvider;
_appHost = appHost;
_logger = logger;
}
/// <inheritdoc />
public LibraryOptions GetLibraryOptions(string libraryPath)
=> GetLibraryOptionsAsync(libraryPath, CancellationToken.None).GetAwaiter().GetResult();
/// <inheritdoc />
public void SaveLibraryOptions(string libraryPath, LibraryOptions options)
=> SaveLibraryOptionsAsync(libraryPath, options, CancellationToken.None).GetAwaiter().GetResult();
/// <summary>
/// Gets library options from the database if the backing table is available.
/// </summary>
/// <param name="libraryPath">The collection folder path.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The expanded library options, or <c>null</c> if no row exists.</returns>
public async Task<LibraryOptions> GetLibraryOptionsAsync(string libraryPath, CancellationToken cancellationToken = default)
{
try
{
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
var entity = await context.LibraryOptions
.AsNoTracking()
.SingleOrDefaultAsync(e => e.LibraryPath == libraryPath, cancellationToken)
.ConfigureAwait(false);
if (entity is null)
{
return null;
}
var options = JsonSerializer.Deserialize<LibraryOptions>(entity.OptionsJson, JsonDefaults.Options) ?? new LibraryOptions();
foreach (var mediaPath in options.PathInfos)
{
if (!string.IsNullOrEmpty(mediaPath.Path))
{
mediaPath.Path = _appHost.ExpandVirtualPath(mediaPath.Path);
}
}
return options;
}
}
catch (Exception ex) when (IsMissingTableException(ex))
{
_logger.LogDebug(ex, "LibraryOptions table is not available yet. Falling back to XML for {LibraryPath}", libraryPath);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading library options from database for {LibraryPath}", libraryPath);
return null;
}
}
/// <summary>
/// Saves library options to the database if the backing table is available.
/// </summary>
/// <param name="libraryPath">The collection folder path.</param>
/// <param name="options">The options to save.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public async Task SaveLibraryOptionsAsync(string libraryPath, LibraryOptions options, CancellationToken cancellationToken = default)
{
try
{
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
var executionStrategy = context.Database.CreateExecutionStrategy();
await executionStrategy.ExecuteAsync(async () =>
{
var clone = JsonSerializer.Deserialize<LibraryOptions>(JsonSerializer.SerializeToUtf8Bytes(options, JsonDefaults.Options), JsonDefaults.Options)
?? new LibraryOptions();
foreach (var mediaPath in clone.PathInfos)
{
if (!string.IsNullOrEmpty(mediaPath.Path))
{
mediaPath.Path = _appHost.ReverseVirtualPath(mediaPath.Path);
}
}
var entity = await context.LibraryOptions
.SingleOrDefaultAsync(e => e.LibraryPath == libraryPath, cancellationToken)
.ConfigureAwait(false);
var utcNow = DateTime.UtcNow;
var json = JsonSerializer.Serialize(clone, JsonDefaults.Options);
if (entity is null)
{
entity = new LibraryOptionsEntity
{
LibraryPath = libraryPath,
OptionsJson = json,
Version = 1,
DateCreated = utcNow,
DateModified = utcNow
};
await context.LibraryOptions.AddAsync(entity, cancellationToken).ConfigureAwait(false);
}
else
{
entity.OptionsJson = json;
entity.DateModified = utcNow;
}
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}).ConfigureAwait(false);
}
}
catch (Exception ex) when (IsMissingTableException(ex))
{
_logger.LogDebug(ex, "LibraryOptions table is not available yet. Skipping database save for {LibraryPath}", libraryPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving library options to database for {LibraryPath}", libraryPath);
}
}
private static bool IsMissingTableException(Exception exception)
{
return exception.Message.Contains("LibraryOptions", StringComparison.OrdinalIgnoreCase)
&& (exception.Message.Contains("does not exist", StringComparison.OrdinalIgnoreCase)
|| exception.Message.Contains("no such table", StringComparison.OrdinalIgnoreCase));
}
}
@@ -62,28 +62,24 @@ namespace Emby.Server.Implementations.Library
var keys = item.GetUserDataKeys();
using var dbContext = _repository.CreateDbContext();
var executionStrategy = dbContext.Database.CreateExecutionStrategy();
executionStrategy.Execute(() =>
using var transaction = dbContext.Database.BeginTransaction();
foreach (var key in keys)
{
using var transaction = dbContext.Database.BeginTransaction();
foreach (var key in keys)
userData.Key = key;
var userDataEntry = Map(userData, user.Id, item.Id);
if (dbContext.UserData.Any(f => f.ItemId == userDataEntry.ItemId && f.UserId == userDataEntry.UserId && f.CustomDataKey == userDataEntry.CustomDataKey))
{
userData.Key = key;
var userDataEntry = Map(userData, user.Id, item.Id);
if (dbContext.UserData.Any(f => f.ItemId == userDataEntry.ItemId && f.UserId == userDataEntry.UserId && f.CustomDataKey == userDataEntry.CustomDataKey))
{
dbContext.UserData.Attach(userDataEntry).State = EntityState.Modified;
}
else
{
dbContext.UserData.Add(userDataEntry);
}
dbContext.UserData.Attach(userDataEntry).State = EntityState.Modified;
}
else
{
dbContext.UserData.Add(userDataEntry);
}
}
dbContext.SaveChanges();
transaction.Commit();
});
dbContext.SaveChanges();
transaction.Commit();
var userId = user.InternalId;
var cacheKey = GetCacheKey(userId, item.Id);
@@ -145,11 +145,6 @@ namespace Emby.Server.Implementations.Library
var sorted = _libraryManager.Sort(list, user, new[] { ItemSortBy.SortName }, SortOrder.Ascending).ToList();
var orders = user.GetPreferenceValues<Guid>(PreferenceKind.OrderedViews);
// Create a lookup dictionary for efficient and reliable ordering
var sortedIndexLookup = sorted
.Select((item, index) => new { item, index })
.ToDictionary(x => x.item.Id, x => x.index);
return list
.OrderBy(i =>
{
@@ -163,7 +158,7 @@ namespace Emby.Server.Implementations.Library
return index == -1 ? int.MaxValue : index;
})
.ThenBy(i => sortedIndexLookup.TryGetValue(i.Id, out var sortIndex) ? sortIndex : int.MaxValue)
.ThenBy(sorted.IndexOf)
.ThenBy(i => i.SortName)
.ToArray();
}
@@ -64,6 +64,7 @@ namespace Emby.Server.Implementations.Session
private readonly IMediaSourceManager _mediaSourceManager;
private readonly IServerApplicationHost _appHost;
private readonly IDeviceManager _deviceManager;
private readonly Jellyfin.Server.Implementations.Clustering.IInstanceRegistry _instanceRegistry;
private readonly CancellationTokenRegistration _shutdownCallback;
private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections
= new(StringComparer.OrdinalIgnoreCase);
@@ -93,6 +94,7 @@ namespace Emby.Server.Implementations.Session
/// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
/// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param>
/// <param name="hostApplicationLifetime">Instance of <see cref="IHostApplicationLifetime"/> interface.</param>
/// <param name="instanceRegistry">Instance registry for multi-instance support (optional for backward compatibility).</param>
public SessionManager(
ILogger<SessionManager> logger,
IEventManager eventManager,
@@ -106,7 +108,8 @@ namespace Emby.Server.Implementations.Session
IServerApplicationHost appHost,
IDeviceManager deviceManager,
IMediaSourceManager mediaSourceManager,
IHostApplicationLifetime hostApplicationLifetime)
IHostApplicationLifetime hostApplicationLifetime,
Jellyfin.Server.Implementations.Clustering.IInstanceRegistry instanceRegistry = null)
{
_logger = logger;
_eventManager = eventManager;
@@ -120,6 +123,7 @@ namespace Emby.Server.Implementations.Session
_appHost = appHost;
_deviceManager = deviceManager;
_mediaSourceManager = mediaSourceManager;
_instanceRegistry = instanceRegistry;
_shutdownCallback = hostApplicationLifetime.ApplicationStopping.Register(OnApplicationStopping);
_deviceManager.DeviceOptionsUpdated += OnDeviceManagerDeviceOptionsUpdated;
@@ -156,10 +160,30 @@ namespace Emby.Server.Implementations.Session
public event EventHandler<SessionEventArgs> SessionControllerConnected;
/// <summary>
/// Gets all connections.
/// Gets all connections for the current instance.
/// </summary>
/// <value>All connections.</value>
public IEnumerable<SessionInfo> Sessions => _activeConnections.Values.OrderByDescending(c => c.LastActivityDate);
/// <remarks>
/// In multi-instance deployments, only returns sessions owned by the current instance.
/// In single-instance deployments (when _instanceRegistry is null), returns all sessions.
/// </remarks>
public IEnumerable<SessionInfo> Sessions
{
get
{
if (_instanceRegistry is null)
{
// Single-instance mode: return all sessions
return _activeConnections.Values.OrderByDescending(c => c.LastActivityDate);
}
// Multi-instance mode: only return sessions for this instance
var currentInstanceId = _instanceRegistry.CurrentInstanceId;
return _activeConnections.Values
.Where(s => s.InstanceId.Equals(currentInstanceId))
.OrderByDescending(c => c.LastActivityDate);
}
}
private void OnDeviceManagerDeviceOptionsUpdated(object sender, GenericEventArgs<Tuple<string, DeviceOptions>> e)
{
@@ -556,7 +580,8 @@ namespace Emby.Server.Implementations.Session
DeviceId = deviceId,
ApplicationVersion = appVersion,
Id = key.GetMD5().ToString("N", CultureInfo.InvariantCulture),
ServerId = _appHost.SystemId
ServerId = _appHost.SystemId,
InstanceId = _instanceRegistry?.CurrentInstanceId ?? Guid.Empty
};
var username = user?.Username;
@@ -1,302 +0,0 @@
// <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);
}
}
-2
View File
@@ -2,12 +2,10 @@
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<ProjectGuid>{DFBEFB4C-DA19-4143-98B7-27320C7F7163}</ProjectGuid>
</PropertyGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<TargetFramework>net11.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
+5 -16
View File
@@ -83,22 +83,11 @@ public class ExceptionMiddleware
{
if (isAuthenticationError)
{
// WebSocket authentication failures are expected when clients connect without tokens
// Log at Debug level to reduce noise, unless it's not a WebSocket endpoint
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug(
"WebSocket connection rejected: Authentication token missing or invalid. IP: {IP}",
context.Connection.RemoteIpAddress);
}
else
{
// Log authentication errors for non-WebSocket endpoints as warnings
_logger.LogWarning(
"Access denied: Unable to process request. Authentication token missing or invalid. URL {Method} {Url}.",
context.Request.Method,
context.Request.Path);
}
// Log authentication errors as warnings with user-friendly message
_logger.LogWarning(
"Access denied: Unable to process request. Authentication token missing or invalid. URL {Method} {Url}.",
context.Request.Method,
context.Request.Path);
}
else
{
@@ -1,135 +0,0 @@
// <copyright file="WebSocketAuthenticationMiddleware.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Api.Middleware;
using System;
using System.Globalization;
using System.Security.Claims;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Controller.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
/// <summary>
/// WebSocket Authentication Middleware.
/// Extracts and validates API tokens from WebSocket query strings.
/// </summary>
public class WebSocketAuthenticationMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<WebSocketAuthenticationMiddleware> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="WebSocketAuthenticationMiddleware"/> class.
/// </summary>
/// <param name="next">The next delegate in the pipeline.</param>
/// <param name="logger">The logger.</param>
public WebSocketAuthenticationMiddleware(RequestDelegate next, ILogger<WebSocketAuthenticationMiddleware> logger)
{
_next = next;
_logger = logger;
}
/// <summary>
/// Executes the middleware action.
/// </summary>
/// <param name="httpContext">The current HTTP context.</param>
/// <param name="authService">The authentication service.</param>
/// <returns>The async task.</returns>
public async Task Invoke(HttpContext httpContext, IAuthService authService)
{
// Only process WebSocket upgrade requests on the /socket endpoint
if (httpContext.WebSockets.IsWebSocketRequest
&& httpContext.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase))
{
await AuthenticateWebSocketRequest(httpContext, authService).ConfigureAwait(false);
}
await _next(httpContext).ConfigureAwait(false);
}
/// <summary>
/// Authenticates WebSocket requests by extracting api_key from query string.
/// </summary>
/// <param name="httpContext">The HTTP context.</param>
/// <param name="authService">The authentication service.</param>
/// <returns>The async task.</returns>
private async Task AuthenticateWebSocketRequest(HttpContext httpContext, IAuthService authService)
{
try
{
// Extract api_key from query string
if (!httpContext.Request.Query.TryGetValue("api_key", out var apiKeyValues) || apiKeyValues.Count == 0)
{
_logger.LogDebug(
"WebSocket connection attempted without api_key query parameter. IP: {IP}",
httpContext.Connection.RemoteIpAddress);
return;
}
var apiKey = apiKeyValues[0];
if (string.IsNullOrWhiteSpace(apiKey))
{
_logger.LogDebug(
"WebSocket connection attempted with empty api_key. IP: {IP}",
httpContext.Connection.RemoteIpAddress);
return;
}
// Authenticate using the provided api_key
var authorizationInfo = await authService.Authenticate(httpContext.Request).ConfigureAwait(false);
if (!authorizationInfo.HasToken)
{
_logger.LogDebug(
"WebSocket authentication failed: Invalid or expired token. IP: {IP}",
httpContext.Connection.RemoteIpAddress);
return;
}
// Set the authenticated user in the context for downstream middleware
var role = UserRoles.User;
if (authorizationInfo.IsApiKey
|| (authorizationInfo.User?.HasPermission(PermissionKind.IsAdministrator) ?? false))
{
role = UserRoles.Administrator;
}
var claims = new[]
{
new Claim(ClaimTypes.Name, authorizationInfo.User?.Username ?? string.Empty),
new Claim(ClaimTypes.Role, role),
new Claim(InternalClaimTypes.UserId, authorizationInfo.UserId.ToString("N", CultureInfo.InvariantCulture)),
new Claim(InternalClaimTypes.DeviceId, authorizationInfo.DeviceId ?? string.Empty),
new Claim(InternalClaimTypes.Device, authorizationInfo.Device ?? string.Empty),
new Claim(InternalClaimTypes.Client, authorizationInfo.Client ?? string.Empty),
new Claim(InternalClaimTypes.Version, authorizationInfo.Version ?? string.Empty),
new Claim(InternalClaimTypes.Token, authorizationInfo.Token),
new Claim(InternalClaimTypes.IsApiKey, authorizationInfo.IsApiKey.ToString(CultureInfo.InvariantCulture))
};
var identity = new ClaimsIdentity(claims, "WebSocket");
var principal = new ClaimsPrincipal(identity);
httpContext.User = principal;
_logger.LogDebug(
"WebSocket authentication successful for user {Username}. IP: {IP}",
authorizationInfo.User?.Username ?? "Unknown",
httpContext.Connection.RemoteIpAddress);
}
catch (Exception ex)
{
_logger.LogDebug(
ex,
"Error during WebSocket authentication. IP: {IP}",
httpContext.Connection.RemoteIpAddress);
}
}
}
@@ -1,23 +0,0 @@
// <copyright file="WebSocketAuthenticationMiddlewareExtensions.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Api.Middleware;
using Microsoft.AspNetCore.Builder;
/// <summary>
/// Extension methods for adding WebSocket authentication to the pipeline.
/// </summary>
public static class WebSocketAuthenticationMiddlewareExtensions
{
/// <summary>
/// Adds WebSocket authentication to the application pipeline.
/// </summary>
/// <param name="appBuilder">The application builder.</param>
/// <returns>The updated application builder.</returns>
public static IApplicationBuilder UseWebSocketAuthentication(this IApplicationBuilder appBuilder)
{
return appBuilder.UseMiddleware<WebSocketAuthenticationMiddleware>();
}
}
-3
View File
@@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<TargetFramework>net11.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
@@ -12,13 +11,11 @@
</PropertyGroup>
<PropertyGroup Condition=" '$(Stability)'=='Unstable'">
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<!-- Include all symbols in the main nupkg until Azure Artifact Feed starts supporting ingesting NuGet symbol packages. -->
<AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder>
</PropertyGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Data</PackageId>
<VersionPrefix>10.12.0</VersionPrefix>
@@ -0,0 +1,309 @@
// <copyright file="CacheCoordinator.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Server.Implementations.Clustering
{
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
/// <summary>
/// Coordinates cache invalidation across multiple Jellyfin instances using PostgreSQL LISTEN/NOTIFY.
/// </summary>
public sealed class CacheCoordinator : ICacheCoordinator, IAsyncDisposable
{
private const string CacheInvalidationChannel = "jellyfin_cache_invalidation";
private readonly IPostgresNotificationListener _notificationListener;
private readonly IInstanceRegistry _instanceRegistry;
private readonly ILogger<CacheCoordinator> _logger;
private readonly ILibraryManager? _libraryManager;
private bool _disposed;
private bool _started;
/// <summary>
/// Initializes a new instance of the <see cref="CacheCoordinator"/> class.
/// </summary>
/// <param name="notificationListener">The PostgreSQL notification listener.</param>
/// <param name="instanceRegistry">The instance registry.</param>
/// <param name="logger">The logger.</param>
/// <param name="libraryManager">The library manager (optional).</param>
public CacheCoordinator(
IPostgresNotificationListener notificationListener,
IInstanceRegistry instanceRegistry,
ILogger<CacheCoordinator> logger,
ILibraryManager? libraryManager = null)
{
_notificationListener = notificationListener;
_instanceRegistry = instanceRegistry;
_logger = logger;
_libraryManager = libraryManager;
}
/// <inheritdoc/>
public async Task StartAsync(CancellationToken cancellationToken = default)
{
if (_started)
{
_logger.LogWarning("Cache coordinator already started");
return;
}
try
{
_notificationListener.NotificationReceived += OnNotificationReceived;
await _notificationListener.StartListeningAsync(CacheInvalidationChannel, cancellationToken).ConfigureAwait(false);
_started = true;
_logger.LogInformation("Cache coordinator started successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start cache coordinator");
throw;
}
}
/// <inheritdoc/>
public async Task StopAsync(CancellationToken cancellationToken = default)
{
if (!_started)
{
return;
}
try
{
_notificationListener.NotificationReceived -= OnNotificationReceived;
await _notificationListener.StopListeningAsync(cancellationToken).ConfigureAwait(false);
_started = false;
_logger.LogInformation("Cache coordinator stopped");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error stopping cache coordinator");
}
}
/// <inheritdoc/>
public async Task InvalidateItemAsync(Guid itemId, CancellationToken cancellationToken = default)
{
var message = new CacheInvalidationMessage
{
Type = CacheInvalidationType.Item,
EntityId = itemId,
Timestamp = DateTime.UtcNow,
SourceInstanceId = _instanceRegistry.CurrentInstanceId
};
await SendInvalidationMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task InvalidateUserDataAsync(Guid userId, CancellationToken cancellationToken = default)
{
var message = new CacheInvalidationMessage
{
Type = CacheInvalidationType.UserData,
EntityId = userId,
Timestamp = DateTime.UtcNow,
SourceInstanceId = _instanceRegistry.CurrentInstanceId
};
await SendInvalidationMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task InvalidateImageAsync(Guid itemId, CancellationToken cancellationToken = default)
{
var message = new CacheInvalidationMessage
{
Type = CacheInvalidationType.Image,
EntityId = itemId,
Timestamp = DateTime.UtcNow,
SourceInstanceId = _instanceRegistry.CurrentInstanceId
};
await SendInvalidationMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task InvalidateAllAsync(CacheInvalidationType cacheType, CancellationToken cancellationToken = default)
{
var message = new CacheInvalidationMessage
{
Type = cacheType,
EntityId = null,
Timestamp = DateTime.UtcNow,
SourceInstanceId = _instanceRegistry.CurrentInstanceId
};
await SendInvalidationMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task InvalidateLibraryAsync(Guid libraryId, CancellationToken cancellationToken = default)
{
var message = new CacheInvalidationMessage
{
Type = CacheInvalidationType.Library,
EntityId = libraryId,
Timestamp = DateTime.UtcNow,
SourceInstanceId = _instanceRegistry.CurrentInstanceId
};
await SendInvalidationMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task InvalidatePersonAsync(Guid personId, CancellationToken cancellationToken = default)
{
var message = new CacheInvalidationMessage
{
Type = CacheInvalidationType.Person,
EntityId = personId,
Timestamp = DateTime.UtcNow,
SourceInstanceId = _instanceRegistry.CurrentInstanceId
};
await SendInvalidationMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task InvalidateMetadataAsync(Guid itemId, CancellationToken cancellationToken = default)
{
var message = new CacheInvalidationMessage
{
Type = CacheInvalidationType.Metadata,
EntityId = itemId,
Timestamp = DateTime.UtcNow,
SourceInstanceId = _instanceRegistry.CurrentInstanceId
};
await SendInvalidationMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
await StopAsync(CancellationToken.None).ConfigureAwait(false);
}
private async Task SendInvalidationMessageAsync(CacheInvalidationMessage message, CancellationToken cancellationToken)
{
try
{
var json = JsonSerializer.Serialize(message);
await _notificationListener.NotifyAsync(CacheInvalidationChannel, json, cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Sent cache invalidation: Type={Type}, EntityId={EntityId}",
message.Type,
message.EntityId);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to send cache invalidation message: Type={Type}, EntityId={EntityId}",
message.Type,
message.EntityId);
}
}
private void OnNotificationReceived(object? sender, PostgresNotificationEventArgs e)
{
try
{
var message = JsonSerializer.Deserialize<CacheInvalidationMessage>(e.Payload);
if (message is null)
{
_logger.LogWarning("Received invalid cache invalidation message");
return;
}
// Don't process messages from this instance
if (message.SourceInstanceId.Equals(_instanceRegistry.CurrentInstanceId))
{
return;
}
_logger.LogDebug(
"Processing cache invalidation from instance {SourceInstanceId}: Type={Type}, EntityId={EntityId}",
message.SourceInstanceId,
message.Type,
message.EntityId);
ProcessInvalidationMessage(message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing cache invalidation notification");
}
}
private void ProcessInvalidationMessage(CacheInvalidationMessage message)
{
// TODO: Actual cache invalidation logic would go here
// This is a placeholder that demonstrates the pattern
// The actual implementation would call into Jellyfin's cache managers
switch (message.Type)
{
case CacheInvalidationType.Item:
if (message.EntityId.HasValue && _libraryManager is not null)
{
// Example: _libraryManager.InvalidateItemCache(message.EntityId.Value);
_logger.LogDebug("Invalidating item cache for {ItemId}", message.EntityId.Value);
}
break;
case CacheInvalidationType.UserData:
if (message.EntityId.HasValue)
{
_logger.LogDebug("Invalidating user data cache for {UserId}", message.EntityId.Value);
}
break;
case CacheInvalidationType.Image:
if (message.EntityId.HasValue)
{
_logger.LogDebug("Invalidating image cache for {ItemId}", message.EntityId.Value);
}
break;
case CacheInvalidationType.Library:
if (message.EntityId.HasValue)
{
_logger.LogDebug("Invalidating library cache for {LibraryId}", message.EntityId.Value);
}
break;
case CacheInvalidationType.All:
_logger.LogDebug("Invalidating all caches of type {CacheType}", message.Type);
break;
default:
_logger.LogWarning("Unknown cache invalidation type: {Type}", message.Type);
break;
}
}
}
}
@@ -0,0 +1,107 @@
// <copyright file="CacheInvalidationMessage.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Server.Implementations.Clustering
{
using System;
using System.Text.Json.Serialization;
/// <summary>
/// Types of cache invalidation.
/// </summary>
public enum CacheInvalidationType
{
/// <summary>
/// Invalidate a specific item cache.
/// </summary>
Item,
/// <summary>
/// Invalidate user data cache.
/// </summary>
UserData,
/// <summary>
/// Invalidate image cache.
/// </summary>
Image,
/// <summary>
/// Invalidate chapter image cache.
/// </summary>
ChapterImage,
/// <summary>
/// Invalidate metadata cache.
/// </summary>
Metadata,
/// <summary>
/// Invalidate all caches of a specific type.
/// </summary>
All,
/// <summary>
/// Invalidate library cache.
/// </summary>
Library,
/// <summary>
/// Invalidate person cache.
/// </summary>
Person,
/// <summary>
/// Invalidate user cache.
/// </summary>
User,
/// <summary>
/// Invalidate device cache.
/// </summary>
Device
}
/// <summary>
/// Represents a cache invalidation message sent between instances.
/// </summary>
public class CacheInvalidationMessage
{
/// <summary>
/// Gets or sets the type of cache being invalidated.
/// </summary>
[JsonPropertyName("type")]
public CacheInvalidationType Type { get; set; }
/// <summary>
/// Gets or sets the ID of the entity being invalidated (if applicable).
/// </summary>
[JsonPropertyName("entityId")]
public Guid? EntityId { get; set; }
/// <summary>
/// Gets or sets the cache key pattern to invalidate.
/// </summary>
[JsonPropertyName("cacheKey")]
public string? CacheKey { get; set; }
/// <summary>
/// Gets or sets the timestamp when this message was created.
/// </summary>
[JsonPropertyName("timestamp")]
public DateTime Timestamp { get; set; }
/// <summary>
/// Gets or sets the instance ID that originated this message.
/// </summary>
[JsonPropertyName("sourceInstanceId")]
public Guid SourceInstanceId { get; set; }
/// <summary>
/// Gets or sets additional metadata about the invalidation.
/// </summary>
[JsonPropertyName("metadata")]
public string? Metadata { get; set; }
}
}
@@ -0,0 +1,427 @@
// <copyright file="DistributedLockManager.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Server.Implementations.Clustering
{
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
/// <summary>
/// Implementation of distributed lock manager using database-backed locks.
/// </summary>
public sealed class DistributedLockManager : IDistributedLockManager, IDisposable
{
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
private readonly ILogger<DistributedLockManager> _logger;
private readonly Guid _instanceId;
private readonly ConcurrentDictionary<string, DistributedLockHandle> _heldLocks;
private readonly TimeSpan _defaultLockDuration;
private readonly Timer? _cleanupTimer;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="DistributedLockManager"/> class.
/// </summary>
/// <param name="dbContextFactory">The database context factory.</param>
/// <param name="logger">The logger.</param>
/// <param name="instanceId">The current instance ID.</param>
public DistributedLockManager(
IDbContextFactory<JellyfinDbContext> dbContextFactory,
ILogger<DistributedLockManager> logger,
Guid instanceId)
{
_dbContextFactory = dbContextFactory;
_logger = logger;
_instanceId = instanceId;
_heldLocks = new ConcurrentDictionary<string, DistributedLockHandle>();
_defaultLockDuration = TimeSpan.FromMinutes(5);
// Start cleanup timer - runs every minute
_cleanupTimer = new Timer(
CleanupTimerCallback,
null,
TimeSpan.FromMinutes(1),
TimeSpan.FromMinutes(1));
}
/// <inheritdoc />
public async Task<IDistributedLockHandle?> TryAcquireLockAsync(
string lockName,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(lockName);
var startTime = DateTime.UtcNow;
var endTime = startTime.Add(timeout);
while (DateTime.UtcNow < endTime)
{
try
{
var handle = await AcquireLockInternalAsync(lockName, cancellationToken).ConfigureAwait(false);
if (handle is not null)
{
return handle;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error attempting to acquire lock '{LockName}'", lockName);
}
// Wait a bit before retrying
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken).ConfigureAwait(false);
}
_logger.LogDebug("Failed to acquire lock '{LockName}' within timeout {Timeout}", lockName, timeout);
return null;
}
/// <inheritdoc />
public async Task<IDistributedLockHandle> AcquireLockAsync(
string lockName,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(lockName);
while (!cancellationToken.IsCancellationRequested)
{
try
{
var handle = await AcquireLockInternalAsync(lockName, cancellationToken).ConfigureAwait(false);
if (handle is not null)
{
return handle;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error attempting to acquire lock '{LockName}'", lockName);
}
// Wait before retrying
await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken).ConfigureAwait(false);
}
throw new OperationCanceledException("Lock acquisition was cancelled");
}
/// <inheritdoc />
public async Task<bool> IsLockHeldAsync(string lockName, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(lockName);
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var lockEntity = await context.DistributedLocks
.AsNoTracking()
.FirstOrDefaultAsync(l => l.LockName == lockName, cancellationToken)
.ConfigureAwait(false);
if (lockEntity is null)
{
return false;
}
// Check if lock is expired
if (lockEntity.IsExpired())
{
// Expired locks are considered not held
return false;
}
return true;
}
/// <inheritdoc />
public async Task ReleaseAllLocksAsync(CancellationToken cancellationToken = default)
{
_logger.LogInformation("Releasing all locks held by instance {InstanceId}", _instanceId);
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var locks = await context.DistributedLocks
.Where(l => l.InstanceId.Equals(_instanceId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (locks.Count > 0)
{
context.DistributedLocks.RemoveRange(locks);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Released {Count} locks", locks.Count);
}
// Clear local tracking
_heldLocks.Clear();
}
/// <inheritdoc />
public async Task<int> CleanupExpiredLocksAsync(CancellationToken cancellationToken = default)
{
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var now = DateTime.UtcNow;
var expiredLocks = await context.DistributedLocks
.Where(l => l.ExpiresAt < now)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (expiredLocks.Count > 0)
{
context.DistributedLocks.RemoveRange(expiredLocks);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Cleaned up {Count} expired locks", expiredLocks.Count);
// Remove from local tracking
foreach (var expiredLock in expiredLocks)
{
_heldLocks.TryRemove(expiredLock.LockName, out _);
}
}
return expiredLocks.Count;
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed)
{
return;
}
_cleanupTimer?.Dispose();
// Release all locks on disposal
try
{
ReleaseAllLocksAsync(CancellationToken.None).GetAwaiter().GetResult();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error releasing locks during disposal");
}
_disposed = true;
}
private async Task<DistributedLockHandle?> AcquireLockInternalAsync(
string lockName,
CancellationToken cancellationToken)
{
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
// Use a transaction for atomic lock acquisition
await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
try
{
// Check if lock exists
var existingLock = await context.DistributedLocks
.FirstOrDefaultAsync(l => l.LockName == lockName, cancellationToken)
.ConfigureAwait(false);
if (existingLock is not null)
{
// Check if it's expired
if (existingLock.IsExpired())
{
// Remove expired lock
context.DistributedLocks.Remove(existingLock);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
else
{
// Lock is still valid and held by another instance
_logger.LogDebug(
"Lock '{LockName}' is held by instance {HolderId} until {ExpiresAt}",
lockName,
existingLock.InstanceId,
existingLock.ExpiresAt);
return null;
}
}
// Create new lock
var expiresAt = DateTime.UtcNow.Add(_defaultLockDuration);
var newLock = new DistributedLock(lockName, _instanceId, expiresAt);
context.DistributedLocks.Add(newLock);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Acquired lock '{LockName}' for instance {InstanceId} until {ExpiresAt}",
lockName,
_instanceId,
expiresAt);
// Create handle
var handle = new DistributedLockHandle(
lockName,
_instanceId,
newLock.AcquiredAt,
expiresAt,
this);
_heldLocks.TryAdd(lockName, handle);
return handle;
}
catch (DbUpdateException ex)
{
// Likely a race condition - another instance acquired the lock first
_logger.LogDebug(ex, "Concurrent lock acquisition detected for '{LockName}'", lockName);
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error acquiring lock '{LockName}'", lockName);
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
throw;
}
}
private async Task ReleaseLockInternalAsync(string lockName, CancellationToken cancellationToken)
{
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var lockEntity = await context.DistributedLocks
.FirstOrDefaultAsync(l => l.LockName == lockName && l.InstanceId.Equals(_instanceId), cancellationToken)
.ConfigureAwait(false);
if (lockEntity is not null)
{
context.DistributedLocks.Remove(lockEntity);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Released lock '{LockName}'", lockName);
}
_heldLocks.TryRemove(lockName, out _);
}
private async Task RenewLockInternalAsync(
string lockName,
TimeSpan additionalTime,
CancellationToken cancellationToken)
{
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var lockEntity = await context.DistributedLocks
.FirstOrDefaultAsync(l => l.LockName == lockName && l.InstanceId.Equals(_instanceId), cancellationToken)
.ConfigureAwait(false);
if (lockEntity is not null)
{
lockEntity.Renew(additionalTime);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Renewed lock '{LockName}' until {ExpiresAt}", lockName, lockEntity.ExpiresAt);
}
}
private void CleanupTimerCallback(object? state)
{
try
{
CleanupExpiredLocksAsync(CancellationToken.None).GetAwaiter().GetResult();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during periodic lock cleanup");
}
}
/// <summary>
/// Internal lock handle implementation.
/// </summary>
private sealed class DistributedLockHandle : IDistributedLockHandle
{
private readonly DistributedLockManager _manager;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="DistributedLockHandle"/> class.
/// </summary>
/// <param name="lockName">The lock name.</param>
/// <param name="instanceId">The instance ID.</param>
/// <param name="acquiredAt">The acquisition time.</param>
/// <param name="expiresAt">The expiration time.</param>
/// <param name="manager">The parent manager.</param>
public DistributedLockHandle(
string lockName,
Guid instanceId,
DateTime acquiredAt,
DateTime expiresAt,
DistributedLockManager manager)
{
LockName = lockName;
InstanceId = instanceId;
AcquiredAt = acquiredAt;
ExpiresAt = expiresAt;
_manager = manager;
}
/// <inheritdoc />
public string LockName { get; }
/// <inheritdoc />
public Guid InstanceId { get; }
/// <inheritdoc />
public DateTime AcquiredAt { get; }
/// <inheritdoc />
public DateTime ExpiresAt { get; private set; }
/// <inheritdoc />
public bool IsValid => !_disposed && DateTime.UtcNow < ExpiresAt;
/// <inheritdoc />
public async Task RenewAsync(TimeSpan additionalTime, CancellationToken cancellationToken = default)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(DistributedLockHandle));
}
await _manager.RenewLockInternalAsync(LockName, additionalTime, cancellationToken).ConfigureAwait(false);
ExpiresAt = DateTime.UtcNow.Add(additionalTime);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
try
{
await _manager.ReleaseLockInternalAsync(LockName, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
_manager._logger.LogError(ex, "Error releasing lock '{LockName}' during disposal", LockName);
}
_disposed = true;
}
}
}
}
@@ -0,0 +1,99 @@
// <copyright file="DistributedLockNames.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Server.Implementations.Clustering
{
using System;
/// <summary>
/// Standard lock names for distributed locking.
/// </summary>
public static class DistributedLockNames
{
/// <summary>
/// Lock for database migrations.
/// </summary>
public const string DatabaseMigration = "database:migration";
/// <summary>
/// Lock for primary instance election.
/// </summary>
public const string PrimaryElection = "instance:primary:election";
/// <summary>
/// Global lock for all library scans.
/// </summary>
public const string GlobalLibraryScan = "library:scan:global";
/// <summary>
/// Lock for library scanning operations.
/// </summary>
/// <param name="libraryId">The library ID.</param>
/// <returns>The lock name.</returns>
public static string LibraryScan(Guid libraryId)
{
return $"library:scan:{libraryId}";
}
/// <summary>
/// Lock for metadata refresh operations.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <returns>The lock name.</returns>
public static string MetadataRefresh(Guid itemId)
{
return $"metadata:refresh:{itemId}";
}
/// <summary>
/// Lock for configuration updates.
/// </summary>
/// <param name="configType">The configuration type.</param>
/// <returns>The lock name.</returns>
public static string ConfigurationUpdate(string configType)
{
return $"config:update:{configType}";
}
/// <summary>
/// Lock for scheduled task execution.
/// </summary>
/// <param name="taskName">The task name.</param>
/// <returns>The lock name.</returns>
public static string ScheduledTask(string taskName)
{
return $"task:{taskName}";
}
/// <summary>
/// Lock for image processing operations.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <returns>The lock name.</returns>
public static string ImageProcessing(Guid itemId)
{
return $"image:process:{itemId}";
}
/// <summary>
/// Lock for cache clearing operations.
/// </summary>
/// <param name="cacheType">The cache type.</param>
/// <returns>The lock name.</returns>
public static string CacheClear(string cacheType)
{
return $"cache:clear:{cacheType}";
}
/// <summary>
/// Lock for plugin operations.
/// </summary>
/// <param name="pluginId">The plugin ID.</param>
/// <returns>The lock name.</returns>
public static string PluginOperation(Guid pluginId)
{
return $"plugin:operation:{pluginId}";
}
}
}
@@ -0,0 +1,305 @@
// <copyright file="FileSystemChangeProcessor.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Server.Implementations.Clustering
{
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Library;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
/// <summary>
/// Processes file system changes from the database.
/// Only the primary instance processes changes; all instances can record them.
/// </summary>
public sealed class FileSystemChangeProcessor : IFileSystemChangeProcessor, IHostedService, IAsyncDisposable
{
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
private readonly IInstanceRegistry _instanceRegistry;
private readonly IPrimaryElectionService _primaryElectionService;
private readonly ILibraryManager? _libraryManager;
private readonly ILogger<FileSystemChangeProcessor> _logger;
private CancellationTokenSource? _processingCts;
private Task? _processingTask;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="FileSystemChangeProcessor"/> class.
/// </summary>
/// <param name="dbContextFactory">The database context factory.</param>
/// <param name="instanceRegistry">The instance registry.</param>
/// <param name="primaryElectionService">The primary election service.</param>
/// <param name="libraryManager">The library manager (optional, may not be available at startup).</param>
/// <param name="logger">The logger.</param>
public FileSystemChangeProcessor(
IDbContextFactory<JellyfinDbContext> dbContextFactory,
IInstanceRegistry instanceRegistry,
IPrimaryElectionService primaryElectionService,
ILibraryManager? libraryManager,
ILogger<FileSystemChangeProcessor> logger)
{
_dbContextFactory = dbContextFactory;
_instanceRegistry = instanceRegistry;
_primaryElectionService = primaryElectionService;
_libraryManager = libraryManager;
_logger = logger;
}
/// <inheritdoc/>
Task IHostedService.StartAsync(CancellationToken cancellationToken)
{
return StartAsync(cancellationToken);
}
/// <inheritdoc/>
Task IHostedService.StopAsync(CancellationToken cancellationToken)
{
return StopAsync(cancellationToken);
}
/// <inheritdoc/>
public async Task StartAsync(CancellationToken cancellationToken = default)
{
_logger.LogInformation("Starting file system change processor");
// Subscribe to primary changes
_primaryElectionService.PrimaryInstanceChanged += OnPrimaryInstanceChanged;
// If we're currently primary, start processing
if (_primaryElectionService.IsPrimary)
{
StartProcessingLoop();
}
_logger.LogInformation("File system change processor started");
await Task.CompletedTask.ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task StopAsync(CancellationToken cancellationToken = default)
{
_logger.LogInformation("Stopping file system change processor");
_primaryElectionService.PrimaryInstanceChanged -= OnPrimaryInstanceChanged;
StopProcessingLoop();
_logger.LogInformation("File system change processor stopped");
await Task.CompletedTask.ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task RecordChangeAsync(string path, string changeType, string? oldPath = null, CancellationToken cancellationToken = default)
{
try
{
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var change = new FileSystemChange
{
Path = path,
ChangeType = changeType,
OldPath = oldPath,
DetectedAt = DateTime.UtcNow,
DetectedBy = _instanceRegistry.CurrentInstanceId
};
context.FileSystemChanges.Add(change);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Recorded file system change: {ChangeType} - {Path}",
changeType,
path);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to record file system change for {Path}", path);
}
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
await StopAsync(CancellationToken.None).ConfigureAwait(false);
_processingCts?.Dispose();
}
private void OnPrimaryInstanceChanged(object? sender, PrimaryInstanceChangedEventArgs e)
{
if (e.IsCurrentInstance)
{
_logger.LogInformation("Became primary instance, starting file system change processing");
StartProcessingLoop();
}
else
{
_logger.LogInformation("No longer primary instance, stopping file system change processing");
StopProcessingLoop();
}
}
private void StartProcessingLoop()
{
if (_processingTask is not null)
{
return; // Already running
}
_processingCts = new CancellationTokenSource();
_processingTask = ProcessChangesLoopAsync(_processingCts.Token);
}
private void StopProcessingLoop()
{
if (_processingTask is null)
{
return; // Not running
}
_processingCts?.Cancel();
_processingTask = null;
_processingCts?.Dispose();
_processingCts = null;
}
private async Task ProcessChangesLoopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("File system change processing loop started");
while (!cancellationToken.IsCancellationRequested)
{
try
{
// Process a batch of changes
await ProcessPendingChangesAsync(cancellationToken).ConfigureAwait(false);
// Wait before next batch (5 seconds)
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in file system change processing loop");
// Wait before retrying on error
try
{
await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}
_logger.LogInformation("File system change processing loop stopped");
}
private async Task ProcessPendingChangesAsync(CancellationToken cancellationToken)
{
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
// Get unprocessed changes (oldest first, limit 100 per batch)
var changes = await context.FileSystemChanges
.Where(c => c.ProcessedAt == null)
.OrderBy(c => c.DetectedAt)
.Take(100)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (changes.Count == 0)
{
return; // Nothing to process
}
_logger.LogDebug("Processing {Count} file system changes", changes.Count);
foreach (var change in changes)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
await ProcessSingleChangeAsync(change, context, cancellationToken).ConfigureAwait(false);
}
// Save all processed changes
try
{
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save processed file system changes");
}
}
private async Task ProcessSingleChangeAsync(FileSystemChange change, JellyfinDbContext context, CancellationToken cancellationToken)
{
try
{
if (_libraryManager is null)
{
// LibraryManager not available yet (still starting up)
return;
}
// Process the change based on type
switch (change.ChangeType)
{
case "Created":
case "Modified":
// LibraryManager will handle these through its file watcher
// We just mark as processed since detection happened
_logger.LogDebug("File change detected: {Type} - {Path}", change.ChangeType, change.Path);
break;
case "Deleted":
_logger.LogDebug("File deleted: {Path}", change.Path);
break;
case "Renamed":
_logger.LogDebug("File renamed from {OldPath} to {NewPath}", change.OldPath, change.Path);
break;
}
// Mark as processed
change.ProcessedAt = DateTime.UtcNow;
change.ProcessedBy = _instanceRegistry.CurrentInstanceId;
// Note: Actual file refresh is handled by LibraryManager's existing file watcher
// This processor just coordinates change detection across instances
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing file system change for {Path}", change.Path);
change.Error = ex.Message;
change.ProcessedAt = DateTime.UtcNow;
change.ProcessedBy = _instanceRegistry.CurrentInstanceId;
}
await Task.CompletedTask.ConfigureAwait(false);
}
}
}
@@ -0,0 +1,86 @@
// <copyright file="ICacheCoordinator.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Server.Implementations.Clustering
{
using System;
using System.Threading;
using System.Threading.Tasks;
/// <summary>
/// Interface for coordinating cache invalidation across multiple instances.
/// </summary>
public interface ICacheCoordinator
{
/// <summary>
/// Starts the cache coordinator.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task StartAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Stops the cache coordinator.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task StopAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates an item cache across all instances.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task InvalidateItemAsync(Guid itemId, CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates user data cache across all instances.
/// </summary>
/// <param name="userId">The user ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task InvalidateUserDataAsync(Guid userId, CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates an image cache across all instances.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task InvalidateImageAsync(Guid itemId, CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates all caches of a specific type across all instances.
/// </summary>
/// <param name="cacheType">The cache type.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task InvalidateAllAsync(CacheInvalidationType cacheType, CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates a library cache across all instances.
/// </summary>
/// <param name="libraryId">The library ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task InvalidateLibraryAsync(Guid libraryId, CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates a person cache across all instances.
/// </summary>
/// <param name="personId">The person ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task InvalidatePersonAsync(Guid personId, CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates metadata for an item across all instances.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task InvalidateMetadataAsync(Guid itemId, CancellationToken cancellationToken = default);
}
}
@@ -0,0 +1,101 @@
// <copyright file="IDistributedLockManager.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Server.Implementations.Clustering
{
using System;
using System.Threading;
using System.Threading.Tasks;
/// <summary>
/// Interface for managing distributed locks across multiple instances.
/// </summary>
public interface IDistributedLockManager
{
/// <summary>
/// Attempts to acquire a distributed lock.
/// </summary>
/// <param name="lockName">The unique name of the lock.</param>
/// <param name="timeout">The maximum time to wait for the lock.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A disposable lock handle, or null if lock could not be acquired.</returns>
Task<IDistributedLockHandle?> TryAcquireLockAsync(
string lockName,
TimeSpan timeout,
CancellationToken cancellationToken = default);
/// <summary>
/// Acquires a distributed lock, waiting indefinitely if necessary.
/// </summary>
/// <param name="lockName">The unique name of the lock.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A disposable lock handle.</returns>
Task<IDistributedLockHandle> AcquireLockAsync(
string lockName,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a lock is currently held.
/// </summary>
/// <param name="lockName">The name of the lock to check.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>True if the lock is held, false otherwise.</returns>
Task<bool> IsLockHeldAsync(
string lockName,
CancellationToken cancellationToken = default);
/// <summary>
/// Releases all locks held by the current instance.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task ReleaseAllLocksAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Cleans up expired locks from the database.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of locks cleaned up.</returns>
Task<int> CleanupExpiredLocksAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Represents a handle to a distributed lock that can be disposed to release the lock.
/// </summary>
public interface IDistributedLockHandle : IAsyncDisposable
{
/// <summary>
/// Gets the name of the lock.
/// </summary>
string LockName { get; }
/// <summary>
/// Gets the instance ID holding the lock.
/// </summary>
Guid InstanceId { get; }
/// <summary>
/// Gets the time when the lock was acquired.
/// </summary>
DateTime AcquiredAt { get; }
/// <summary>
/// Gets the time when the lock will expire.
/// </summary>
DateTime ExpiresAt { get; }
/// <summary>
/// Gets a value indicating whether the lock is still valid.
/// </summary>
bool IsValid { get; }
/// <summary>
/// Renews the lock by extending its expiration time.
/// </summary>
/// <param name="additionalTime">The additional time to extend the lock.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task RenewAsync(TimeSpan additionalTime, CancellationToken cancellationToken = default);
}
}
@@ -0,0 +1,39 @@
// <copyright file="IFileSystemChangeProcessor.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Server.Implementations.Clustering
{
using System.Threading;
using System.Threading.Tasks;
/// <summary>
/// Service that processes file system changes from the database (primary instance only).
/// </summary>
public interface IFileSystemChangeProcessor
{
/// <summary>
/// Starts the file system change processor.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task StartAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Stops the file system change processor.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task StopAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Records a file system change to the database.
/// </summary>
/// <param name="path">The path that changed.</param>
/// <param name="changeType">The type of change (Created, Modified, Deleted, Renamed).</param>
/// <param name="oldPath">The old path for rename operations.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task RecordChangeAsync(string path, string changeType, string? oldPath = null, CancellationToken cancellationToken = default);
}
}
@@ -0,0 +1,87 @@
// <copyright file="IInstanceRegistry.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Server.Implementations.Clustering
{
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
/// <summary>
/// Interface for managing Jellyfin instance registration and lifecycle.
/// </summary>
public interface IInstanceRegistry
{
/// <summary>
/// Gets the unique identifier for the current instance.
/// </summary>
Guid CurrentInstanceId { get; }
/// <summary>
/// Registers the current instance in the database.
/// </summary>
/// <param name="hostname">The hostname of the instance.</param>
/// <param name="processId">The process ID of the instance.</param>
/// <param name="httpPort">The HTTP port.</param>
/// <param name="httpsPort">The HTTPS port (optional).</param>
/// <param name="version">The Jellyfin version.</param>
/// <param name="capabilities">Instance capabilities (JSON).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task RegisterInstanceAsync(
string hostname,
int processId,
int httpPort,
int? httpsPort,
string version,
string? capabilities,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates the heartbeat timestamp for the current instance.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task UpdateHeartbeatAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Marks the current instance as shutdown in the database.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task ShutdownInstanceAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets all active instances from the database.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of active instances.</returns>
Task<IReadOnlyList<Instance>> GetActiveInstancesAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets a specific instance by ID.
/// </summary>
/// <param name="instanceId">The instance ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The instance, or null if not found.</returns>
Task<Instance?> GetInstanceAsync(Guid instanceId, CancellationToken cancellationToken = default);
/// <summary>
/// Removes stale instances from the database.
/// </summary>
/// <param name="staleThreshold">Time after which an instance is considered stale.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of instances removed.</returns>
Task<int> CleanupStaleInstancesAsync(TimeSpan staleThreshold, CancellationToken cancellationToken = default);
/// <summary>
/// Checks if the current instance is the primary instance.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if this instance is primary.</returns>
Task<bool> IsPrimaryInstanceAsync(CancellationToken cancellationToken = default);
}
}
@@ -0,0 +1,72 @@
// <copyright file="IPostgresNotificationListener.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Server.Implementations.Clustering
{
using System;
using System.Threading;
using System.Threading.Tasks;
/// <summary>
/// Interface for listening to PostgreSQL NOTIFY messages.
/// </summary>
public interface IPostgresNotificationListener
{
/// <summary>
/// Event fired when a notification is received.
/// </summary>
event EventHandler<PostgresNotificationEventArgs>? NotificationReceived;
/// <summary>
/// Starts listening for notifications on the specified channel.
/// </summary>
/// <param name="channel">The channel name to listen on.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task StartListeningAsync(string channel, CancellationToken cancellationToken = default);
/// <summary>
/// Stops listening for notifications.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task StopListeningAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Sends a notification to a channel.
/// </summary>
/// <param name="channel">The channel name.</param>
/// <param name="payload">The notification payload.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task NotifyAsync(string channel, string payload, CancellationToken cancellationToken = default);
}
/// <summary>
/// Event arguments for PostgreSQL notifications.
/// </summary>
public class PostgresNotificationEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="PostgresNotificationEventArgs"/> class.
/// </summary>
/// <param name="channel">The channel name.</param>
/// <param name="payload">The notification payload.</param>
public PostgresNotificationEventArgs(string channel, string payload)
{
Channel = channel;
Payload = payload;
}
/// <summary>
/// Gets the channel name.
/// </summary>
public string Channel { get; }
/// <summary>
/// Gets the notification payload.
/// </summary>
public string Payload { get; }
}
}
@@ -0,0 +1,58 @@
// <copyright file="IPrimaryElectionService.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Server.Implementations.Clustering
{
using System;
using System.Threading;
using System.Threading.Tasks;
/// <summary>
/// Service for managing primary instance election and coordination.
/// </summary>
public interface IPrimaryElectionService
{
/// <summary>
/// Event raised when the primary instance changes.
/// </summary>
event EventHandler<PrimaryInstanceChangedEventArgs>? PrimaryInstanceChanged;
/// <summary>
/// Gets a value indicating whether the current instance is the primary instance.
/// </summary>
bool IsPrimary { get; }
/// <summary>
/// Gets the ID of the current primary instance, if any.
/// </summary>
Guid? PrimaryInstanceId { get; }
/// <summary>
/// Starts the primary election service and begins monitoring.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task StartAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Stops the primary election service.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task StopAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Runs the primary election algorithm.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The ID of the elected primary instance.</returns>
Task<Guid?> ElectPrimaryAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Checks if this instance should execute scheduled tasks.
/// </summary>
/// <returns>True if this instance should execute scheduled tasks, false otherwise.</returns>
bool ShouldExecuteScheduledTasks();
}
}
@@ -0,0 +1,252 @@
// <copyright file="InstanceRegistry.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Server.Implementations.Clustering
{
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
/// <summary>
/// Manages Jellyfin instance registration and lifecycle.
/// </summary>
public sealed class InstanceRegistry : IInstanceRegistry, IAsyncDisposable
{
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
private readonly ILogger<InstanceRegistry> _logger;
private readonly Timer _heartbeatTimer;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="InstanceRegistry"/> class.
/// </summary>
/// <param name="dbContextFactory">The database context factory.</param>
/// <param name="logger">The logger.</param>
public InstanceRegistry(
IDbContextFactory<JellyfinDbContext> dbContextFactory,
ILogger<InstanceRegistry> logger)
{
_dbContextFactory = dbContextFactory;
_logger = logger;
CurrentInstanceId = Guid.NewGuid();
// Send heartbeat every 30 seconds
_heartbeatTimer = new Timer(
HeartbeatCallback,
null,
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(30));
}
/// <summary>
/// Gets the unique identifier for the current instance.
/// </summary>
public Guid CurrentInstanceId { get; }
/// <inheritdoc/>
public async Task RegisterInstanceAsync(
string hostname,
int processId,
int httpPort,
int? httpsPort,
string version,
string? capabilities,
CancellationToken cancellationToken = default)
{
try
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var instance = new Instance(
CurrentInstanceId,
hostname,
processId,
httpPort,
version)
{
HttpsPort = httpsPort
};
if (!string.IsNullOrEmpty(capabilities))
{
instance.SetCapabilities(capabilities);
}
dbContext.Instances.Add(instance);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Registered instance {InstanceId} ({Hostname}:{Port})",
CurrentInstanceId,
hostname,
httpPort);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to register instance {InstanceId}", CurrentInstanceId);
throw;
}
}
/// <inheritdoc/>
public async Task UpdateHeartbeatAsync(CancellationToken cancellationToken = default)
{
try
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var instance = await dbContext.Instances
.FirstOrDefaultAsync(i => i.InstanceId.Equals(CurrentInstanceId), cancellationToken)
.ConfigureAwait(false);
if (instance is not null)
{
instance.UpdateHeartbeat();
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to update heartbeat for instance {InstanceId}", CurrentInstanceId);
}
}
/// <inheritdoc/>
public async Task ShutdownInstanceAsync(CancellationToken cancellationToken = default)
{
try
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var instance = await dbContext.Instances
.FirstOrDefaultAsync(i => i.InstanceId.Equals(CurrentInstanceId), cancellationToken)
.ConfigureAwait(false);
if (instance is not null)
{
instance.UpdateStatus(InstanceStatus.Shutdown);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Instance {InstanceId} marked as shutdown", CurrentInstanceId);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to shutdown instance {InstanceId}", CurrentInstanceId);
}
}
/// <inheritdoc/>
public async Task<IReadOnlyList<Instance>> GetActiveInstancesAsync(CancellationToken cancellationToken = default)
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
return await dbContext.Instances
.Where(i => i.Status == InstanceStatus.Active)
.Where(i => !i.IsStale(TimeSpan.FromMinutes(2)))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<Instance?> GetInstanceAsync(Guid instanceId, CancellationToken cancellationToken = default)
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
return await dbContext.Instances
.FirstOrDefaultAsync(i => i.InstanceId.Equals(instanceId), cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<int> CleanupStaleInstancesAsync(TimeSpan staleThreshold, CancellationToken cancellationToken = default)
{
try
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var cutoffTime = DateTime.UtcNow - staleThreshold;
var staleInstances = await dbContext.Instances
.Where(i => i.LastHeartbeat < cutoffTime)
.Where(i => i.Status != InstanceStatus.Shutdown)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (staleInstances.Count > 0)
{
foreach (var instance in staleInstances)
{
instance.UpdateStatus(InstanceStatus.Failed);
}
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Marked {Count} stale instances as failed",
staleInstances.Count);
}
return staleInstances.Count;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to cleanup stale instances");
return 0;
}
}
/// <inheritdoc/>
public async Task<bool> IsPrimaryInstanceAsync(CancellationToken cancellationToken = default)
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var instance = await dbContext.Instances
.FirstOrDefaultAsync(i => i.InstanceId.Equals(CurrentInstanceId), cancellationToken)
.ConfigureAwait(false);
return instance?.IsPrimary ?? false;
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
await _heartbeatTimer.DisposeAsync().ConfigureAwait(false);
await ShutdownInstanceAsync(CancellationToken.None).ConfigureAwait(false);
}
private void HeartbeatCallback(object? state)
{
if (_disposed)
{
return;
}
Task.Run(async () =>
{
try
{
await UpdateHeartbeatAsync(CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Heartbeat timer error for instance {InstanceId}", CurrentInstanceId);
}
});
}
}
}
@@ -0,0 +1,200 @@
// <copyright file="PostgresNotificationListener.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Server.Implementations.Clustering
{
using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
/// <summary>
/// Listens for PostgreSQL NOTIFY messages for cross-instance communication.
/// </summary>
public sealed class PostgresNotificationListener : IPostgresNotificationListener, IAsyncDisposable
{
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
private readonly ILogger<PostgresNotificationListener> _logger;
private NpgsqlConnection? _connection;
private Task? _listenerTask;
private CancellationTokenSource? _listenerCts;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="PostgresNotificationListener"/> class.
/// </summary>
/// <param name="dbContextFactory">The database context factory.</param>
/// <param name="logger">The logger.</param>
public PostgresNotificationListener(
IDbContextFactory<JellyfinDbContext> dbContextFactory,
ILogger<PostgresNotificationListener> logger)
{
_dbContextFactory = dbContextFactory;
_logger = logger;
}
/// <inheritdoc/>
public event EventHandler<PostgresNotificationEventArgs>? NotificationReceived;
/// <inheritdoc/>
public async Task StartListeningAsync(string channel, CancellationToken cancellationToken = default)
{
if (_connection is not null)
{
_logger.LogWarning("Already listening on channel {Channel}", channel);
return;
}
try
{
// Create a dedicated connection for listening
await using var tempContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var connectionString = tempContext.Database.GetConnectionString();
if (string.IsNullOrEmpty(connectionString))
{
throw new InvalidOperationException("Connection string not found");
}
_connection = new NpgsqlConnection(connectionString);
await _connection.OpenAsync(cancellationToken).ConfigureAwait(false);
_connection.Notification += OnNotification;
// Subscribe to the channel
await using (var cmd = new NpgsqlCommand($"LISTEN {channel}", _connection))
{
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
_logger.LogInformation("Started listening on PostgreSQL channel: {Channel}", channel);
// Start the listener loop
_listenerCts = new CancellationTokenSource();
_listenerTask = ListenLoopAsync(_listenerCts.Token);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start listening on channel {Channel}", channel);
throw;
}
}
/// <inheritdoc/>
public async Task StopListeningAsync(CancellationToken cancellationToken = default)
{
if (_connection is null)
{
return;
}
try
{
_listenerCts?.Cancel();
if (_listenerTask is not null)
{
await _listenerTask.ConfigureAwait(false);
}
if (_connection is not null)
{
_connection.Notification -= OnNotification;
await _connection.CloseAsync().ConfigureAwait(false);
await _connection.DisposeAsync().ConfigureAwait(false);
_connection = null;
}
_logger.LogInformation("Stopped listening for PostgreSQL notifications");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error stopping notification listener");
}
}
/// <inheritdoc/>
public async Task NotifyAsync(string channel, string payload, CancellationToken cancellationToken = default)
{
try
{
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
// Escape the payload for PostgreSQL
var escapedPayload = payload.Replace("'", "''", StringComparison.Ordinal);
var sql = $"NOTIFY {channel}, '{escapedPayload}'";
await context.Database.ExecuteSqlRawAsync(sql, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Sent notification on channel {Channel}", channel);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send notification on channel {Channel}", channel);
throw;
}
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
await StopListeningAsync(CancellationToken.None).ConfigureAwait(false);
_listenerCts?.Dispose();
}
private async Task ListenLoopAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested && _connection is not null)
{
try
{
// Wait for notifications
await _connection.WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in notification listener loop");
// Wait a bit before retrying
try
{
await Task.Delay(5000, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}
}
private void OnNotification(object? sender, NpgsqlNotificationEventArgs e)
{
try
{
_logger.LogDebug("Received notification on channel {Channel}: {Payload}", e.Channel, e.Payload);
NotificationReceived?.Invoke(this, new PostgresNotificationEventArgs(e.Channel, e.Payload));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling notification");
}
}
}
}
@@ -0,0 +1,263 @@
// <copyright file="PrimaryElectionService.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Server.Implementations.Clustering
{
using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
/// <summary>
/// Service that manages primary instance election and coordination.
/// </summary>
public sealed class PrimaryElectionService : IPrimaryElectionService, IHostedService, IAsyncDisposable
{
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
private readonly IInstanceRegistry _instanceRegistry;
private readonly ILogger<PrimaryElectionService> _logger;
private CancellationTokenSource? _monitoringCts;
private Task? _monitoringTask;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="PrimaryElectionService"/> class.
/// </summary>
/// <param name="dbContextFactory">The database context factory.</param>
/// <param name="instanceRegistry">The instance registry.</param>
/// <param name="logger">The logger.</param>
public PrimaryElectionService(
IDbContextFactory<JellyfinDbContext> dbContextFactory,
IInstanceRegistry instanceRegistry,
ILogger<PrimaryElectionService> logger)
{
_dbContextFactory = dbContextFactory;
_instanceRegistry = instanceRegistry;
_logger = logger;
}
/// <inheritdoc/>
public event EventHandler<PrimaryInstanceChangedEventArgs>? PrimaryInstanceChanged;
/// <inheritdoc/>
public bool IsPrimary { get; private set; }
/// <inheritdoc/>
public Guid? PrimaryInstanceId { get; private set; }
/// <inheritdoc/>
Task IHostedService.StartAsync(CancellationToken cancellationToken)
{
return StartAsync(cancellationToken);
}
/// <inheritdoc/>
Task IHostedService.StopAsync(CancellationToken cancellationToken)
{
return StopAsync(cancellationToken);
}
/// <inheritdoc/>
public async Task StartAsync(CancellationToken cancellationToken = default)
{
_logger.LogInformation("Starting primary election service");
// Run initial election
await ElectPrimaryAsync(cancellationToken).ConfigureAwait(false);
// Start background monitoring
_monitoringCts = new CancellationTokenSource();
_monitoringTask = MonitorPrimaryStatusAsync(_monitoringCts.Token);
_logger.LogInformation(
"Primary election service started. Current instance is primary: {IsPrimary}",
IsPrimary);
}
/// <inheritdoc/>
public async Task StopAsync(CancellationToken cancellationToken = default)
{
_logger.LogInformation("Stopping primary election service");
if (_monitoringCts is not null)
{
_monitoringCts.Cancel();
if (_monitoringTask is not null)
{
try
{
await _monitoringTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected
}
}
}
// If we were primary, relinquish it
if (IsPrimary)
{
await RelinquishPrimaryAsync(cancellationToken).ConfigureAwait(false);
}
_logger.LogInformation("Primary election service stopped");
}
/// <inheritdoc/>
public async Task<Guid?> ElectPrimaryAsync(CancellationToken cancellationToken = default)
{
try
{
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
// Call the database function to elect primary
var result = await context.Database
.SqlQuery<Guid?>($"SELECT library.elect_primary_instance()")
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
var previousPrimaryId = PrimaryInstanceId;
PrimaryInstanceId = result;
IsPrimary = result.HasValue && result.Value.Equals(_instanceRegistry.CurrentInstanceId);
// Raise event if primary changed
if (!previousPrimaryId.Equals(PrimaryInstanceId))
{
_logger.LogInformation(
"Primary instance changed from {PreviousPrimaryId} to {NewPrimaryId}. Current instance is primary: {IsPrimary}",
previousPrimaryId,
PrimaryInstanceId,
IsPrimary);
PrimaryInstanceChanged?.Invoke(
this,
new PrimaryInstanceChangedEventArgs(previousPrimaryId, PrimaryInstanceId, IsPrimary));
}
return PrimaryInstanceId;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to elect primary instance");
throw;
}
}
/// <inheritdoc/>
public bool ShouldExecuteScheduledTasks()
{
// Only the primary instance should execute scheduled tasks
return IsPrimary;
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
await StopAsync(CancellationToken.None).ConfigureAwait(false);
_monitoringCts?.Dispose();
}
private async Task MonitorPrimaryStatusAsync(CancellationToken cancellationToken)
{
_logger.LogDebug("Primary status monitoring started");
while (!cancellationToken.IsCancellationRequested)
{
try
{
// Check primary status every 30 seconds
await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken).ConfigureAwait(false);
await CheckAndUpdatePrimaryStatusAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in primary status monitoring loop");
// Wait before retrying
try
{
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}
_logger.LogDebug("Primary status monitoring stopped");
}
private async Task CheckAndUpdatePrimaryStatusAsync(CancellationToken cancellationToken)
{
try
{
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
// Check if current primary is still active
var currentPrimary = await context.Database
.SqlQuery<Guid?>($"SELECT library.get_primary_instance()")
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
// If no primary exists or primary changed, run election
if (!currentPrimary.Equals(PrimaryInstanceId))
{
_logger.LogWarning(
"Primary instance status changed. Current: {CurrentPrimary}, Expected: {ExpectedPrimary}. Running election.",
currentPrimary,
PrimaryInstanceId);
await ElectPrimaryAsync(cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to check primary status");
}
}
private async Task RelinquishPrimaryAsync(CancellationToken cancellationToken)
{
try
{
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
// Clear IsPrimary flag for this instance
await context.Database
.ExecuteSqlAsync(
$@"UPDATE library.""Instances""
SET ""IsPrimary"" = FALSE
WHERE ""InstanceId"" = {_instanceRegistry.CurrentInstanceId}",
cancellationToken)
.ConfigureAwait(false);
IsPrimary = false;
_logger.LogInformation("Relinquished primary status");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to relinquish primary status");
}
}
}
}
@@ -0,0 +1,42 @@
// <copyright file="PrimaryInstanceChangedEventArgs.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Server.Implementations.Clustering
{
using System;
/// <summary>
/// Event arguments for when the primary instance changes.
/// </summary>
public class PrimaryInstanceChangedEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="PrimaryInstanceChangedEventArgs"/> class.
/// </summary>
/// <param name="previousPrimaryId">The previous primary instance ID.</param>
/// <param name="newPrimaryId">The new primary instance ID.</param>
/// <param name="isCurrentInstance">Whether the current instance is now primary.</param>
public PrimaryInstanceChangedEventArgs(Guid? previousPrimaryId, Guid? newPrimaryId, bool isCurrentInstance)
{
PreviousPrimaryId = previousPrimaryId;
NewPrimaryId = newPrimaryId;
IsCurrentInstance = isCurrentInstance;
}
/// <summary>
/// Gets the previous primary instance ID.
/// </summary>
public Guid? PreviousPrimaryId { get; }
/// <summary>
/// Gets the new primary instance ID.
/// </summary>
public Guid? NewPrimaryId { get; }
/// <summary>
/// Gets a value indicating whether the current instance is now the primary.
/// </summary>
public bool IsCurrentInstance { get; }
}
}
@@ -0,0 +1,181 @@
// <copyright file="PrimaryInstanceTaskManager.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Server.Implementations.Clustering
{
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Data.Events;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
/// <summary>
/// Task manager decorator that ensures scheduled tasks only run on the primary instance.
/// </summary>
public sealed class PrimaryInstanceTaskManager : ITaskManager
{
private readonly ITaskManager _innerTaskManager;
private readonly IPrimaryElectionService _primaryElectionService;
private readonly ILogger<PrimaryInstanceTaskManager> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="PrimaryInstanceTaskManager"/> class.
/// </summary>
/// <param name="innerTaskManager">The inner task manager.</param>
/// <param name="primaryElectionService">The primary election service.</param>
/// <param name="logger">The logger.</param>
public PrimaryInstanceTaskManager(
ITaskManager innerTaskManager,
IPrimaryElectionService primaryElectionService,
ILogger<PrimaryInstanceTaskManager> logger)
{
_innerTaskManager = innerTaskManager;
_primaryElectionService = primaryElectionService;
_logger = logger;
}
/// <inheritdoc/>
public event EventHandler<GenericEventArgs<IScheduledTaskWorker>>? TaskExecuting
{
add => _innerTaskManager.TaskExecuting += value;
remove => _innerTaskManager.TaskExecuting -= value;
}
/// <inheritdoc/>
public event EventHandler<TaskCompletionEventArgs>? TaskCompleted
{
add => _innerTaskManager.TaskCompleted += value;
remove => _innerTaskManager.TaskCompleted -= value;
}
/// <inheritdoc/>
public IReadOnlyList<IScheduledTaskWorker> ScheduledTasks => _innerTaskManager.ScheduledTasks;
/// <inheritdoc/>
public void CancelIfRunningAndQueue<T>(TaskOptions options)
where T : IScheduledTask
{
if (ShouldExecuteTask<T>())
{
_innerTaskManager.CancelIfRunningAndQueue<T>(options);
}
}
/// <inheritdoc/>
public void CancelIfRunningAndQueue<T>()
where T : IScheduledTask
{
if (ShouldExecuteTask<T>())
{
_innerTaskManager.CancelIfRunningAndQueue<T>();
}
}
/// <inheritdoc/>
public void CancelIfRunning<T>()
where T : IScheduledTask
{
_innerTaskManager.CancelIfRunning<T>();
}
/// <inheritdoc/>
public void QueueScheduledTask<T>(TaskOptions options)
where T : IScheduledTask
{
if (ShouldExecuteTask<T>())
{
_innerTaskManager.QueueScheduledTask<T>(options);
}
}
/// <inheritdoc/>
public void QueueScheduledTask<T>()
where T : IScheduledTask
{
if (ShouldExecuteTask<T>())
{
_innerTaskManager.QueueScheduledTask<T>();
}
}
/// <inheritdoc/>
public void QueueIfNotRunning<T>()
where T : IScheduledTask
{
if (ShouldExecuteTask<T>())
{
_innerTaskManager.QueueIfNotRunning<T>();
}
}
/// <inheritdoc/>
public void Execute<T>()
where T : IScheduledTask
{
if (ShouldExecuteTask<T>())
{
_innerTaskManager.Execute<T>();
}
}
/// <inheritdoc/>
public void QueueScheduledTask(IScheduledTask task, TaskOptions options)
{
if (ShouldExecuteTask(task.GetType()))
{
_innerTaskManager.QueueScheduledTask(task, options);
}
}
/// <inheritdoc/>
public void AddTasks(IEnumerable<IScheduledTask> tasks)
{
_innerTaskManager.AddTasks(tasks);
}
/// <inheritdoc/>
public void Cancel(IScheduledTaskWorker task)
{
_innerTaskManager.Cancel(task);
}
/// <inheritdoc/>
public Task Execute(IScheduledTaskWorker task, TaskOptions options)
{
if (ShouldExecuteTask(task.ScheduledTask.GetType()))
{
return _innerTaskManager.Execute(task, options);
}
return Task.CompletedTask;
}
/// <inheritdoc/>
public void Dispose()
{
_innerTaskManager.Dispose();
}
private bool ShouldExecuteTask<T>()
where T : IScheduledTask
{
return ShouldExecuteTask(typeof(T));
}
private bool ShouldExecuteTask(Type taskType)
{
if (!_primaryElectionService.ShouldExecuteScheduledTasks())
{
_logger.LogDebug(
"Skipping task {TaskName} - not primary instance. Primary: {PrimaryId}",
taskType.Name,
_primaryElectionService.PrimaryInstanceId);
return false;
}
return true;
}
}
}
@@ -99,13 +99,23 @@ public static class ServiceCollectionExtensions
}
else
{
// PostgreSQL-only build: default to PostgreSQL when no database.xml exists.
// when nothing is setup via new Database configuration, fallback to SQLite with default settings.
efCoreConfiguration = new DatabaseConfigurationOptions()
{
DatabaseType = "Jellyfin-PostgreSQL",
DatabaseType = "Jellyfin-SQLite",
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",
@@ -125,7 +135,7 @@ public static class ServiceCollectionExtensions
// Ensure DatabaseType is set (handle corrupted or empty configuration files)
if (string.IsNullOrWhiteSpace(efCoreConfiguration.DatabaseType))
{
efCoreConfiguration.DatabaseType = "Jellyfin-PostgreSQL";
efCoreConfiguration.DatabaseType = "Jellyfin-SQLite";
configurationManager.SaveConfiguration("database", efCoreConfiguration);
}
@@ -15,13 +15,13 @@ using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Server.Implementations.Migrations;
using Jellyfin.Server.Implementations.StorageHelpers;
using Jellyfin.Server.Implementations.SystemBackupService;
using MediaBrowser.Controller;
using MediaBrowser.Controller.SystemBackupService;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -156,31 +156,34 @@ public class BackupService : IBackupService
await using (dbContext.ConfigureAwait(false))
{
// restore migration history manually
var historyEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{MigrationTracker.BackupEntryName}.json")));
var historyEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{nameof(HistoryRow)}.json")));
if (historyEntry is null)
{
_logger.LogInformation("No backup of the history table in archive. This is required for Jellyfin operation");
throw new InvalidOperationException("Cannot restore backup that has no History data.");
}
MigrationRecord[] historyEntries;
HistoryRow[] historyEntries;
var historyArchive = await historyEntry.OpenAsync().ConfigureAwait(false);
await using (historyArchive.ConfigureAwait(false))
{
historyEntries = await JsonSerializer.DeserializeAsync<MigrationRecord[]>(historyArchive).ConfigureAwait(false) ??
historyEntries = await JsonSerializer.DeserializeAsync<HistoryRow[]>(historyArchive).ConfigureAwait(false) ??
throw new InvalidOperationException("Cannot restore backup that has no History data.");
}
await MigrationTracker.EnsureTableExistsAsync(dbContext).ConfigureAwait(false);
var historyRepository = dbContext.GetService<IHistoryRepository>();
await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
foreach (var item in await MigrationTracker.GetAppliedMigrationIdsAsync(dbContext, CancellationToken.None).ConfigureAwait(false))
foreach (var item in await historyRepository.GetAppliedMigrationsAsync(CancellationToken.None).ConfigureAwait(false))
{
await MigrationTracker.DeleteAsync(dbContext, item).ConfigureAwait(false);
var insertScript = historyRepository.GetDeleteScript(item.MigrationId);
await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
}
foreach (var item in historyEntries)
{
await MigrationTracker.InsertAsync(dbContext, item.MigrationId, item.ProductVersion).ConfigureAwait(false);
var insertScript = historyRepository.GetInsertScript(item);
await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
}
dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
@@ -307,7 +310,8 @@ public class BackupService : IBackupService
}
// include the migration history as well
var migrations = await MigrationTracker.GetAppliedMigrationsAsync(dbContext).ConfigureAwait(false);
var historyRepository = dbContext.GetService<IHistoryRepository>();
var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes =
[
@@ -315,12 +319,12 @@ public class BackupService : IBackupService
.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
.Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
.Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))),
(Type: typeof(MigrationRecord), SourceName: MigrationTracker.BackupEntryName, ValueFactory: () => migrations.ToAsyncEnumerable())
(Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable())
];
manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray();
var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false);
var executionStrategy = dbContext.Database.CreateExecutionStrategy();
await executionStrategy.ExecuteAsync(async () =>
await using (transaction.ConfigureAwait(false))
{
_logger.LogInformation("Begin Database backup");
@@ -359,7 +363,7 @@ public class BackupService : IBackupService
_logger.LogInformation("Backup of entity {Table} with {Number} created", entityType.SourceName, entities);
}
}).ConfigureAwait(false);
}
}
_logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath);
File diff suppressed because it is too large Load Diff
@@ -77,25 +77,21 @@ public class ChapterRepository : IChapterRepository
public async Task SaveChaptersAsync(Guid itemId, IReadOnlyList<ChapterInfo> chapters, CancellationToken cancellationToken = default)
{
await using var context = _dbProvider.CreateDbContext();
await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
// Use the execution strategy to handle transactional consistency and retries
// This is required for PostgreSQL NpgsqlRetryingExecutionStrategy
var executionStrategy = context.Database.CreateExecutionStrategy();
await executionStrategy.ExecuteAsync(async () =>
await context.Chapters
.Where(e => e.ItemId.Equals(itemId))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
for (var i = 0; i < chapters.Count; i++)
{
await context.Chapters
.Where(e => e.ItemId.Equals(itemId))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
var chapter = chapters[i];
await context.Chapters.AddAsync(Map(chapter, i, itemId), cancellationToken).ConfigureAwait(false);
}
for (var i = 0; i < chapters.Count; i++)
{
var chapter = chapters[i];
await context.Chapters.AddAsync(Map(chapter, i, itemId), cancellationToken).ConfigureAwait(false);
}
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}).ConfigureAwait(false);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
@@ -64,16 +64,12 @@ public class KeyframeRepository : IKeyframeRepository
public async Task SaveKeyframeDataAsync(Guid itemId, MediaEncoding.Keyframes.KeyframeData data, CancellationToken cancellationToken = default)
{
await using var context = _dbProvider.CreateDbContext();
await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
// Use the execution strategy to handle transactional consistency and retries
// This is required for PostgreSQL NpgsqlRetryingExecutionStrategy
var executionStrategy = context.Database.CreateExecutionStrategy();
await executionStrategy.ExecuteAsync(async () =>
{
await context.KeyframeData.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.KeyframeData.AddAsync(Map(data, itemId), cancellationToken).ConfigureAwait(false);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}).ConfigureAwait(false);
await context.KeyframeData.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.KeyframeData.AddAsync(Map(data, itemId), cancellationToken).ConfigureAwait(false);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
@@ -29,29 +29,25 @@ public class MediaAttachmentRepository(IDbContextFactory<JellyfinDbContext> dbPr
CancellationToken cancellationToken = default)
{
await using var context = dbProvider.CreateDbContext();
await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
// Use the execution strategy to handle transactional consistency and retries
// This is required for PostgreSQL NpgsqlRetryingExecutionStrategy
var executionStrategy = context.Database.CreateExecutionStrategy();
await executionStrategy.ExecuteAsync(async () =>
// Users may replace a media with a version that includes attachments to one without them.
// So when saving attachments is triggered by a library scan, we always unconditionally
// clear the old ones, and then add the new ones if given.
await context.AttachmentStreamInfos
.Where(e => e.ItemId.Equals(id))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
if (attachments.Any())
{
// Users may replace a media with a version that includes attachments to one without them.
// So when saving attachments is triggered by a library scan, we always unconditionally
// clear the old ones, and then add the new ones if given.
await context.AttachmentStreamInfos
.Where(e => e.ItemId.Equals(id))
.ExecuteDeleteAsync(cancellationToken)
.AddRangeAsync(attachments.Select(e => Map(e, id)), cancellationToken)
.ConfigureAwait(false);
}
if (attachments.Any())
{
await context.AttachmentStreamInfos
.AddRangeAsync(attachments.Select(e => Map(e, id)), cancellationToken)
.ConfigureAwait(false);
}
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}).ConfigureAwait(false);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
@@ -44,23 +44,19 @@ public class MediaStreamRepository : IMediaStreamRepository
public async Task SaveMediaStreamsAsync(Guid id, IReadOnlyList<MediaStream> streams, CancellationToken cancellationToken = default)
{
await using var context = _dbProvider.CreateDbContext();
await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
// Use the execution strategy to handle transactional consistency and retries
// This is required for PostgreSQL NpgsqlRetryingExecutionStrategy
var executionStrategy = context.Database.CreateExecutionStrategy();
await executionStrategy.ExecuteAsync(async () =>
{
await context.MediaStreamInfos
.Where(e => e.ItemId.Equals(id))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await context.MediaStreamInfos
.Where(e => e.ItemId.Equals(id))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await context.MediaStreamInfos
.AddRangeAsync(streams.Select(f => Map(f, id)), cancellationToken)
.ConfigureAwait(false);
await context.MediaStreamInfos
.AddRangeAsync(streams.Select(f => Map(f, id)), cancellationToken)
.ConfigureAwait(false);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}).ConfigureAwait(false);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
@@ -68,8 +64,6 @@ public class MediaStreamRepository : IMediaStreamRepository
{
await using var context = _dbProvider.CreateDbContext();
var streams = await TranslateQuery(context.MediaStreamInfos.AsNoTracking(), filter)
.Include(e => e.AudioDetails)
.Include(e => e.VideoDetails)
.ToArrayAsync(cancellationToken)
.ConfigureAwait(false);
@@ -133,54 +127,46 @@ public class MediaStreamRepository : IMediaStreamRepository
dto.Language = language;
dto.ChannelLayout = entity.ChannelLayout;
dto.Profile = entity.Profile;
dto.AspectRatio = entity.AspectRatio;
dto.Path = RestorePath(entity.Path);
dto.IsInterlaced = entity.IsInterlaced.GetValueOrDefault();
dto.BitRate = entity.BitRate;
dto.Channels = entity.Channels;
dto.SampleRate = entity.SampleRate;
dto.IsDefault = entity.IsDefault;
dto.IsForced = entity.IsForced;
dto.IsExternal = entity.IsExternal;
dto.Height = entity.Height;
dto.Width = entity.Width;
dto.AverageFrameRate = entity.AverageFrameRate;
dto.RealFrameRate = entity.RealFrameRate;
dto.Level = entity.Level;
dto.PixelFormat = entity.PixelFormat;
dto.BitDepth = entity.BitDepth;
dto.IsAnamorphic = entity.IsAnamorphic;
dto.RefFrames = entity.RefFrames;
dto.CodecTag = entity.CodecTag;
dto.Comment = entity.Comment;
dto.NalLengthSize = entity.NalLengthSize;
dto.Title = entity.Title;
dto.TimeBase = entity.TimeBase;
dto.CodecTimeBase = entity.CodecTimeBase;
if (entity.AudioDetails is { } audio)
{
dto.ChannelLayout = audio.ChannelLayout;
dto.Channels = audio.Channels;
dto.SampleRate = audio.SampleRate;
dto.IsHearingImpaired = audio.IsHearingImpaired.GetValueOrDefault();
}
if (entity.VideoDetails is { } video)
{
dto.AspectRatio = video.AspectRatio;
dto.IsInterlaced = video.IsInterlaced.GetValueOrDefault();
dto.Height = video.Height;
dto.Width = video.Width;
dto.AverageFrameRate = video.AverageFrameRate;
dto.RealFrameRate = video.RealFrameRate;
dto.PixelFormat = video.PixelFormat;
dto.BitDepth = video.BitDepth;
dto.IsAnamorphic = video.IsAnamorphic;
dto.RefFrames = video.RefFrames;
dto.NalLengthSize = video.NalLengthSize;
dto.ColorPrimaries = video.ColorPrimaries;
dto.ColorSpace = video.ColorSpace;
dto.ColorTransfer = video.ColorTransfer;
dto.DvVersionMajor = video.DvVersionMajor;
dto.DvVersionMinor = video.DvVersionMinor;
dto.DvProfile = video.DvProfile;
dto.DvLevel = video.DvLevel;
dto.RpuPresentFlag = video.RpuPresentFlag;
dto.ElPresentFlag = video.ElPresentFlag;
dto.BlPresentFlag = video.BlPresentFlag;
dto.DvBlSignalCompatibilityId = video.DvBlSignalCompatibilityId;
dto.Rotation = video.Rotation;
dto.Hdr10PlusPresentFlag = video.Hdr10PlusPresentFlag;
}
dto.ColorPrimaries = entity.ColorPrimaries;
dto.ColorSpace = entity.ColorSpace;
dto.ColorTransfer = entity.ColorTransfer;
dto.DvVersionMajor = entity.DvVersionMajor;
dto.DvVersionMinor = entity.DvVersionMinor;
dto.DvProfile = entity.DvProfile;
dto.DvLevel = entity.DvLevel;
dto.RpuPresentFlag = entity.RpuPresentFlag;
dto.ElPresentFlag = entity.ElPresentFlag;
dto.BlPresentFlag = entity.BlPresentFlag;
dto.DvBlSignalCompatibilityId = entity.DvBlSignalCompatibilityId;
dto.IsHearingImpaired = entity.IsHearingImpaired.GetValueOrDefault();
dto.Rotation = entity.Rotation;
dto.Hdr10PlusPresentFlag = entity.Hdr10PlusPresentFlag;
if (dto.Type is MediaStreamType.Audio or MediaStreamType.Subtitle)
{
@@ -216,65 +202,47 @@ public class MediaStreamRepository : IMediaStreamRepository
Codec = dto.Codec,
Language = dto.Language,
ChannelLayout = dto.ChannelLayout,
Profile = dto.Profile,
AspectRatio = dto.AspectRatio,
Path = GetPathToSave(dto.Path) ?? dto.Path,
IsInterlaced = dto.IsInterlaced,
BitRate = dto.BitRate,
Channels = dto.Channels,
SampleRate = dto.SampleRate,
IsDefault = dto.IsDefault,
IsForced = dto.IsForced,
IsExternal = dto.IsExternal,
Height = dto.Height,
Width = dto.Width,
AverageFrameRate = dto.AverageFrameRate,
RealFrameRate = dto.RealFrameRate,
Level = dto.Level.HasValue ? (float)dto.Level : null,
PixelFormat = dto.PixelFormat,
BitDepth = dto.BitDepth,
IsAnamorphic = dto.IsAnamorphic,
RefFrames = dto.RefFrames,
CodecTag = dto.CodecTag,
Comment = dto.Comment,
NalLengthSize = dto.NalLengthSize,
Title = dto.Title,
TimeBase = dto.TimeBase,
CodecTimeBase = dto.CodecTimeBase,
ColorPrimaries = dto.ColorPrimaries,
ColorSpace = dto.ColorSpace,
ColorTransfer = dto.ColorTransfer,
DvVersionMajor = dto.DvVersionMajor,
DvVersionMinor = dto.DvVersionMinor,
DvProfile = dto.DvProfile,
DvLevel = dto.DvLevel,
RpuPresentFlag = dto.RpuPresentFlag,
ElPresentFlag = dto.ElPresentFlag,
BlPresentFlag = dto.BlPresentFlag,
DvBlSignalCompatibilityId = dto.DvBlSignalCompatibilityId,
IsHearingImpaired = dto.IsHearingImpaired,
Rotation = dto.Rotation,
Hdr10PlusPresentFlag = dto.Hdr10PlusPresentFlag,
};
if (dto.Type == MediaStreamType.Audio)
{
entity.AudioDetails = new AudioStreamDetails
{
ItemId = itemId,
StreamIndex = dto.Index,
ChannelLayout = dto.ChannelLayout,
Channels = dto.Channels,
SampleRate = dto.SampleRate,
IsHearingImpaired = dto.IsHearingImpaired,
};
}
else if (dto.Type == MediaStreamType.Video)
{
entity.VideoDetails = new VideoStreamDetails
{
ItemId = itemId,
StreamIndex = dto.Index,
AspectRatio = dto.AspectRatio,
IsInterlaced = dto.IsInterlaced,
Height = dto.Height,
Width = dto.Width,
AverageFrameRate = dto.AverageFrameRate,
RealFrameRate = dto.RealFrameRate,
PixelFormat = dto.PixelFormat,
BitDepth = dto.BitDepth,
IsAnamorphic = dto.IsAnamorphic,
RefFrames = dto.RefFrames,
NalLengthSize = dto.NalLengthSize,
ColorPrimaries = dto.ColorPrimaries,
ColorSpace = dto.ColorSpace,
ColorTransfer = dto.ColorTransfer,
DvVersionMajor = dto.DvVersionMajor,
DvVersionMinor = dto.DvVersionMinor,
DvProfile = dto.DvProfile,
DvLevel = dto.DvLevel,
RpuPresentFlag = dto.RpuPresentFlag,
ElPresentFlag = dto.ElPresentFlag,
BlPresentFlag = dto.BlPresentFlag,
DvBlSignalCompatibilityId = dto.DvBlSignalCompatibilityId,
Rotation = dto.Rotation,
Hdr10PlusPresentFlag = dto.Hdr10PlusPresentFlag,
};
}
return entity;
}
}
@@ -46,11 +46,11 @@ public static class OrderMapper
(ItemSortBy.AlbumArtist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
(ItemSortBy.Studio, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
(ItemSortBy.OfficialRating, _) => e => e.InheritedParentalRatingValue,
(ItemSortBy.SeriesSortName, _) => e => e.TvExtras!.SeriesName,
(ItemSortBy.Album, _) => e => e.AudioExtras!.Album,
(ItemSortBy.SeriesSortName, _) => e => e.SeriesName,
(ItemSortBy.Album, _) => e => e.Album,
(ItemSortBy.DateCreated, _) => e => e.DateCreated,
(ItemSortBy.PremiereDate, _) => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)),
(ItemSortBy.StartDate, _) => e => e.LiveTvExtras!.StartDate,
(ItemSortBy.StartDate, _) => e => e.StartDate,
(ItemSortBy.Name, _) => e => e.CleanName,
(ItemSortBy.CommunityRating, _) => e => e.CommunityRating,
(ItemSortBy.ProductionYear, _) => e => e.ProductionYear,
@@ -60,10 +60,10 @@ public static class OrderMapper
(ItemSortBy.IndexNumber, _) => e => e.IndexNumber,
(ItemSortBy.SeriesDatePlayed, not null) => e =>
jellyfinDbContext.BaseItems
.Where(w => w.TvExtras!.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
.Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
.Join(jellyfinDbContext.UserData.Where(w => w.UserId == query.User.Id && w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
.Max(f => f),
(ItemSortBy.SeriesDatePlayed, null) => e => jellyfinDbContext.BaseItems.Where(w => w.TvExtras!.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
(ItemSortBy.SeriesDatePlayed, null) => e => jellyfinDbContext.BaseItems.Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
.Join(jellyfinDbContext.UserData.Where(w => w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
.Max(f => f),
// ItemSortBy.SeriesDatePlayed => e => jellyfinDbContext.UserData
@@ -123,81 +123,77 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
var personKeys = people.Select(e => e.Name + "-" + e.Type).ToArray();
await using var context = _dbProvider.CreateDbContext();
await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
// Use the execution strategy to handle transactional consistency and retries
// This is required for PostgreSQL NpgsqlRetryingExecutionStrategy
var executionStrategy = context.Database.CreateExecutionStrategy();
await executionStrategy.ExecuteAsync(async () =>
{
var existingPersons = await context.Peoples
.Select(e => new
{
item = e,
SelectionKey = e.Name + "-" + e.PersonType
})
.Where(p => personKeys.Contains(p.SelectionKey))
.Select(f => f.item)
.ToArrayAsync(cancellationToken)
.ConfigureAwait(false);
var toAdd = people
.Where(e => e.Type is not PersonKind.Artist && e.Type is not PersonKind.AlbumArtist)
.Where(e => !existingPersons.Any(f => f.Name == e.Name && f.PersonType == e.Type.ToString()))
.Select(Map);
await context.Peoples.AddRangeAsync(toAdd, cancellationToken).ConfigureAwait(false);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
var personsEntities = toAdd.Concat(existingPersons).ToArray();
var existingMaps = await context.PeopleBaseItemMap
.Include(e => e.People)
.Where(e => e.ItemId == itemId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var listOrder = 0;
foreach (var person in people)
var existingPersons = await context.Peoples
.Select(e => new
{
if (person.Type == PersonKind.Artist || person.Type == PersonKind.AlbumArtist)
{
continue;
}
item = e,
SelectionKey = e.Name + "-" + e.PersonType
})
.Where(p => personKeys.Contains(p.SelectionKey))
.Select(f => f.item)
.ToArrayAsync(cancellationToken)
.ConfigureAwait(false);
var entityPerson = personsEntities.First(e => e.Name == person.Name && e.PersonType == person.Type.ToString());
var existingMap = existingMaps.FirstOrDefault(e => e.People.Name == person.Name && e.People.PersonType == person.Type.ToString() && e.Role == person.Role);
if (existingMap is null)
{
await context.PeopleBaseItemMap.AddAsync(
new PeopleBaseItemMap()
{
Item = null!,
ItemId = itemId,
People = null!,
PeopleId = entityPerson.Id,
ListOrder = listOrder,
SortOrder = person.SortOrder,
Role = person.Role
},
cancellationToken).ConfigureAwait(false);
}
else
{
// Update the order for existing mappings
existingMap.ListOrder = listOrder;
existingMap.SortOrder = person.SortOrder;
// person mapping already exists so remove from list
existingMaps.Remove(existingMap);
}
var toAdd = people
.Where(e => e.Type is not PersonKind.Artist && e.Type is not PersonKind.AlbumArtist)
.Where(e => !existingPersons.Any(f => f.Name == e.Name && f.PersonType == e.Type.ToString()))
.Select(Map);
listOrder++;
await context.Peoples.AddRangeAsync(toAdd, cancellationToken).ConfigureAwait(false);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
var personsEntities = toAdd.Concat(existingPersons).ToArray();
var existingMaps = await context.PeopleBaseItemMap
.Include(e => e.People)
.Where(e => e.ItemId == itemId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var listOrder = 0;
foreach (var person in people)
{
if (person.Type == PersonKind.Artist || person.Type == PersonKind.AlbumArtist)
{
continue;
}
context.PeopleBaseItemMap.RemoveRange(existingMaps);
var entityPerson = personsEntities.First(e => e.Name == person.Name && e.PersonType == person.Type.ToString());
var existingMap = existingMaps.FirstOrDefault(e => e.People.Name == person.Name && e.People.PersonType == person.Type.ToString() && e.Role == person.Role);
if (existingMap is null)
{
await context.PeopleBaseItemMap.AddAsync(
new PeopleBaseItemMap()
{
Item = null!,
ItemId = itemId,
People = null!,
PeopleId = entityPerson.Id,
ListOrder = listOrder,
SortOrder = person.SortOrder,
Role = person.Role
},
cancellationToken).ConfigureAwait(false);
}
else
{
// Update the order for existing mappings
existingMap.ListOrder = listOrder;
existingMap.SortOrder = person.SortOrder;
// person mapping already exists so remove from list
existingMaps.Remove(existingMap);
}
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}).ConfigureAwait(false);
listOrder++;
}
context.PeopleBaseItemMap.RemoveRange(existingMaps);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
private PersonInfo Map(People people)
@@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<TargetFramework>net11.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
@@ -1,15 +0,0 @@
// <copyright file="MigrationRecord.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Server.Implementations.Migrations;
/// <summary>Represents one row of the EF migrations history table.</summary>
public sealed class MigrationRecord
{
/// <summary>Gets or sets the migration identifier.</summary>
public string MigrationId { get; set; } = string.Empty;
/// <summary>Gets or sets the product version that applied this migration.</summary>
public string ProductVersion { get; set; } = string.Empty;
}
@@ -1,80 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Microsoft.EntityFrameworkCore;
namespace Jellyfin.Server.Implementations.Migrations;
/// <summary>
/// Plain-SQL tracker for the EF migrations history table.
/// Replaces the EF-internal IHistoryRepository / SnakeCaseHistoryRepository.
/// </summary>
public static class MigrationTracker
{
/// <summary>
/// Entry name used when writing/reading migration history from backup archives.
/// Kept as "HistoryRow" for backward compatibility with existing backup files.
/// </summary>
public const string BackupEntryName = "HistoryRow";
private const string CreateTableSql = """
CREATE TABLE IF NOT EXISTS public."__EFMigrationsHistory" (
migration_id character varying(150) NOT NULL,
product_version character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY (migration_id)
)
""";
/// <summary>Ensures the migrations history table exists.</summary>
/// <param name="dbContext">The database context.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the operation.</returns>
public static async Task EnsureTableExistsAsync(JellyfinDbContext dbContext, CancellationToken ct = default)
=> await dbContext.Database.ExecuteSqlRawAsync(CreateTableSql, ct).ConfigureAwait(false);
/// <summary>Returns the migration_id of every applied migration.</summary>
/// <param name="dbContext">The database context.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A read-only list of applied migration identifiers.</returns>
public static async Task<IReadOnlyList<string>> GetAppliedMigrationIdsAsync(JellyfinDbContext dbContext, CancellationToken ct = default)
=> await dbContext.Database
.SqlQueryRaw<string>("""SELECT migration_id AS "Value" FROM public."__EFMigrationsHistory" """)
.ToListAsync(ct).ConfigureAwait(false);
/// <summary>Returns all applied migration rows.</summary>
/// <param name="dbContext">The database context.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A read-only list of <see cref="MigrationRecord"/> instances.</returns>
public static async Task<IReadOnlyList<MigrationRecord>> GetAppliedMigrationsAsync(JellyfinDbContext dbContext, CancellationToken ct = default)
=> await dbContext.Database
.SqlQueryRaw<MigrationRecord>("""
SELECT migration_id AS "MigrationId", product_version AS "ProductVersion"
FROM public."__EFMigrationsHistory"
""")
.ToListAsync(ct).ConfigureAwait(false);
/// <summary>Inserts a migration row, ignoring conflicts.</summary>
/// <param name="dbContext">The database context.</param>
/// <param name="migrationId">The migration identifier.</param>
/// <param name="productVersion">The product version.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the operation.</returns>
public static async Task InsertAsync(JellyfinDbContext dbContext, string migrationId, string productVersion, CancellationToken ct = default)
=> await dbContext.Database.ExecuteSqlAsync(
$"""
INSERT INTO public."__EFMigrationsHistory" (migration_id, product_version)
VALUES ({migrationId}, {productVersion}) ON CONFLICT DO NOTHING
""",
ct).ConfigureAwait(false);
/// <summary>Deletes a migration row by id.</summary>
/// <param name="dbContext">The database context.</param>
/// <param name="migrationId">The migration identifier to delete.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the operation.</returns>
public static async Task DeleteAsync(JellyfinDbContext dbContext, string migrationId, CancellationToken ct = default)
=> await dbContext.Database.ExecuteSqlAsync(
$"""DELETE FROM public."__EFMigrationsHistory" WHERE migration_id = {migrationId}""",
ct).ConfigureAwait(false);
}
@@ -1,138 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Queries;
using Jellyfin.Extensions.Dapper;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Implementations.ScheduledTasks
{
/// <summary>
/// Class PostgresPeriodicTuner.
/// </summary>
public class PostgresPeriodicTuner : IScheduledTask, IConfigurableScheduledTask
{
private readonly ILogger<PostgresPeriodicTuner> _logger;
private readonly IDbContext _dbContext;
/// <summary>
/// Initializes a new instance of the <see cref="PostgresPeriodicTuner"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="dbContext">The database context.</param>
public PostgresPeriodicTuner(ILogger<PostgresPeriodicTuner> logger, IDbContext dbContext)
{
_logger = logger;
_dbContext = dbContext;
}
/// <inheritdoc />
public string Name => "PostgreSQL Periodic Tuner";
/// <inheritdoc />
public string Key => "PostgresPeriodicTuner";
/// <inheritdoc />
public string Description => "Periodically checks for dead tuples and runs VACUUM ANALYZE on tables that need it.";
/// <inheritdoc />
public string Category => "Database";
/// <inheritdoc />
public bool IsHidden => false;
/// <inheritdoc />
public bool IsEnabled => true;
/// <inheritdoc />
public bool IsLogged => true;
/// <summary>
/// Executes the task.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <param name="progress">The progress.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task ExecuteAsync(CancellationToken cancellationToken, IProgress<double> progress)
{
_logger.LogInformation("PostgreSQL Periodic Tuner task started.");
try
{
const string DeadTupleQuery = @"
SELECT
relname AS TableName,
n_dead_tup AS DeadTuples
FROM
pg_stat_user_tables
WHERE
n_dead_tup > 1000 -- Example threshold: more than 1000 dead tuples
ORDER BY
n_dead_tup DESC;";
var tablesToVacuum = (await _dbContext.QueryAsync<(string TableName, long DeadTuples)>(DeadTupleQuery)).ToList();
if (tablesToVacuum.Count == 0)
{
_logger.LogInformation("No tables require vacuuming at this time.");
return;
}
progress.Report(10);
double progressStep = 90.0 / tablesToVacuum.Count;
double currentProgress = 10.0;
foreach (var table in tablesToVacuum)
{
cancellationToken.ThrowIfCancellationRequested();
_logger.LogInformation("Found {DeadTuples} dead tuples in table {TableName}. Running VACUUM ANALYZE.", table.DeadTuples, table.TableName);
try
{
await _dbContext.ExecuteAsync($"VACUUM ANALYZE \"{table.TableName}\"");
_logger.LogInformation("Successfully vacuumed and analyzed table {TableName}.", table.TableName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error running VACUUM ANALYZE on table {TableName}.", table.TableName);
}
currentProgress += progressStep;
progress.Report(currentProgress);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during PostgreSQL Periodic Tuner task.");
}
finally
{
_logger.LogInformation("PostgreSQL Periodic Tuner task finished.");
progress.Report(100);
}
}
/// <summary>
/// Gets the default triggers.
/// </summary>
/// <returns>IEnumerable&lt;TaskTriggerInfo&gt;.</returns>
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return new[]
{
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerInterval,
IntervalTicks = TimeSpan.FromHours(24).Ticks
}
};
}
}
}
@@ -83,15 +83,6 @@ namespace Jellyfin.Server.Implementations.Security
IHeaderDictionary headers,
IQueryCollection queryString)
{
// Token extraction priority (in order):
// 1. Authorization header (MediaBrowser Token parameter)
// 2. X-Emby-Token header (if legacy auth enabled)
// 3. X-MediaBrowser-Token header (if legacy auth enabled)
// 4. ApiKey query parameter
// 5. api_key query parameter (if legacy auth enabled)
// For WebSocket connections, use query parameters:
// ws://server:port/path?api_key=YOUR_TOKEN
string? deviceId = null;
string? deviceName = null;
string? client = null;
@@ -149,7 +140,7 @@ namespace Jellyfin.Server.Implementations.Security
return authInfo;
}
var tokenPreview = token?.Length > 8 ? string.Concat(token.AsSpan(0, 8), "...") : token ?? "null";
var tokenPreview = token?.Length > 8 ? token.Substring(0, 8) + "..." : token ?? "null";
_logger.LogDebug(
"Validating authentication token. Client: {Client}, Device: {Device}, DeviceId: {DeviceId}, Token: {Token}",
client ?? "unknown",
@@ -255,7 +246,7 @@ namespace Jellyfin.Server.Implementations.Security
}
else
{
var tokenPreview2 = token?.Length > 8 ? string.Concat(token.AsSpan(0, 8), "...") : token ?? "null";
var tokenPreview2 = token?.Length > 8 ? token.Substring(0, 8) + "..." : token ?? "null";
_logger.LogDebug(
"Token validation failed: Token not found in devices or API keys. Token: {Token}",
tokenPreview2);
@@ -28,6 +28,7 @@ namespace Jellyfin.Server.Extensions
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions.Json;
using Jellyfin.Server.Configuration;
using Jellyfin.Server.Filters;
using MediaBrowser.Common.Api;
using MediaBrowser.Common.Net;
using MediaBrowser.Model.Entities;
@@ -42,6 +43,7 @@ namespace Jellyfin.Server.Extensions
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Interfaces;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.Swagger;
using Swashbuckle.AspNetCore.SwaggerGen;
using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes;
@@ -252,7 +254,20 @@ namespace Jellyfin.Server.Extensions
// Allow parameters to properly be nullable.
c.UseAllOfToExtendReferenceSchemas();
c.SupportNonNullableReferenceTypes();
});
// TODO - remove when all types are supported in System.Text.Json
c.AddSwaggerTypeMappings();
c.SchemaFilter<IgnoreEnumSchemaFilter>();
c.SchemaFilter<FlagsEnumSchemaFilter>();
c.OperationFilter<RetryOnTemporarilyUnavailableFilter>();
c.OperationFilter<SecurityRequirementsOperationFilter>();
c.OperationFilter<FileResponseFilter>();
c.OperationFilter<FileRequestFilter>();
c.OperationFilter<ParameterObsoleteFilter>();
c.DocumentFilter<AdditionalModelFilter>();
})
.Replace(ServiceDescriptor.Transient<ISwaggerProvider, CachingOpenApiProvider>());
}
private static void AddPolicy(this AuthorizationOptions authorizationOptions, string policyName, IAuthorizationRequirement authorizationRequirement)
@@ -312,5 +327,40 @@ namespace Jellyfin.Server.Extensions
options.KnownIPNetworks.Add(new System.Net.IPNetwork(addr, prefixLength));
}
}
private static void AddSwaggerTypeMappings(this SwaggerGenOptions options)
{
/*
* TODO remove when System.Text.Json properly supports non-string keys.
* Used in BaseItemDto.ImageBlurHashes
*/
options.MapType<Dictionary<ImageType, string>>(() =>
new OpenApiSchema
{
Type = "object",
AdditionalProperties = new OpenApiSchema
{
Type = "string"
}
});
// Support dictionary with nullable string value.
options.MapType<Dictionary<string, string?>>(() =>
new OpenApiSchema
{
Type = "object",
AdditionalProperties = new OpenApiSchema
{
Type = "string",
Nullable = true
}
});
// Swashbuckle doesn't use JsonOptions to describe responses, so we need to manually describe it.
options.MapType<Version>(() => new OpenApiSchema
{
Type = "string"
});
}
}
}
+5 -7
View File
@@ -31,11 +31,6 @@ public static class StartupHelpers
{
private static readonly string[] _relevantEnvVarPrefixes = { "JELLYFIN_", "DOTNET_", "ASPNETCORE_" };
private static readonly System.Text.Json.JsonSerializerOptions _jsonSerializerOptions = new()
{
WriteIndented = true
};
/// <summary>
/// Logs relevant environment variables and information about the host.
/// </summary>
@@ -240,7 +235,10 @@ public static class StartupHelpers
}
};
var json = System.Text.Json.JsonSerializer.Serialize(defaultConfig, _jsonSerializerOptions);
var json = System.Text.Json.JsonSerializer.Serialize(defaultConfig, new System.Text.Json.JsonSerializerOptions
{
WriteIndented = true
});
File.WriteAllText(configPath, json);
Console.WriteLine($"Created default startup configuration at: {configPath}");
@@ -370,7 +368,7 @@ public static class StartupHelpers
// If $XDG_CACHE_HOME is either not set or a relative path,
// a default equal to $HOME/.cache should be used.
if (cacheHome is null || !cacheHome.StartsWith('/', StringComparison.Ordinal))
if (cacheHome is null || !cacheHome.StartsWith("/", StringComparison.Ordinal))
{
cacheHome = Path.Join(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, Environment.SpecialFolderOption.DoNotVerify),
+8 -18
View File
@@ -2,12 +2,10 @@
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517;CS1061;CS0103</NoWarn>
<ProjectGuid>{07E39F42-A2C6-4B32-AF8C-725F957A73FF}</ProjectGuid>
</PropertyGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517;CS1061;CS0103</NoWarn>
<AssemblyName>jellyfin</AssemblyName>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
@@ -16,12 +14,10 @@
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<ApplicationIcon>Jellyfin.Server.ico</ApplicationIcon>
<IsPackable>true</IsPackable>
<!-- Suppress trimming warnings for existing migration and serialization code -->
<NoWarn>$(NoWarn);IL2026;IL2072;IL2075</NoWarn>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Filters/**/*.cs" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\SharedVersion.cs" />
</ItemGroup>
@@ -45,11 +41,6 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="SerilogAnalyzer" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" />
@@ -85,7 +76,6 @@
<PackageReference Include="Serilog.Sinks.Graylog" />
<!-- SQLite package for migration routines only (SQLite -> PostgreSQL) -->
<PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="Swashbuckle.AspNetCore" />
</ItemGroup>
<ItemGroup>
@@ -116,21 +106,21 @@
<ItemGroup>
<!-- SQL initialization scripts for PostgreSQL -->
<None Include="sql\**\*.sql">
<None Include="..\sql\**\*.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<Pack>false</Pack>
<Link>sql\%(RecursiveDir)%(Filename)%(Extension)</Link>
</None>
</ItemGroup>
<!-- Post-build event to ensure SQL files are copied -->
<Target Name="CopySQLFiles" AfterTargets="AfterBuild">
<Target Name="CopySQLFiles" AfterTargets="Build">
<ItemGroup>
<SQLFiles Include="$(MSBuildProjectDirectory)\sql\**\*.sql" />
<SQLFiles Include="$(ProjectDir)..\sql\**\*.sql" />
</ItemGroup>
<Message Importance="high" Text="Copying SQL files to $(OutDir)sql\" />
<Copy SourceFiles="@(SQLFiles)" DestinationFiles="@(SQLFiles->'$(OutDir)sql\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
<Message Importance="high" Text="✅ SQL files copied to output directory: $(OutDir)sql\" />
<Copy SourceFiles="@(SQLFiles)" DestinationFolder="$(OutDir)sql\%(RecursiveDir)" SkipUnchangedFiles="true" />
<Message Importance="high" Text="Copied SQL files to output directory" />
</Target>
</Project>
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<NameOfLastUsedPublishProfile>D:\Projects\pgsql-jellyfin\Jellyfin.Server\Properties\PublishProfiles\SelfContained-Linux-x64.pubxml</NameOfLastUsedPublishProfile>
<NameOfLastUsedPublishProfile>D:\Projects\pgsql-jellyfin\Jellyfin.Server\Properties\PublishProfiles\MultiInstance-FrameworkDependent.pubxml</NameOfLastUsedPublishProfile>
</PropertyGroup>
</Project>
@@ -2,8 +2,6 @@
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
#pragma warning disable CS8602 // Dereference of a possibly null reference
namespace Jellyfin.Server.Migrations;
using System;
@@ -16,7 +14,6 @@ using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations.Serialization;
using Jellyfin.Database.Implementations;
using Jellyfin.Server.Implementations.Migrations;
using Jellyfin.Server.Implementations.SystemBackupService;
using Jellyfin.Server.Migrations.Stages;
using Jellyfin.Server.ServerSetupApp;
@@ -25,6 +22,7 @@ using MediaBrowser.Controller.SystemBackupService;
using MediaBrowser.Model.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -99,7 +97,7 @@ internal class JellyfinMigrationService
public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths)
{
var logger = _startupLogger.Attach(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migration Startup");
var logger = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migration Startup");
logger.LogInformation("Initialise Migration service.");
var xmlSerializer = new MyXmlSerializer();
var serverConfig = File.Exists(appPaths.SystemConfigurationFilePath)
@@ -113,25 +111,25 @@ internal class JellyfinMigrationService
var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
// DISABLED for .NET 11 preview: Database creation handled by PostgresDatabaseProvider using SQL scripts
/*
var databaseCreator = dbContext.Database.GetService<IDatabaseCreator>() as IRelationalDatabaseCreator
?? throw new InvalidOperationException("Jellyfin does only support relational databases.");
if (!await databaseCreator.ExistsAsync().ConfigureAwait(false))
{
await databaseCreator.CreateAsync().ConfigureAwait(false);
}
*/
await MigrationTracker.EnsureTableExistsAsync(dbContext).ConfigureAwait(false);
var appliedMigrations = await MigrationTracker.GetAppliedMigrationIdsAsync(dbContext).ConfigureAwait(false);
var migrationsToSeed = flatApplyMigrations
var historyRepository = dbContext.GetService<IHistoryRepository>();
await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
var appliedMigrations = await dbContext.Database.GetAppliedMigrationsAsync().ConfigureAwait(false);
var startupScripts = flatApplyMigrations
.Where(e => !appliedMigrations.Any(f => f != e.BuildCodeMigrationId()))
.Select(e => (Migration: e.Metadata, Script: historyRepository.GetInsertScript(new HistoryRow(e.BuildCodeMigrationId(), GetJellyfinVersion()))))
.ToArray();
foreach (var item in migrationsToSeed)
foreach (var item in startupScripts)
{
logger.LogInformation("Seed migration {Key}-{Name}.", item.Metadata.Key, item.Metadata.Name);
await MigrationTracker.InsertAsync(dbContext, item.BuildCodeMigrationId(), GetJellyfinVersion()).ConfigureAwait(false);
logger.LogInformation("Seed migration {Key}-{Name}.", item.Migration.Key, item.Migration.Name);
await dbContext.Database.ExecuteSqlRawAsync(item.Script).ConfigureAwait(false);
}
}
@@ -152,8 +150,8 @@ internal class JellyfinMigrationService
var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
await MigrationTracker.EnsureTableExistsAsync(dbContext).ConfigureAwait(false);
var appliedMigrations = await MigrationTracker.GetAppliedMigrationIdsAsync(dbContext).ConfigureAwait(false);
var historyRepository = dbContext.GetService<IHistoryRepository>();
var appliedMigrations = await dbContext.Database.GetAppliedMigrationsAsync().ConfigureAwait(false);
var lastOldAppliedMigration = Migrations
.SelectMany(e => e.Where(e => e.Metadata.Key is not null)) // only consider migrations that have the key set as its the reference marker for legacy migrations.
.Where(e => migrationOptions.Applied.Any(f => f.Id.Equals(e.Metadata.Key!.Value)))
@@ -170,10 +168,11 @@ internal class JellyfinMigrationService
];
// those are all migrations that had to run in the old migration system, even if not noted in the migration.xml file.
foreach (var item in oldMigrations)
var startupScripts = oldMigrations.Select(e => (Migration: e.Metadata, Script: historyRepository.GetInsertScript(new HistoryRow(e.BuildCodeMigrationId(), GetJellyfinVersion()))));
foreach (var item in startupScripts)
{
logger.LogInformation("Migrate migration {Key}-{Name}.", item.Metadata.Key, item.Metadata.Name);
await MigrationTracker.InsertAsync(dbContext, item.BuildCodeMigrationId(), GetJellyfinVersion()).ConfigureAwait(false);
logger.LogInformation("Migrate migration {Key}-{Name}.", item.Migration.Key, item.Migration.Name);
await dbContext.Database.ExecuteSqlRawAsync(item.Script).ConfigureAwait(false);
}
logger.LogInformation("Rename old migration.xml to migration.xml.backup");
@@ -191,7 +190,7 @@ internal class JellyfinMigrationService
public async Task MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider? serviceProvider)
{
var logger = _startupLogger.Attach(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migrate stage {stage}.");
var logger = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migrate stage {stage}.");
// Ensure database exists before attempting migrations (PostgreSQL-specific)
if (stage == JellyfinMigrationStageTypes.CoreInitialisation && _jellyfinDatabaseProvider is Jellyfin.Database.Providers.Postgres.PostgresDatabaseProvider postgresProvider)
@@ -236,24 +235,35 @@ internal class JellyfinMigrationService
var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
// Ensure database exists before querying migration history
// Note: This does NOT run migrations, it only ensures the database itself exists
// Ensure the migration history table exists before querying it
var databaseCreator = dbContext.Database.GetService<IDatabaseCreator>() as IRelationalDatabaseCreator;
if (databaseCreator is not null && !await databaseCreator.ExistsAsync().ConfigureAwait(false))
{
logger.LogInformation("Database does not exist, creating empty database...");
logger.LogInformation("Database does not exist, creating...");
await databaseCreator.CreateAsync().ConfigureAwait(false);
logger.LogInformation("Empty database created successfully");
}
await MigrationTracker.EnsureTableExistsAsync(dbContext).ConfigureAwait(false);
var appliedMigrations = await MigrationTracker.GetAppliedMigrationIdsAsync(dbContext).ConfigureAwait(false);
var historyRepository = dbContext.GetService<IHistoryRepository>();
// Explicitly ensure the __EFMigrationsHistory table exists
await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>();
var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
var pendingCodeMigrations = migrationStage
.Where(e => !appliedMigrations.Contains(e.BuildCodeMigrationId()))
.Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId()))
.Select(e => (Key: e.BuildCodeMigrationId(), Migration: new InternalCodeMigration(e, serviceProvider, dbContext)))
.ToArray();
(string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations];
(string Key, InternalDatabaseMigration Migration)[] pendingDatabaseMigrations = [];
if (stage is JellyfinMigrationStageTypes.CoreInitialisation)
{
pendingDatabaseMigrations = migrationsAssembly.Migrations.Where(f => appliedMigrations.All(e => e.MigrationId != f.Key))
.Select(e => (Key: e.Key, Migration: new InternalDatabaseMigration(e, dbContext)))
.ToArray();
}
(string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations];
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingMigrations.Length, stage);
var migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
@@ -374,15 +384,16 @@ internal class JellyfinMigrationService
{
logger.LogInformation("Prepare system for possible migrations");
JellyfinMigrationBackupAttribute backupInstruction;
IReadOnlyList<string> appliedMigrations;
IReadOnlyList<HistoryRow> appliedMigrations;
var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
await MigrationTracker.EnsureTableExistsAsync(dbContext).ConfigureAwait(false);
appliedMigrations = await MigrationTracker.GetAppliedMigrationIdsAsync(dbContext).ConfigureAwait(false);
var historyRepository = dbContext.GetService<IHistoryRepository>();
var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>();
appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
backupInstruction = new JellyfinMigrationBackupAttribute()
{
JellyfinDb = false // Schema migrations are disabled; no EF schema migrations are ever pending
JellyfinDb = migrationsAssembly.Migrations.Any(f => appliedMigrations.All(e => e.MigrationId != f.Key))
};
}
@@ -391,7 +402,7 @@ internal class JellyfinMigrationService
bool isSqliteProvider = false;
backupInstruction = Migrations.SelectMany(e => e)
.Where(e => !appliedMigrations.Contains(e.BuildCodeMigrationId()))
.Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId()))
.Where(e => isSqliteProvider || !e.Metadata.RequiresSqlite) // Skip SQLite migrations for non-SQLite providers
.Select(e => e.BackupRequirements)
.Where(e => e is not null)
@@ -483,8 +494,27 @@ internal class JellyfinMigrationService
{
await _codeMigration.Perform(_serviceProvider, logger, CancellationToken.None).ConfigureAwait(false);
await MigrationTracker.InsertAsync(_dbContext, _codeMigration.BuildCodeMigrationId(), GetJellyfinVersion()).ConfigureAwait(false);
var historyRepository = _dbContext.GetService<IHistoryRepository>();
var createScript = historyRepository.GetInsertScript(new HistoryRow(_codeMigration.BuildCodeMigrationId(), GetJellyfinVersion()));
await _dbContext.Database.ExecuteSqlRawAsync(createScript).ConfigureAwait(false);
}
}
private class InternalDatabaseMigration : IInternalMigration
{
private readonly JellyfinDbContext _jellyfinDbContext;
private KeyValuePair<string, TypeInfo> _databaseMigrationInfo;
public InternalDatabaseMigration(KeyValuePair<string, TypeInfo> databaseMigrationInfo, JellyfinDbContext jellyfinDbContext)
{
_databaseMigrationInfo = databaseMigrationInfo;
_jellyfinDbContext = jellyfinDbContext;
}
public async Task PerformAsync(IStartupLogger logger)
{
var migrator = _jellyfinDbContext.GetService<IMigrator>();
await migrator.MigrateAsync(_databaseMigrationInfo.Key).ConfigureAwait(false);
}
}
}
@@ -36,7 +36,7 @@ public class FixDates : IAsyncMigrationRoutine
IStartupLogger<FixDates> startupLogger,
IDbContextFactory<JellyfinDbContext> dbProvider)
{
_logger = startupLogger.Attach(logger);
_logger = startupLogger.With(logger);
_dbProvider = dbProvider;
}
@@ -64,36 +64,30 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
_logger.LogInformation("Checking {Count} items for importable keyframe data.", records);
context.KeyframeData.ExecuteDelete();
// NpgsqlRetryingExecutionStrategy requires all operations inside a user-initiated
// transaction to be wrapped with CreateExecutionStrategy().Execute(...).
context.Database.CreateExecutionStrategy().Execute(() =>
using var transaction = context.Database.BeginTransaction();
do
{
using var transaction = context.Database.BeginTransaction();
do
var results = baseQuery.Skip(offset).Take(Limit).Select(b => new Tuple<Guid, string?>(b.Id, b.Path)).ToList();
foreach (var result in results)
{
var results = baseQuery.Skip(offset).Take(Limit).Select(b => new Tuple<Guid, string?>(b.Id, b.Path)).ToList();
foreach (var result in results)
if (TryGetKeyframeData(result.Item1, result.Item2, out var data))
{
if (TryGetKeyframeData(result.Item1, result.Item2, out var data))
{
itemCount++;
context.KeyframeData.Add(data);
}
itemCount++;
context.KeyframeData.Add(data);
}
}
offset += Limit;
if (offset > records)
{
offset = records;
}
offset += Limit;
if (offset > records)
{
offset = records;
}
_logger.LogInformation("Checked: {Count} - Imported: {Items} - Time: {Time}", offset, itemCount, sw.Elapsed);
} while (offset < records);
_logger.LogInformation("Checked: {Count} - Imported: {Items} - Time: {Time}", offset, itemCount, sw.Elapsed);
} while (offset < records);
context.SaveChanges();
transaction.Commit();
});
context.SaveChanges();
transaction.Commit();
_logger.LogInformation("Imported keyframes for {Count} items in {Time}", itemCount, sw.Elapsed);
@@ -3,8 +3,6 @@
// </copyright>
#pragma warning disable RS0030 // Do not use banned APIs
#pragma warning disable CS1061 // Type does not contain definition (legacy schema migration)
#pragma warning disable CS0103 // Name does not exist (legacy code)
using System;
using System.Collections.Generic;
@@ -97,7 +95,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
}
// notify the other migration to just silently abort because the fix has been applied here already.
// ReseedFolderFlag.RerunGuardFlag = true;
ReseedFolderFlag.RerunGuardFlag = true;
var legacyBaseItemWithUserKeys = new Dictionary<string, BaseItemEntity>();
connection.Open();
@@ -598,16 +596,19 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
/// <returns>MediaStream.</returns>
private MediaStreamInfo GetMediaStream(SqliteDataReader reader)
{
var streamType = Enum.Parse<MediaStreamTypeEntity>(reader.GetString(2));
var itemId = reader.GetGuid(0);
var streamIndex = reader.GetInt32(1);
var item = new MediaStreamInfo
{
StreamIndex = streamIndex,
StreamType = streamType,
StreamIndex = reader.GetInt32(1),
StreamType = Enum.Parse<MediaStreamTypeEntity>(reader.GetString(2)),
Item = null!,
ItemId = itemId,
ItemId = reader.GetGuid(0),
AspectRatio = null!,
ChannelLayout = null!,
Codec = null!,
IsInterlaced = false,
Language = null!,
Path = null!,
Profile = null!,
};
if (reader.TryGetString(3, out var codec))
@@ -620,30 +621,92 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
item.Language = language;
}
if (reader.TryGetString(5, out var channelLayout))
{
item.ChannelLayout = channelLayout;
}
if (reader.TryGetString(6, out var profile))
{
item.Profile = profile;
}
if (reader.TryGetString(7, out var aspectRatio))
{
item.AspectRatio = aspectRatio;
}
if (reader.TryGetString(8, out var path))
{
item.Path = path;
}
item.IsInterlaced = reader.GetBoolean(9);
if (reader.TryGetInt32(10, out var bitrate))
{
item.BitRate = bitrate;
}
if (reader.TryGetInt32(11, out var channels))
{
item.Channels = channels;
}
if (reader.TryGetInt32(12, out var sampleRate))
{
item.SampleRate = sampleRate;
}
item.IsDefault = reader.GetBoolean(13);
item.IsForced = reader.GetBoolean(14);
item.IsExternal = reader.GetBoolean(15);
if (reader.TryGetInt32(16, out var width))
{
item.Width = width;
}
if (reader.TryGetInt32(17, out var height))
{
item.Height = height;
}
if (reader.TryGetSingle(18, out var averageFrameRate))
{
item.AverageFrameRate = averageFrameRate;
}
if (reader.TryGetSingle(19, out var realFrameRate))
{
item.RealFrameRate = realFrameRate;
}
if (reader.TryGetSingle(20, out var level))
{
item.Level = level;
}
if (reader.TryGetString(21, out var pixelFormat))
{
item.PixelFormat = pixelFormat;
}
if (reader.TryGetInt32(22, out var bitDepth))
{
item.BitDepth = bitDepth;
}
if (reader.TryGetBoolean(23, out var isAnamorphic))
{
item.IsAnamorphic = isAnamorphic;
}
if (reader.TryGetInt32(24, out var refFrames))
{
item.RefFrames = refFrames;
}
if (reader.TryGetString(25, out var codecTag))
{
item.CodecTag = codecTag;
@@ -654,6 +717,11 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
item.Comment = comment;
}
if (reader.TryGetString(27, out var nalLengthSize))
{
item.NalLengthSize = nalLengthSize;
}
if (reader.TryGetBoolean(28, out var isAVC))
{
item.IsAvc = isAVC;
@@ -674,151 +742,68 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
item.CodecTimeBase = codecTimeBase;
}
if (streamType == MediaStreamTypeEntity.Audio)
if (reader.TryGetString(32, out var colorPrimaries))
{
var audio = new AudioStreamDetails
{
ItemId = itemId,
StreamIndex = streamIndex,
};
if (reader.TryGetString(5, out var channelLayout))
{
audio.ChannelLayout = channelLayout;
}
if (reader.TryGetInt32(11, out var channels))
{
audio.Channels = channels;
}
if (reader.TryGetInt32(12, out var sampleRate))
{
audio.SampleRate = sampleRate;
}
audio.IsHearingImpaired = reader.TryGetBoolean(43, out var hearingImpaired) && hearingImpaired;
item.AudioDetails = audio;
item.ColorPrimaries = colorPrimaries;
}
else if (streamType == MediaStreamTypeEntity.Video)
if (reader.TryGetString(33, out var colorSpace))
{
var video = new VideoStreamDetails
{
ItemId = itemId,
StreamIndex = streamIndex,
};
if (reader.TryGetString(7, out var aspectRatio))
{
video.AspectRatio = aspectRatio;
}
video.IsInterlaced = reader.GetBoolean(9);
if (reader.TryGetInt32(16, out var width))
{
video.Width = width;
}
if (reader.TryGetInt32(17, out var height))
{
video.Height = height;
}
if (reader.TryGetSingle(18, out var averageFrameRate))
{
video.AverageFrameRate = averageFrameRate;
}
if (reader.TryGetSingle(19, out var realFrameRate))
{
video.RealFrameRate = realFrameRate;
}
if (reader.TryGetString(21, out var pixelFormat))
{
video.PixelFormat = pixelFormat;
}
if (reader.TryGetInt32(22, out var bitDepth))
{
video.BitDepth = bitDepth;
}
if (reader.TryGetBoolean(23, out var isAnamorphic))
{
video.IsAnamorphic = isAnamorphic;
}
if (reader.TryGetInt32(24, out var refFrames))
{
video.RefFrames = refFrames;
}
if (reader.TryGetString(27, out var nalLengthSize))
{
video.NalLengthSize = nalLengthSize;
}
if (reader.TryGetString(32, out var colorPrimaries))
{
video.ColorPrimaries = colorPrimaries;
}
if (reader.TryGetString(33, out var colorSpace))
{
video.ColorSpace = colorSpace;
}
if (reader.TryGetString(34, out var colorTransfer))
{
video.ColorTransfer = colorTransfer;
}
if (reader.TryGetInt32(35, out var dvVersionMajor))
{
video.DvVersionMajor = dvVersionMajor;
}
if (reader.TryGetInt32(36, out var dvVersionMinor))
{
video.DvVersionMinor = dvVersionMinor;
}
if (reader.TryGetInt32(37, out var dvProfile))
{
video.DvProfile = dvProfile;
}
if (reader.TryGetInt32(38, out var dvLevel))
{
video.DvLevel = dvLevel;
}
if (reader.TryGetInt32(39, out var rpuPresentFlag))
{
video.RpuPresentFlag = rpuPresentFlag;
}
if (reader.TryGetInt32(40, out var elPresentFlag))
{
video.ElPresentFlag = elPresentFlag;
}
if (reader.TryGetInt32(41, out var blPresentFlag))
{
video.BlPresentFlag = blPresentFlag;
}
if (reader.TryGetInt32(42, out var dvBlSignalCompatibilityId))
{
video.DvBlSignalCompatibilityId = dvBlSignalCompatibilityId;
}
item.VideoDetails = video;
item.ColorSpace = colorSpace;
}
if (reader.TryGetString(34, out var colorTransfer))
{
item.ColorTransfer = colorTransfer;
}
if (reader.TryGetInt32(35, out var dvVersionMajor))
{
item.DvVersionMajor = dvVersionMajor;
}
if (reader.TryGetInt32(36, out var dvVersionMinor))
{
item.DvVersionMinor = dvVersionMinor;
}
if (reader.TryGetInt32(37, out var dvProfile))
{
item.DvProfile = dvProfile;
}
if (reader.TryGetInt32(38, out var dvLevel))
{
item.DvLevel = dvLevel;
}
if (reader.TryGetInt32(39, out var rpuPresentFlag))
{
item.RpuPresentFlag = rpuPresentFlag;
}
if (reader.TryGetInt32(40, out var elPresentFlag))
{
item.ElPresentFlag = elPresentFlag;
}
if (reader.TryGetInt32(41, out var blPresentFlag))
{
item.BlPresentFlag = blPresentFlag;
}
if (reader.TryGetInt32(42, out var dvBlSignalCompatibilityId))
{
item.DvBlSignalCompatibilityId = dvBlSignalCompatibilityId;
}
item.IsHearingImpaired = reader.TryGetBoolean(43, out var result) && result;
// if (reader.TryGetInt32(44, out var rotation))
// {
// item.Rotation = rotation;
// }
return item;
}
@@ -881,12 +866,12 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
if (reader.TryReadDateTime(index++, out var startDate))
{
// entity.StartDate = startDate; // Property removed in new schema
entity.StartDate = startDate;
}
if (reader.TryReadDateTime(index++, out var endDate))
{
// entity.EndDate = endDate; // Property removed in new schema
entity.EndDate = endDate;
}
if (reader.TryGetGuid(index++, out var guid))
@@ -906,7 +891,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
if (reader.TryGetString(index++, out var episodeTitle))
{
// entity.EpisodeTitle = episodeTitle; // Property removed in new schema
entity.EpisodeTitle = episodeTitle;
}
if (reader.TryGetBoolean(index++, out var isRepeat))
@@ -1036,12 +1021,12 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
if (reader.TryGetString(index++, out var audioString) && Enum.TryParse<ProgramAudioEntity>(audioString, out var audioType))
{
// entity.Audio = audioType; // Property removed in new schema
entity.Audio = audioType;
}
if (reader.TryGetString(index++, out var serviceName))
{
// entity.ExternalServiceId = serviceName; // Property removed in new schema
entity.ExternalServiceId = serviceName;
}
if (reader.TryGetBoolean(index++, out var isInMixedFolder))
@@ -1105,17 +1090,17 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
if (reader.TryGetString(index++, out var album))
{
// entity.Album = album; // Property removed in new schema
entity.Album = album;
}
if (reader.TryGetSingle(index++, out var lUFS))
{
// entity.LUFS = lUFS; // Property removed in new schema
entity.LUFS = lUFS;
}
if (reader.TryGetSingle(index++, out var normalizationGain))
{
// entity.NormalizationGain = normalizationGain; // Property removed in new schema
entity.NormalizationGain = normalizationGain;
}
if (reader.TryGetSingle(index++, out var criticRating))
@@ -1130,7 +1115,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
if (reader.TryGetString(index++, out var seriesName))
{
// entity.SeriesName = seriesName; // Property removed in new schema
entity.SeriesName = seriesName;
}
var userDataKeys = new List<string>();
@@ -1141,17 +1126,17 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
if (reader.TryGetString(index++, out var seasonName))
{
// entity.SeasonName = seasonName; // Property removed in new schema
entity.SeasonName = seasonName;
}
if (reader.TryGetGuid(index++, out var seasonId))
{
// entity.SeasonId = seasonId; // Property removed in new schema
entity.SeasonId = seasonId;
}
if (reader.TryGetGuid(index++, out var seriesId))
{
// entity.SeriesId = seriesId; // Property removed in new schema
entity.SeriesId = seriesId;
}
if (reader.TryGetString(index++, out var presentationUniqueKey))
@@ -1166,7 +1151,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
if (reader.TryGetString(index++, out var externalSeriesId))
{
// entity.ExternalSeriesId = externalSeriesId; // Property removed in new schema
entity.ExternalSeriesId = externalSeriesId;
}
if (reader.TryGetString(index++, out var tagLine))
@@ -1214,12 +1199,12 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
if (reader.TryGetString(index++, out var artists))
{
// entity.Artists = artists; // Property removed in new schema
entity.Artists = artists;
}
if (reader.TryGetString(index++, out var albumArtists))
{
// entity.AlbumArtists = albumArtists; // Property removed in new schema
entity.AlbumArtists = albumArtists;
}
if (reader.TryGetString(index++, out var externalId))
@@ -1229,12 +1214,12 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
if (reader.TryGetString(index++, out var seriesPresentationUniqueKey))
{
// entity.SeriesPresentationUniqueKey = seriesPresentationUniqueKey; // Property removed in new schema
entity.SeriesPresentationUniqueKey = seriesPresentationUniqueKey;
}
if (reader.TryGetString(index++, out var showId))
{
// entity.ShowId = showId; // Property removed in new schema
entity.ShowId = showId;
}
if (reader.TryGetString(index++, out var ownerId))
@@ -40,43 +40,37 @@ internal class MigrateRatingLevels : IDatabaseMigrationRoutine
{
_logger.LogInformation("Recalculating parental rating levels based on rating string.");
using var context = _provider.CreateDbContext();
using var transaction = context.Database.BeginTransaction();
// NpgsqlRetryingExecutionStrategy requires all operations inside a user-initiated
// transaction to be wrapped with CreateExecutionStrategy().Execute(...).
context.Database.CreateExecutionStrategy().Execute(() =>
// Materialize the ratings list first to avoid "command in progress" error
var ratings = context.BaseItems.AsNoTracking().Select(e => e.OfficialRating).Distinct().ToList();
foreach (var rating in ratings)
{
using var transaction = context.Database.BeginTransaction();
// Materialize the ratings list first to avoid "command in progress" error
var ratings = context.BaseItems.AsNoTracking().Select(e => e.OfficialRating).Distinct().ToList();
foreach (var rating in ratings)
if (string.IsNullOrEmpty(rating))
{
if (string.IsNullOrEmpty(rating))
{
int? value = null;
context.BaseItems
.Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty)
.ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, value));
context.BaseItems
.Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty)
.ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, value));
}
else
{
var ratingValue = _localizationManager.GetRatingScore(rating);
var score = ratingValue?.Score;
var subScore = ratingValue?.SubScore;
context.BaseItems
.Where(e => e.OfficialRating == rating)
.ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, score));
context.BaseItems
.Where(e => e.OfficialRating == rating)
.ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, subScore));
}
int? value = null;
context.BaseItems
.Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty)
.ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, value));
context.BaseItems
.Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty)
.ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, value));
}
else
{
var ratingValue = _localizationManager.GetRatingScore(rating);
var score = ratingValue?.Score;
var subScore = ratingValue?.SubScore;
context.BaseItems
.Where(e => e.OfficialRating == rating)
.ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, score));
context.BaseItems
.Where(e => e.OfficialRating == rating)
.ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, subScore));
}
}
transaction.Commit();
});
transaction.Commit();
}
}
@@ -0,0 +1,237 @@
// <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; }
}
}
@@ -57,7 +57,7 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
IDbContextFactory<JellyfinDbContext> dbProvider)
{
_appPaths = appPaths;
_logger = startupLogger.Attach(logger);
_logger = startupLogger.With(logger);
_pathManager = pathManager;
_fileSystem = fileSystem;
_dbProvider = dbProvider;
@@ -0,0 +1,88 @@
// <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();
}
}
}
@@ -0,0 +1,78 @@
// <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);
}
}
}
}
+6 -1
View File
@@ -139,7 +139,7 @@ namespace Jellyfin.Server
}
}
StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.Attach(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check"));
StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check"));
StartupHelpers.PerformStaticInitialization();
@@ -290,6 +290,11 @@ namespace Jellyfin.Server
_migrationLogger = StartupLogger.Logger.BeginGroup<JellyfinMigrationService>($"Migration Service");
var startupConfigurationManager = new ServerConfigurationManager(appPaths, _loggerFactory, new MyXmlSerializer());
startupConfigurationManager.AddParts([new DatabaseConfigurationFactory()]);
// Force load database configuration from disk BEFORE initializing DbContext
// This ensures database.xml is read from the configured config directory
_ = startupConfigurationManager.GetConfiguration("database");
var migrationStartupServiceProvider = new ServiceCollection()
.AddLogging(d => d.AddSerilog())
.AddJellyfinDbContext(startupConfigurationManager, startupConfig)
@@ -22,7 +22,7 @@ public interface IStartupLogger : ILogger
/// </summary>
/// <param name="logger">Other logger to rely messages to.</param>
/// <returns>A combined logger.</returns>
IStartupLogger Attach(ILogger logger);
IStartupLogger With(ILogger logger);
/// <summary>
/// Opens a new Group logger within the parent logger.
@@ -37,7 +37,7 @@ public interface IStartupLogger : ILogger
/// <param name="logger">Other logger to rely messages to.</param>
/// <returns>A combined logger.</returns>
/// <typeparam name="TCategory">The logger cateogry.</typeparam>
IStartupLogger<TCategory> Attach<TCategory>(ILogger logger);
IStartupLogger<TCategory> With<TCategory>(ILogger logger);
/// <summary>
/// Opens a new Group logger within the parent logger.
@@ -59,7 +59,7 @@ public interface IStartupLogger<TCategory> : IStartupLogger
/// </summary>
/// <param name="logger">Other logger to rely messages to.</param>
/// <returns>A combined logger.</returns>
new IStartupLogger<TCategory> Attach(ILogger logger);
new IStartupLogger<TCategory> With(ILogger logger);
/// <summary>
/// Opens a new Group logger within the parent logger.
@@ -12,6 +12,8 @@ using Microsoft.Extensions.Logging.Abstractions;
/// <inheritdoc/>
public class StartupLogger : IStartupLogger
{
private readonly StartupLogTopic? _topic;
/// <summary>
/// Initializes a new instance of the <see cref="StartupLogger"/> class.
/// </summary>
@@ -19,7 +21,6 @@ public class StartupLogger : IStartupLogger
public StartupLogger(ILogger logger)
{
BaseLogger = logger;
Topic = null;
}
/// <summary>
@@ -29,13 +30,13 @@ public class StartupLogger : IStartupLogger
/// <param name="topic">The group for this logger.</param>
internal StartupLogger(ILogger logger, StartupLogTopic? topic) : this(logger)
{
Topic = topic;
_topic = topic;
}
internal static IStartupLogger Logger { get; set; } = new StartupLogger(NullLogger.Instance);
/// <inheritdoc/>
public StartupLogTopic? Topic { get; private set; }
public StartupLogTopic? Topic => _topic;
/// <summary>
/// Gets or Sets the underlying base logger.
@@ -49,13 +50,13 @@ public class StartupLogger : IStartupLogger
}
/// <inheritdoc/>
public IStartupLogger Attach(ILogger logger)
public IStartupLogger With(ILogger logger)
{
return new StartupLogger(logger, Topic);
}
/// <inheritdoc/>
public IStartupLogger<TCategory> Attach<TCategory>(ILogger logger)
public IStartupLogger<TCategory> With<TCategory>(ILogger logger)
{
return new StartupLogger<TCategory>(logger, Topic);
}
@@ -53,7 +53,7 @@ public class StartupLogger<TCategory> : StartupLogger, IStartupLogger<TCategory>
return new StartupLogger<TCategory>(BaseLogger, startupEntry);
}
IStartupLogger<TCategory> IStartupLogger<TCategory>.Attach(ILogger logger)
IStartupLogger<TCategory> IStartupLogger<TCategory>.With(ILogger logger)
{
return new StartupLogger<TCategory>(logger, Topic);
}
+12 -21
View File
@@ -85,25 +85,18 @@ namespace Jellyfin.Server
var acceptJsonHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json, 1.0);
var acceptXmlHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Xml, 0.9);
var acceptAnyHeader = new MediaTypeWithQualityHeaderValue("*/*", 0.8);
HttpMessageHandler CreateEyeballsHttpClientHandler(IServiceProvider serviceProvider)
Func<IServiceProvider, HttpMessageHandler> eyeballsHttpClientHandlerDelegate = (_) => new SocketsHttpHandler()
{
return new SocketsHttpHandler()
{
AutomaticDecompression = DecompressionMethods.All,
RequestHeaderEncodingSelector = (_, _) => Encoding.UTF8,
ConnectCallback = HttpClientExtension.OnConnect
};
}
AutomaticDecompression = DecompressionMethods.All,
RequestHeaderEncodingSelector = (_, _) => Encoding.UTF8,
ConnectCallback = HttpClientExtension.OnConnect
};
HttpMessageHandler CreateDefaultHttpClientHandler(IServiceProvider serviceProvider)
Func<IServiceProvider, HttpMessageHandler> defaultHttpClientHandlerDelegate = (_) => new SocketsHttpHandler()
{
return new SocketsHttpHandler()
{
AutomaticDecompression = DecompressionMethods.All,
RequestHeaderEncodingSelector = (_, _) => Encoding.UTF8
};
}
AutomaticDecompression = DecompressionMethods.All,
RequestHeaderEncodingSelector = (_, _) => Encoding.UTF8
};
services.AddHttpClient(NamedClient.Default, c =>
{
@@ -112,7 +105,7 @@ namespace Jellyfin.Server
c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader);
c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
})
.ConfigurePrimaryHttpMessageHandler(CreateEyeballsHttpClientHandler);
.ConfigurePrimaryHttpMessageHandler(eyeballsHttpClientHandlerDelegate);
services.AddHttpClient(NamedClient.MusicBrainz, c =>
{
@@ -121,7 +114,7 @@ namespace Jellyfin.Server
c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader);
c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
})
.ConfigurePrimaryHttpMessageHandler(CreateEyeballsHttpClientHandler);
.ConfigurePrimaryHttpMessageHandler(eyeballsHttpClientHandlerDelegate);
services.AddHttpClient(NamedClient.DirectIp, c =>
{
@@ -130,7 +123,7 @@ namespace Jellyfin.Server
c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader);
c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
})
.ConfigurePrimaryHttpMessageHandler(CreateDefaultHttpClientHandler);
.ConfigurePrimaryHttpMessageHandler(defaultHttpClientHandlerDelegate);
services.AddHealthChecks()
.AddCheck<DbContextFactoryHealthCheck<JellyfinDbContext>>(nameof(JellyfinDbContext));
@@ -175,8 +168,6 @@ namespace Jellyfin.Server
mainApp.UseWebSockets();
mainApp.UseWebSocketAuthentication();
mainApp.UseResponseCompression();
mainApp.UseCors();
+2 -4
View File
@@ -264,13 +264,11 @@ BEGIN
ORDER BY mean_exec_time DESC
LIMIT 10
LOOP
RAISE NOTICE 'Calls: %, Avg: %ms, Max: %ms, Total: %ms, %%: %, Query: %',
RAISE NOTICE 'Calls: %, Avg: %ms, Max: %ms, Total: %ms, Percent: %%',
query_rec.calls,
query_rec.avg_time_ms,
query_rec.max_time_ms,
query_rec.total_time_ms,
query_rec.percent_total,
query_rec.query_preview;
query_rec.total_time_ms;
END LOOP;
END IF;
END $$;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9,6 +9,6 @@
"CacheDir": "C:/ProgramData/jellyfin/cache",
"LogDir": "C:/ProgramData/jellyfin/log",
"TempDir": "C:\\Users\\wjones\\AppData\\Local\\Temp\\jellyfin",
"WebDir": "C:/ProgramData/jellyfin/wwwroot"
"WebDir": "wwwroot"
}
}
+16 -2
View File
@@ -1,4 +1,4 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.0.11222.15
@@ -93,6 +93,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Jellyfin.Database", "Jellyf
src\Jellyfin.Database\readme.md = src\Jellyfin.Database\readme.md
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.Sqlite", "src\Jellyfin.Database\Jellyfin.Database.Providers.Sqlite\Jellyfin.Database.Providers.Sqlite.csproj", "{A5590358-33CC-4B39-BDE7-DC62FEB03C76}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Implementations", "src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj", "{8C9F9221-8415-496C-B1F5-E7756F03FA59}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.CodeAnalysis", "src\Jellyfin.CodeAnalysis\Jellyfin.CodeAnalysis.csproj", "{11643D0F-6761-4EF7-AB71-6F9F8DE00714}"
@@ -553,6 +555,18 @@ Global
{8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Release|x64.Build.0 = Release|Any CPU
{8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Release|x86.ActiveCfg = Release|Any CPU
{8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Release|x86.Build.0 = Release|Any CPU
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|x64.ActiveCfg = Debug|Any CPU
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|x64.Build.0 = Debug|Any CPU
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|x86.ActiveCfg = Debug|Any CPU
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|x86.Build.0 = Debug|Any CPU
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|Any CPU.Build.0 = Release|Any CPU
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|x64.ActiveCfg = Release|Any CPU
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|x64.Build.0 = Release|Any CPU
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|x86.ActiveCfg = Release|Any CPU
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|x86.Build.0 = Release|Any CPU
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -618,6 +632,7 @@ Global
{C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
{8C6B2B13-58A4-4506-9DAB-1F882A093FE0} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
{4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
{A5590358-33CC-4B39-BDE7-DC62FEB03C76} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
{8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
{11643D0F-6761-4EF7-AB71-6F9F8DE00714} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
{E1A314DC-837D-4472-AC15-EC08947C55B7} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
@@ -644,4 +659,3 @@ Global
$2.DirectoryNamespaceAssociation = PrefixedHierarchical
EndGlobalSection
EndGlobal
@@ -11,7 +11,6 @@
<VersionPrefix>10.12.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
<NoWarn>$(NoWarn);CA1054;CA1055;CA1308;CA1031;CA1032;CA2007;CA1822;CA1848;CA1303;CA1508;CA1805;CA1056;CA1062;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517;CA1865;CA1869;CA1716;CA2101;SA1127</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -20,7 +20,6 @@ using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions.Json;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.IO;
@@ -70,8 +69,6 @@ namespace MediaBrowser.Controller.Entities
public static IServerApplicationHost ApplicationHost { get; set; }
public static ILibraryOptionsRepository LibraryOptionsRepository { get; set; }
[JsonIgnore]
public override bool SupportsPlayedStatus => false;
@@ -115,24 +112,9 @@ namespace MediaBrowser.Controller.Entities
private static LibraryOptions LoadLibraryOptions(string path)
{
if (LibraryOptionsRepository is not null)
{
var result = LibraryOptionsRepository.GetLibraryOptions(path);
if (result is not null)
{
return result;
}
}
var xmlPath = GetLibraryOptionsPath(path);
if (!File.Exists(xmlPath))
{
return new LibraryOptions();
}
try
{
if (XmlSerializer.DeserializeFromFile(typeof(LibraryOptions), xmlPath) is not LibraryOptions result)
if (XmlSerializer.DeserializeFromFile(typeof(LibraryOptions), GetLibraryOptionsPath(path)) is not LibraryOptions result)
{
return new LibraryOptions();
}
@@ -145,8 +127,6 @@ namespace MediaBrowser.Controller.Entities
}
}
LibraryOptionsRepository?.SaveLibraryOptions(path, result);
return result;
}
catch (FileNotFoundException)
@@ -182,8 +162,6 @@ namespace MediaBrowser.Controller.Entities
{
_libraryOptions[path] = options;
LibraryOptionsRepository?.SaveLibraryOptions(path, options);
var clone = JsonSerializer.Deserialize<LibraryOptions>(JsonSerializer.SerializeToUtf8Bytes(options, _jsonOptions), _jsonOptions);
foreach (var mediaPath in clone.PathInfos)
{
+1 -7
View File
@@ -420,7 +420,6 @@ namespace MediaBrowser.Controller.Entities
// Create a list for our validated children
var newItems = new List<BaseItem>();
var changedItems = new List<BaseItem>();
cancellationToken.ThrowIfCancellationRequested();
@@ -437,7 +436,7 @@ namespace MediaBrowser.Controller.Entities
if (currentChild.UpdateFromResolvedItem(child) > ItemUpdateType.None)
{
changedItems.Add(currentChild);
await currentChild.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
}
else
{
@@ -485,11 +484,6 @@ namespace MediaBrowser.Controller.Entities
LibraryManager.CreateItems(newItems, this, cancellationToken);
}
if (changedItems.Count > 0)
{
await LibraryManager.UpdateItemsAsync(changedItems, this, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
}
// After removing items, reattach any detached user data to remaining children
// that share the same user data keys (eg. same episode replaced with a new file).
if (actuallyRemoved.Count > 0)
@@ -218,7 +218,7 @@ namespace MediaBrowser.Controller.Entities.TV
query.AncestorWithPresentationUniqueKey = null;
query.SeriesPresentationUniqueKey = seriesKey;
query.IncludeItemTypes = new[] { BaseItemKind.Season };
query.OrderBy = new[] { (ItemSortBy.ParentIndexNumber, SortOrder.Ascending), (ItemSortBy.IndexNumber, SortOrder.Ascending) };
query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
if (user is not null && !user.DisplayMissingEpisodes)
{
@@ -204,12 +204,6 @@ namespace MediaBrowser.Controller.Entities
query.IncludeItemTypes = new[] { BaseItemKind.Movie };
// Ensure alphabetical ordering if not specified
if (query.OrderBy.Count == 0)
{
query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
}
return _libraryManager.GetItemsResult(query);
}
@@ -378,12 +372,6 @@ namespace MediaBrowser.Controller.Entities
query.IncludeItemTypes = new[] { BaseItemKind.Series };
// Ensure alphabetical ordering if not specified
if (query.OrderBy.Count == 0)
{
query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
}
return _libraryManager.GetItemsResult(query);
}
@@ -68,6 +68,11 @@ 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>
@@ -137,5 +142,13 @@ namespace MediaBrowser.Controller.Extensions
/// <returns>The unix socket permissions.</returns>
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);
}
}
@@ -115,7 +115,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
if (fanoutConcurrency <= 0)
{
// in case the user did not set a limit manually, we can assume he has 3 or more cores as already checked by ShouldForceSequentialOperation.
return Math.Max(2, Environment.ProcessorCount / 2);
return Environment.ProcessorCount - 3;
}
return fanoutConcurrency;
@@ -2,12 +2,10 @@
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<ProjectGuid>{17E1F4E6-8ABD-4FE5-9ECF-43D4B6087BA2}</ProjectGuid>
</PropertyGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Controller</PackageId>
<VersionPrefix>10.12.0</VersionPrefix>
@@ -16,7 +14,6 @@
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<CodeAnalysisTreatWarningsAsErrors>false</CodeAnalysisTreatWarningsAsErrors>
</PropertyGroup>
@@ -38,7 +35,6 @@
</ItemGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<TargetFramework>net11.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
@@ -49,7 +45,6 @@
</PropertyGroup>
<PropertyGroup Condition=" '$(Stability)'=='Unstable'">
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<!-- Include all symbols in the main nupkg until Azure Artifact Feed starts supporting ingesting NuGet symbol packages. -->
<AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder>
</PropertyGroup>
@@ -1,18 +0,0 @@
// <copyright file="ILibraryOptionsRepository.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
#nullable disable
#pragma warning disable CS1591
using MediaBrowser.Model.Configuration;
namespace MediaBrowser.Controller.Persistence;
public interface ILibraryOptionsRepository
{
LibraryOptions GetLibraryOptions(string libraryPath);
void SaveLibraryOptions(string libraryPath, LibraryOptions options);
}
@@ -7,7 +7,6 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MediaBrowser.Model.IO;
@@ -31,22 +30,7 @@ namespace MediaBrowser.Controller.Providers
public FileSystemMetadata[] GetFileSystemEntries(string path)
{
if (_cache.TryGetValue(path, out var result))
{
return result;
}
try
{
result = _fileSystem.GetFileSystemEntries(path).ToArray();
_cache.TryAdd(path, result);
return result;
}
catch (DirectoryNotFoundException)
{
// A folder may be deleted between scans; treat it as empty so refresh logic can continue.
return Array.Empty<FileSystemMetadata>();
}
return _cache.GetOrAdd(path, static (p, fileSystem) => fileSystem.GetFileSystemEntries(p).ToArray(), _fileSystem);
}
public List<FileSystemMetadata> GetDirectories(string path)
@@ -178,6 +178,16 @@ namespace MediaBrowser.Controller.Session
/// <value>The application version.</value>
public string ApplicationVersion { get; set; }
/// <summary>
/// Gets or sets the instance ID that owns this session.
/// </summary>
/// <value>The instance ID.</value>
/// <remarks>
/// Used for multi-instance deployments to track which Jellyfin instance
/// is managing this session's resources (transcoding, network connections, etc.).
/// </remarks>
public Guid InstanceId { get; set; }
/// <summary>
/// Gets or sets the session controller.
/// </summary>
@@ -2,7 +2,6 @@
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<ProjectGuid>{7EF9F3E0-697D-42F3-A08F-19DEB5F84392}</ProjectGuid>
</PropertyGroup>
@@ -12,7 +11,6 @@
</ItemGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<TargetFramework>net11.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
@@ -2,12 +2,10 @@
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<ProjectGuid>{960295EE-4AF4-4440-A525-B4C295B01A61}</ProjectGuid>
</PropertyGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<TargetFramework>net11.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
@@ -2,12 +2,10 @@
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<ProjectGuid>{7EEEB4BB-F3E8-48FC-B4C5-70F0FFF8329B}</ProjectGuid>
</PropertyGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Model</PackageId>
<VersionPrefix>10.12.0</VersionPrefix>
@@ -16,7 +14,6 @@
</PropertyGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<TargetFramework>net11.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
@@ -27,12 +24,10 @@
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<CodeAnalysisTreatWarningsAsErrors>false</CodeAnalysisTreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup Condition=" '$(Stability)'=='Unstable'">
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<!-- Include all symbols in the main nupkg until Azure Artifact Feed starts supporting ingesting NuGet symbol packages. -->
<AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder>
</PropertyGroup>
@@ -2,7 +2,6 @@
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517;NU1510</NoWarn>
<ProjectGuid>{442B5058-DCAF-4263-BB6A-F21E31120A1B}</ProjectGuid>
</PropertyGroup>
@@ -20,6 +19,7 @@
<PackageReference Include="Jellyfin.Sdk" />
<PackageReference Include="LrcParser" />
<PackageReference Include="MetaBrainz.MusicBrainz" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="PlaylistsNET" />
@@ -28,7 +28,6 @@
</ItemGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<TargetFramework>net11.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
@@ -286,7 +286,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (ep is not null)
{
seasonNumber = ep.SeasonNumber;
episodeNumber = (int)ep.EpisodeNumber;
episodeNumber = ep.EpisodeNumber;
}
}
@@ -2,7 +2,6 @@
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<ProjectGuid>{23499896-B135-4527-8574-C26E926EA99E}</ProjectGuid>
</PropertyGroup>
@@ -16,7 +15,6 @@
</ItemGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
<TargetFramework>net11.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
+142
View File
@@ -0,0 +1,142 @@
# Quick Reference - Publishing Multi-Instance Jellyfin
## One-Command Publishing
```powershell
# Windows self-contained (recommended)
dotnet publish -p:PublishProfile=MultiInstance-Win-x64
# Linux self-contained
dotnet publish -p:PublishProfile=MultiInstance-Linux-x64
# Framework-dependent (smaller, requires .NET 11 runtime)
dotnet publish -p:PublishProfile=MultiInstance-FrameworkDependent
```
## Automated Deployment
```powershell
# Publish and deploy in one step
.\PublishAndDeploy.ps1
# Options
.\PublishAndDeploy.ps1 -Profile MultiInstance-Linux-x64 -SkipDeploy
.\PublishAndDeploy.ps1 -CreatePackage -PackageOutput "C:\Releases"
.\PublishAndDeploy.ps1 -DeployPath "D:\Jellyfin" -Verbose
```
## Output Locations
```
Profile: MultiInstance-Win-x64
Output: lib\Release\net11.0\win-x64\publish\
Profile: MultiInstance-Linux-x64
Output: lib\Release\net11.0\linux-x64\publish\
Profile: MultiInstance-FrameworkDependent
Output: lib\Release\net11.0\win-x64\publish\
```
## Essential Files in Package
```
jellyfin.exe # Main application
Npgsql.dll # PostgreSQL driver
sql\add_multi_instance_support.sql # Multi-instance setup script
startup.json # Configuration file
startup.json.windows # Windows template
startup.json.linux # Linux template
```
## First-Time Deployment Checklist
- [ ] 1. Publish application using a profile
- [ ] 2. Copy to target server
- [ ] 3. Create `config/database.xml` with PostgreSQL connection
- [ ] 4. Run `sql/add_multi_instance_support.sql` on database
- [ ] 5. Start Jellyfin
## Database Configuration Template
```xml
<!-- config/database.xml -->
<?xml version="1.0" encoding="utf-8"?>
<DatabaseConfigurationOptions>
<DatabaseType>Jellyfin-PostgreSQL</DatabaseType>
<CustomProviderOptions>
<ConnectionString>Host=YOUR_SERVER;Port=5432;Database=jellyfin;Username=jellyfin;Password=YOUR_PASSWORD</ConnectionString>
</CustomProviderOptions>
</DatabaseConfigurationOptions>
```
## Multi-Instance Setup SQL
```powershell
# Run once per database (not per instance!)
psql -h YOUR_SERVER -U jellyfin -d jellyfin -f sql\add_multi_instance_support.sql
```
## Verify Deployment
```powershell
# Check critical files
Test-Path "C:\Jellyfin\jellyfin.exe"
Test-Path "C:\Jellyfin\sql\add_multi_instance_support.sql"
Test-Path "C:\Jellyfin\config\database.xml"
# Test PostgreSQL connection
Test-NetConnection -ComputerName YOUR_SERVER -Port 5432
```
## Start Application
```powershell
# Windows
cd C:\Jellyfin
.\jellyfin.exe
# Linux
cd /opt/jellyfin
./jellyfin
# Custom port
.\jellyfin.exe --port 8097
```
## Verify Multi-Instance Setup
```sql
-- Check registered instances
SELECT instance_id, host_name, is_active FROM library.instances;
-- Check primary instance
SELECT library.get_primary_instance();
-- Check heartbeats
SELECT instance_id, last_heartbeat FROM library.instances WHERE is_active = true;
```
## Common Issues
| Issue | Solution |
|-------|----------|
| "Failed to connect to 127.0.0.1:5432" | Create `config/database.xml` with correct connection |
| SQL script not found | Verify `sql/add_multi_instance_support.sql` exists in publish output |
| Port already in use | Use `--port 8097` to specify different port |
| .NET runtime not found (Framework-Dependent) | Install .NET 11 runtime on target server |
## Package Sizes
- **Self-Contained**: ~210 MB (includes .NET runtime)
- **Framework-Dependent**: ~100 MB (requires .NET 11 runtime)
## Documentation
- 📘 `docs/PUBLISHING_PROFILES_GUIDE.md` - Complete guide
- 📘 `docs/PUBLISH_PROFILES_SETUP_COMPLETE.md` - Setup summary
- 📘 `docs/MULTI_INSTANCE_COMPLETE.md` - Multi-instance overview
---
**Quick Help**: For detailed instructions, see `docs/PUBLISHING_PROFILES_GUIDE.md`
+269
View File
@@ -0,0 +1,269 @@
# PublishAndDeploy.ps1
# Automated publish and deployment script for Jellyfin multi-instance builds
param(
[ValidateSet("MultiInstance-Win-x64", "MultiInstance-Linux-x64", "MultiInstance-FrameworkDependent")]
[string]$Profile = "MultiInstance-Win-x64",
[string]$DeployPath = "D:\Program Files\Jellyfin-multiinstance",
[switch]$SkipDeploy,
[switch]$CreatePackage,
[string]$PackageOutput = ".",
[switch]$Verbose
)
$ErrorActionPreference = "Stop"
# Colors
function Write-Step {
param([string]$Message)
Write-Host "[>] $Message" -ForegroundColor Cyan
}
function Write-Success {
param([string]$Message)
Write-Host "[+] $Message" -ForegroundColor Green
}
function Write-Warning {
param([string]$Message)
Write-Host "[!] $Message" -ForegroundColor Yellow
}
function Write-Failure {
param([string]$Message)
Write-Host "[X] $Message" -ForegroundColor Red
}
# Header
Write-Host ""
Write-Host "================================================================" -ForegroundColor Cyan
Write-Host " Jellyfin Multi-Instance Publisher & Deployer" -ForegroundColor Cyan
Write-Host "================================================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Profile: $Profile" -ForegroundColor White
Write-Host "Deploy Path: $DeployPath" -ForegroundColor White
Write-Host "Skip Deploy: $SkipDeploy" -ForegroundColor White
Write-Host "Create Pkg: $CreatePackage`n" -ForegroundColor White
# Validate project exists
$projectPath = "Jellyfin.Server\Jellyfin.Server.csproj"
if (-not (Test-Path $projectPath)) {
Write-Failure "Project not found: $projectPath"
Write-Host "Please run this script from the solution root directory.`n" -ForegroundColor Yellow
exit 1
}
Write-Success "Found project: $projectPath"
# Step 1: Clean
Write-Step "Cleaning previous builds..."
dotnet clean $projectPath --configuration Release --verbosity minimal
if ($LASTEXITCODE -ne 0) {
Write-Failure "Clean failed!"
exit 1
}
Write-Success "Clean completed"
# Step 2: Restore
Write-Step "Restoring NuGet packages..."
dotnet restore $projectPath --verbosity minimal
if ($LASTEXITCODE -ne 0) {
Write-Failure "Restore failed!"
exit 1
}
Write-Success "Restore completed"
# Step 3: Publish
Write-Step "Publishing with profile: $Profile..."
$verbosityArg = if ($Verbose) { "normal" } else { "minimal" }
dotnet publish $projectPath `
-p:PublishProfile=$Profile `
--configuration Release `
--verbosity $verbosityArg `
--nologo
if ($LASTEXITCODE -ne 0) {
Write-Failure "Publish failed!"
exit 1
}
Write-Success "Publish completed"
# Step 4: Verify output
$publishDir = "Jellyfin.Server\bin\Publish\$Profile"
Write-Step "Verifying publish output at: $publishDir"
if (-not (Test-Path $publishDir)) {
Write-Failure "Publish directory not found: $publishDir"
exit 1
}
$exeName = if ($Profile -like "*Win*") { "jellyfin.exe" } else { "jellyfin" }
$exePath = Join-Path $publishDir $exeName
if (-not (Test-Path $exePath)) {
Write-Failure "Executable not found: $exePath"
exit 1
}
# Verify SQL scripts
$sqlPath = Join-Path $publishDir "sql\add_multi_instance_support.sql"
if (-not (Test-Path $sqlPath)) {
Write-Warning "SQL script not found (expected at: $sqlPath)"
} else {
Write-Success "SQL scripts included"
}
# Verify documentation
$docsPath = Join-Path $publishDir "docs"
if (Test-Path $docsPath) {
$docCount = (Get-ChildItem $docsPath -Filter "*.md").Count
Write-Success "Documentation included ($docCount files)"
} else {
Write-Warning "Documentation directory not found"
}
# Get publish size
$publishSize = (Get-ChildItem $publishDir -Recurse | Measure-Object -Property Length -Sum).Sum / 1MB
Write-Success "Publish output verified (Size: $($publishSize.ToString('N2')) MB)"
# Step 5: Create package (optional)
if ($CreatePackage) {
Write-Step "Creating deployment package..."
$version = Get-Date -Format "yyyyMMdd-HHmmss"
$packageName = "Jellyfin-MultiInstance-$version.zip"
$packagePath = Join-Path $PackageOutput $packageName
Compress-Archive -Path "$publishDir\*" `
-DestinationPath $packagePath `
-CompressionLevel Optimal `
-Force
if (Test-Path $packagePath) {
$packageSize = (Get-Item $packagePath).Length / 1MB
Write-Success "Package created: $packagePath ($($packageSize.ToString('N2')) MB)"
} else {
Write-Failure "Failed to create package"
exit 1
}
}
# Step 6: Deploy (optional)
if (-not $SkipDeploy) {
Write-Step "Deploying to: $DeployPath"
# Check if jellyfin is running
$jellyfinProcess = Get-Process -Name "jellyfin" -ErrorAction SilentlyContinue
if ($jellyfinProcess) {
Write-Warning "Jellyfin is currently running (PID: $($jellyfinProcess.Id))"
$response = Read-Host "Stop Jellyfin and continue? (y/N)"
if ($response -eq 'y' -or $response -eq 'Y') {
Write-Step "Stopping Jellyfin..."
$jellyfinProcess | Stop-Process -Force
Start-Sleep -Seconds 2
Write-Success "Jellyfin stopped"
} else {
Write-Warning "Deployment cancelled by user"
exit 0
}
}
# Backup existing installation
if (Test-Path $DeployPath) {
$backupPath = "$DeployPath.backup-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
Write-Step "Backing up existing installation to: $backupPath"
try {
Copy-Item -Path $DeployPath -Destination $backupPath -Recurse -Force
Write-Success "Backup created"
} catch {
Write-Warning "Backup failed: $_"
$response = Read-Host "Continue without backup? (y/N)"
if ($response -ne 'y' -and $response -ne 'Y') {
exit 1
}
}
}
# Create deploy directory
if (-not (Test-Path $DeployPath)) {
New-Item -ItemType Directory -Path $DeployPath -Force | Out-Null
}
# Copy files
Write-Step "Copying files to deployment directory..."
try {
Copy-Item -Path "$publishDir\*" -Destination $DeployPath -Recurse -Force
Write-Success "Files copied successfully"
} catch {
Write-Failure "Deployment failed: $_"
exit 1
}
# Verify deployment
$deployedExe = Join-Path $DeployPath $exeName
if (Test-Path $deployedExe) {
Write-Success "Deployment verified"
} else {
Write-Failure "Deployment verification failed"
exit 1
}
Write-Success "Deployment complete!"
# Post-deployment instructions
Write-Host ""
Write-Host "================================================================" -ForegroundColor Green
Write-Host " Deployment Successful!" -ForegroundColor Green
Write-Host "================================================================" -ForegroundColor Green
Write-Host ""
Write-Host "Next Steps:" -ForegroundColor Yellow
Write-Host "1. Create database.xml in config directory" -ForegroundColor White
Write-Host " Location: " -NoNewline -ForegroundColor White
Write-Host "$DeployPath\config\database.xml" -ForegroundColor Cyan
Write-Host "`n2. Run multi-instance SQL setup (first deployment only):" -ForegroundColor White
Write-Host " psql -h YOUR_SERVER -U jellyfin -d jellyfin -f $DeployPath\sql\add_multi_instance_support.sql" -ForegroundColor Cyan
Write-Host "`n3. Start Jellyfin:" -ForegroundColor White
Write-Host " cd `"$DeployPath`"" -ForegroundColor Cyan
Write-Host " .\$exeName" -ForegroundColor Cyan
Write-Host ""
} else {
Write-Step "Skipping deployment (use -SkipDeploy:`$false to deploy)"
Write-Host ""
Write-Host "================================================================" -ForegroundColor Green
Write-Host " Publish Successful!" -ForegroundColor Green
Write-Host "================================================================" -ForegroundColor Green
Write-Host ""
Write-Host "Publish Directory:" -ForegroundColor Yellow
Write-Host " $publishDir" -ForegroundColor Cyan
Write-Host "`nTo deploy manually:" -ForegroundColor Yellow
Write-Host " Copy-Item -Path `"$publishDir\*`" -Destination `"YOUR_PATH`" -Recurse -Force" -ForegroundColor Cyan
Write-Host ""
}
# Summary
Write-Host "================================================================" -ForegroundColor Gray
Write-Host "Profile: $Profile" -ForegroundColor White
Write-Host "Published: $publishDir" -ForegroundColor White
Write-Host "Size: $($publishSize.ToString('N2')) MB" -ForegroundColor White
if ($CreatePackage) {
Write-Host "Package: $packagePath" -ForegroundColor White
}
if (-not $SkipDeploy) {
Write-Host "Deployed to: $DeployPath" -ForegroundColor White
}
Write-Host "================================================================" -ForegroundColor Gray
Write-Host ""
exit 0
-133
View File
@@ -1,133 +0,0 @@
# PowerShell script to convert remaining SQL identifiers from quoted PascalCase to lowercase/snake_case
# This script handles constraints, indexes, and foreign keys
param([string]$filepath = "Jellyfin.Server/sql/schema_init/create_database_schema.sql")
$content = Get-Content $filepath -Raw
# Table name mappings for constraint/index/FK replacement
$tableMappings = @{
'"ActivityLogs"' = 'activity_logs'
'"ApiKeys"' = 'api_keys'
'"DeviceOptions"' = 'device_options'
'"Devices"' = 'devices'
'"CustomItemDisplayPreferences"' = 'custom_item_display_preferences'
'"DisplayPreferences"' = 'display_preferences'
'"HomeSection"' = 'home_sections'
'"ItemDisplayPreferences"' = 'item_display_preferences'
'"AncestorIds"' = 'ancestor_ids'
'"AttachmentStreamInfos"' = 'attachment_stream_infos'
'"BaseItemImageInfos"' = 'base_item_image_infos'
'"BaseItemMetadataFields"' = 'base_item_metadata_fields'
'"BaseItemProviders"' = 'base_item_providers'
'"BaseItemTrailerTypes"' = 'base_item_trailer_types'
'"BaseItems"' = 'base_items'
'"Chapters"' = 'chapters'
'"ImageInfos"' = 'image_infos'
'"ItemValues"' = 'item_values'
'"ItemValuesMap"' = 'item_values_map'
'"KeyframeData"' = 'keyframe_data'
'"MediaSegments"' = 'media_segments'
'"MediaStreamInfos"' = 'media_stream_infos'
'"PeopleBaseItemMap"' = 'people_base_item_map'
'"Peoples"' = 'peoples'
'"TrickplayInfos"' = 'trickplay_infos'
'"UserData"' = 'user_data'
'"__EFMigrationsHistory"' = '__ef_migrations_history'
'"AccessSchedules"' = 'access_schedules'
'"Permissions"' = 'permissions'
'"Preferences"' = 'preferences'
'"Users"' = 'users'
}
# Column name mappings (critical columns that appear in constraints)
$columnMappings = @{
'"Id"' = 'id'
'"ParentItemId"' = 'parent_item_id'
'"ItemId"' = 'item_id'
'"Index"' = 'index'
'"UserId"' = 'user_id'
'"DisplayPreferencesId"' = 'display_preferences_id'
'"ItemValueId"' = 'item_value_id'
'"ChapterIndex"' = 'chapter_index'
'"PeopleId"' = 'people_id'
'"StreamIndex"' = 'stream_index'
'"CustomDataKey"' = 'custom_data_key'
'"MigrationId"' = 'migration_id'
'"ProductVersion"' = 'product_version'
'"DayOfWeek"' = 'day_of_week'
'"StartHour"' = 'start_hour'
'"EndHour"' = 'end_hour'
'"Kind"' = 'kind'
'"Value"' = 'value'
'"RowVersion"' = 'row_version'
'"Permission_Permissions_Guid"' = 'permission_permissions_guid'
'"Preference_Preferences_Guid"' = 'preference_preferences_guid'
}
# Apply table replacements
foreach ($oldTable in $tableMappings.Keys) {
$newTable = $tableMappings[$oldTable]
$content = $content -replace [regex]::Escape($oldTable), $newTable
}
# Apply column replacements
foreach ($oldCol in $columnMappings.Keys) {
$newCol = $columnMappings[$oldCol]
$content = $content -replace [regex]::Escape($oldCol), $newCol
}
# Constraint name replacements (remove quotes from constraint names)
$content = $content -replace 'ADD CONSTRAINT "([^"]+)"', 'ADD CONSTRAINT $1'
# Index name replacements (remove quotes from index names)
$content = $content -replace 'CREATE (\w+ )?INDEX "([^"]+)"', 'CREATE $1INDEX $2'
# Handle remaining quoted identifiers in special cases
$content = $content -replace '"ProviderId"', 'provider_id'
$content = $content -replace '"ProviderValue"', 'provider_value'
$content = $content -replace '"SortOrder"', 'sort_order'
$content = $content -replace '"ListOrder"', 'list_order'
$content = $content -replace '"Role"', 'role'
$content = $content -replace '"Name"', 'name'
$content = $content -replace '"Path"', 'path'
$content = $content -replace '"DateCreated"', 'date_created'
$content = $content -replace '"DateLastActivity"', 'date_last_activity'
$content = $content -replace '"DateModified"', 'date_modified'
$content = $content -replace '"DateLastSaved"', 'date_last_saved'
$content = $content -replace '"LastPlayedDate"', 'last_played_date'
$content = $content -replace '"Played"', 'played'
$content = $content -replace '"IsFavorite"', 'is_favorite'
$content = $content -replace '"PlaybackPositionTicks"', 'playback_position_ticks'
$content = $content -replace '"Type"', 'type'
$content = $content -replace '"AccessToken"', 'access_token'
$content = $content -replace '"DeviceId"', 'device_id'
$content = $content -replace '"Client"', 'client'
$content = $content -replace '"Username"', 'username'
$content = $content -replace '"StartDate"', 'start_date'
$content = $content -replace '"EndDate"', 'end_date'
$content = $content -replace '"IsFolder"', 'is_folder'
$content = $content -replace '"IsVirtualItem"', 'is_virtual_item'
$content = $content -replace '"PresentationUniqueKey"', 'presentation_unique_key'
$content = $content -replace '"SeriesPresentationUniqueKey"', 'series_presentation_unique_key'
$content = $content -replace '"TopParentId"', 'top_parent_id'
$content = $content -replace '"ProviderId"', 'provider_id'
$content = $content -replace '"StreamType"', 'stream_type'
$content = $content -replace '"Language"', 'language'
$content = $content -replace '"Codec"', 'codec'
$content = $content -replace '"CommunityRating"', 'community_rating'
$content = $content -replace '"ProductionYear"', 'production_year'
$content = $content -replace '"PremiereDate"', 'premiere_date'
$content = $content -replace '"SortName"', 'sort_name'
$content = $content -replace '"CleanValue"', 'clean_value'
$content = $content -replace '"TotalDuration"', 'total_duration'
$content = $content -replace '"KeyframeTicks"', 'keyframe_ticks'
$content = $content -replace '"EndTicks"', 'end_ticks'
$content = $content -replace '"StartTicks"', 'start_ticks'
$content = $content -replace '"SegmentProviderId"', 'segment_provider_id'
$content = $content -replace '"AudioStreamIndex"', 'audio_stream_index'
$content = $content -replace '"SubtitleStreamIndex"', 'subtitle_stream_index'
$content = $content -replace '"RetentionDate"', 'retention_date'
Set-Content -Path $filepath -Value $content
Write-Host "SQL identifiers conversion complete!"
@@ -1,234 +0,0 @@
# Database Schema Mismatch - Detailed Entity Mapping
## Quick Reference: All Mismatches
### ACTIVITYLOG Schema (1 table)
| Entity | SQL Table Name | C# Expected Name | Columns Match? | Status |
|--------|----------------|------------------|-----------------|--------|
| ActivityLog | `"ActivityLogs"` | `activity_logs` | NO (11 cols) | ❌ CRITICAL |
**SQL Columns:**
- "Id", "Name", "Overview", "ShortOverview", "Type", "UserId", "ItemId", "DateCreated", "LogSeverity", "RowVersion"
**C# Expected Columns (via SnakeCaseNamingConvention):**
- id, name, overview, short_overview, type, user_id, item_id, date_created, log_severity, row_version
---
### AUTHENTICATION Schema (3 tables)
| Entity | SQL Table Name | C# Expected Name | Columns Match? | Status |
|--------|----------------|------------------|-----------------|--------|
| ApiKey | `"ApiKeys"` | `api_keys` | NO (5 cols) | ❌ CRITICAL |
| Device | `"Devices"` | `devices` | NO (11 cols) | ❌ CRITICAL |
| DeviceOptions | `"DeviceOptions"` | `device_options` | NO (3 cols) | ❌ CRITICAL |
#### ApiKey
**SQL Columns:** "Id", "DateCreated", "DateLastActivity", "Name", "AccessToken"
**C# Expected:** id, date_created, date_last_activity, name, access_token
#### Device
**SQL Columns:** "Id", "UserId", "AccessToken", "AppName", "AppVersion", "DeviceName", "DeviceId", "IsActive", "DateCreated", "DateModified", "DateLastActivity"
**C# Expected:** id, user_id, access_token, app_name, app_version, device_name, device_id, is_active, date_created, date_modified, date_last_activity
#### DeviceOptions
**SQL Columns:** "Id", "DeviceId", "CustomName"
**C# Expected:** id, device_id, custom_name
---
### DISPLAYPREFERENCES Schema (4 tables)
| Entity | SQL Table Name | C# Expected Name | Columns Match? | Status |
|--------|----------------|------------------|-----------------|--------|
| CustomItemDisplayPreferences | `"CustomItemDisplayPreferences"` | `custom_item_display_preferences` | NO (6 cols) | ❌ CRITICAL |
| DisplayPreferences | `"DisplayPreferences"` | `display_preferences` | NO (14 cols) | ❌ CRITICAL |
| HomeSection | `"HomeSection"` | `home_sections` | NO (4 cols) | ❌ CRITICAL |
| ItemDisplayPreferences | `"ItemDisplayPreferences"` | `item_display_preferences` | NO (10 cols) | ❌ CRITICAL |
#### CustomItemDisplayPreferences
**SQL Columns:** "Id", "UserId", "ItemId", "Client", "Key", "Value"
**C# Expected:** id, user_id, item_id, client, key, value
#### DisplayPreferences
**SQL Columns:** "Id", "UserId", "ItemId", "Client", "ShowSidebar", "ShowBackdrop", "ScrollDirection", "IndexBy", "SkipForwardLength", "SkipBackwardLength", "ChromecastVersion", "EnableNextVideoInfoOverlay", "DashboardTheme", "TvHome"
**C# Expected:** id, user_id, item_id, client, show_sidebar, show_backdrop, scroll_direction, index_by, skip_forward_length, skip_backward_length, chromecast_version, enable_next_video_info_overlay, dashboard_theme, tv_home
#### HomeSection
**SQL Columns:** "Id", "DisplayPreferencesId", "Order", "Type"
**C# Expected:** id, display_preferences_id, order, type
#### ItemDisplayPreferences
**SQL Columns:** "Id", "UserId", "ItemId", "Client", "ViewType", "RememberIndexing", "IndexBy", "RememberSorting", "SortBy", "SortOrder"
**C# Expected:** id, user_id, item_id, client, view_type, remember_indexing, index_by, remember_sorting, sort_by, sort_order
---
### LIBRARY Schema (15 tables - MOST CRITICAL)
| Entity | SQL Table Name | C# Expected Name | Columns Match? | Status |
|--------|----------------|------------------|-----------------|--------|
| AncestorId | `"AncestorIds"` | `ancestor_ids` | NO (2 cols) | ❌ |
| AttachmentStreamInfo | `"AttachmentStreamInfos"` | `attachment_stream_infos` | NO (7 cols) | ❌ |
| BaseItemImageInfo | `"BaseItemImageInfos"` | `base_item_image_infos` | NO (8 cols) | ❌ |
| BaseItemMetadataField | `"BaseItemMetadataFields"` | `base_item_metadata_fields` | NO (2 cols) | ❌ |
| BaseItemProvider | `"BaseItemProviders"` | `base_item_providers` | NO (3 cols) | ❌ |
| BaseItemTrailerType | `"BaseItemTrailerTypes"` | `base_item_trailer_types` | NO (2 cols) | ❌ |
| BaseItemEntity | `"BaseItems"` | `base_items` | NO (73 cols!) | ❌ SEVERE |
| Chapter | `"Chapters"` | `chapters` | NO (6 cols) | ❌ |
| ImageInfo | `"ImageInfos"` | `image_infos` | NO (4 cols) | ❌ |
| ItemValue | `"ItemValues"` | `item_values` | NO (4 cols) | ❌ |
| ItemValueMap | `"ItemValuesMap"` | `item_values_map` | NO (2 cols) | ❌ |
| KeyframeData | `"KeyframeData"` | `keyframe_data` | NO (3 cols) | ❌ |
| MediaSegment | `"MediaSegments"` | `media_segments` | NO (6 cols) | ❌ |
| MediaStreamInfo | `"MediaStreamInfos"` | `media_stream_infos` | NO (60+ cols) | ❌ SEVERE |
| PeopleBaseItemMap | `"PeopleBaseItemMap"` | `people_base_item_map` | NO (5 cols) | ❌ |
| People | `"Peoples"` | `peoples` | NO (3 cols) | ❌ |
| TrickplayInfo | `"TrickplayInfos"` | `trickplay_infos` | NO (8 cols) | ❌ |
| UserData | `"UserData"` | `user_data` | NO (13 cols) | ❌ |
#### BaseItemEntity (MOST CRITICAL - 73 columns)
**SQL Columns:**
"Id", "Type", "Data", "Path", "StartDate", "EndDate", "ChannelId", "IsMovie", "CommunityRating", "CustomRating", "IndexNumber", "IsLocked", "Name", "OfficialRating", "MediaType", "Overview", "ParentIndexNumber", "PremiereDate", "ProductionYear", "Genres", "SortName", "ForcedSortName", "RunTimeTicks", "DateCreated", "DateModified", "IsSeries", "EpisodeTitle", "IsRepeat", "PreferredMetadataLanguage", "PreferredMetadataCountryCode", "DateLastRefreshed", "DateLastSaved", "IsInMixedFolder", "Studios", "ExternalServiceId", "Tags", "IsFolder", "InheritedParentalRatingValue", "InheritedParentalRatingSubValue", "UnratedType", "CriticRating", "CleanName", "PresentationUniqueKey", "OriginalTitle", "PrimaryVersionId", "DateLastMediaAdded", "Album", "LUFS", "NormalizationGain", "IsVirtualItem", "SeriesName", "SeasonName", "ExternalSeriesId", "Tagline", "ProductionLocations", "ExtraIds", "TotalBitrate", "ExtraType", "Artists", "AlbumArtists", "ExternalId", "SeriesPresentationUniqueKey", "ShowId", "OwnerId", "Width", "Height", "Size", "Audio", "ParentId", "TopParentId", "SeasonId", "SeriesId"
**C# Expected (73 snake_case columns):**
id, type, data, path, start_date, end_date, channel_id, is_movie, community_rating, custom_rating, index_number, is_locked, name, official_rating, media_type, overview, parent_index_number, premiere_date, production_year, genres, sort_name, forced_sort_name, run_time_ticks, date_created, date_modified, is_series, episode_title, is_repeat, preferred_metadata_language, preferred_metadata_country_code, date_last_refreshed, date_last_saved, is_in_mixed_folder, studios, external_service_id, tags, is_folder, inherited_parental_rating_value, inherited_parental_rating_sub_value, unrated_type, critic_rating, clean_name, presentation_unique_key, original_title, primary_version_id, date_last_media_added, album, lufs, normalization_gain, is_virtual_item, series_name, season_name, external_series_id, tagline, production_locations, extra_ids, total_bitrate, extra_type, artists, album_artists, external_id, series_presentation_unique_key, show_id, owner_id, width, height, size, audio, parent_id, top_parent_id, season_id, series_id
---
### USERS Schema (4 tables)
| Entity | SQL Table Name | C# Expected Name | Columns Match? | Status |
|--------|----------------|------------------|-----------------|--------|
| AccessSchedule | `"AccessSchedules"` | `access_schedules` | NO (5 cols) | ❌ |
| Permission | `"Permissions"` | `permissions` | NO (6 cols) | ❌ |
| Preference | `"Preferences"` | `preferences` | NO (6 cols) | ❌ |
| User | `"Users"` | `users` | NO (31 cols) | ❌ |
#### AccessSchedule
**SQL Columns:** "Id", "UserId", "DayOfWeek", "StartHour", "EndHour"
**C# Expected:** id, user_id, day_of_week, start_hour, end_hour
#### Permission
**SQL Columns:** "Id", "UserId", "Kind", "Value", "RowVersion", "Permission_Permissions_Guid"
**C# Expected:** id, user_id, kind, value, row_version, permission_permissions_guid
#### Preference
**SQL Columns:** "Id", "UserId", "Kind", "Value", "RowVersion", "Preference_Preferences_Guid"
**C# Expected:** id, user_id, kind, value, row_version, preference_preferences_guid
#### User (31 columns)
**SQL Columns:**
"Id", "Username", "Password", "MustUpdatePassword", "AudioLanguagePreference", "AuthenticationProviderId", "PasswordResetProviderId", "InvalidLoginAttemptCount", "LastActivityDate", "LastLoginDate", "LoginAttemptsBeforeLockout", "MaxActiveSessions", "SubtitleMode", "PlayDefaultAudioTrack", "SubtitleLanguagePreference", "DisplayMissingEpisodes", "DisplayCollectionsView", "EnableLocalPassword", "HidePlayedInLatest", "RememberAudioSelections", "RememberSubtitleSelections", "EnableNextEpisodeAutoPlay", "EnableAutoLogin", "EnableUserPreferenceAccess", "MaxParentalRatingScore", "MaxParentalRatingSubScore", "RemoteClientBitrateLimit", "InternalId", "SyncPlayAccess", "CastReceiverId", "RowVersion"
**C# Expected (31 snake_case columns):**
id, username, password, must_update_password, audio_language_preference, authentication_provider_id, password_reset_provider_id, invalid_login_attempt_count, last_activity_date, last_login_date, login_attempts_before_lockout, max_active_sessions, subtitle_mode, play_default_audio_track, subtitle_language_preference, display_missing_episodes, display_collections_view, enable_local_password, hide_played_in_latest, remember_audio_selections, remember_subtitle_selections, enable_next_episode_auto_play, enable_auto_login, enable_user_preference_access, max_parental_rating_score, max_parental_rating_sub_score, remote_client_bitrate_limit, internal_id, sync_play_access, cast_receiver_id, row_version
---
## Statistics
- **Total Tables:** 31
- **Mismatched Table Names:** 31 (100%)
- **Total Columns Affected:** 200+ columns
- **Severity Level:** CRITICAL - Application will not run
### Breakdown by Schema
| Schema | Total Tables | Mismatched | Correct | Mismatch % |
|--------|--------------|-----------|---------|-----------|
| activitylog | 1 | 1 | 0 | 100% |
| authentication | 3 | 3 | 0 | 100% |
| displaypreferences | 4 | 4 | 0 | 100% |
| library | 15 | 15 | 0 | 100% |
| users | 4 | 4 | 0 | 100% |
| **TOTAL** | **31** | **31** | **0** | **100%** |
---
## Column Naming Convention Samples
The SnakeCaseNamingConvention applies these transformations:
### Examples from BaseItems Table
| Original (PascalCase) | Expected (snake_case) | SQL Has | Match? |
|----------------------|----------------------|---------|--------|
| Id | id | "Id" | ❌ |
| IsMovie | is_movie | "IsMovie" | ❌ |
| CommunityRating | community_rating | "CommunityRating" | ❌ |
| DateCreated | date_created | "DateCreated" | ❌ |
| IsVirtualItem | is_virtual_item | "IsVirtualItem" | ❌ |
| PresentationUniqueKey | presentation_unique_key | "PresentationUniqueKey" | ❌ |
| SeriesPresentationUniqueKey | series_presentation_unique_key | "SeriesPresentationUniqueKey" | ❌ |
| PreferredMetadataLanguage | preferred_metadata_language | "PreferredMetadataLanguage" | ❌ |
| TopParentId | top_parent_id | "TopParentId" | ❌ |
**Conversion Rule (from SnakeCaseNamingConvention.cs):**
```
DateLastMediaAdded → date_last_media_added
PreferredMetadataCountryCode → preferred_metadata_country_code
InheritedParentalRatingSubValue → inherited_parental_rating_sub_value
```
---
## Impact Timeline
### On Application Startup
1. ✓ Database connection succeeds
2. ✓ Migration history table found (`__EFMigrationsHistory`)
3. ✓ Migrations complete
4. ❌ First query to ActivityLog fails: `relation "activity_logs" does not exist`
5. ❌ Application crashes
### Error Messages You Will See
```
Npgsql.PostgresException: 42P01: relation "activity_logs" does not exist
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong()
at [Your Entity Query Code]
```
### Affected Operations
- ✓ Schema creation works (uses explicit table names in SQL)
- ❌ Any LINQ query fails
- ❌ Any repository method fails
- ❌ Any DbContext.Set<T>.ToList() fails
- ❌ Bulk operations fail
- ❌ Migrations that query tables fail
---
## Code Configuration References
### PostgresDatabaseProvider.cs (Lines 763-823)
Table mapping that creates the mismatch:
```csharp
public void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ActivityLog>().ToTable("activity_logs", Schemas.ActivityLog);
modelBuilder.Entity<ApiKey>().ToTable("api_keys", Schemas.Authentication);
// ... 31 mappings total ...
}
```
### SnakeCaseNamingConvention.cs (Lines 25-30)
Column name conversion that creates column mismatches:
```csharp
public void ProcessPropertyAdded(
IConventionPropertyBuilder propertyBuilder,
IConventionContext<IConventionPropertyBuilder> context)
{
propertyBuilder.HasColumnName(ToSnakeCase(propertyBuilder.Metadata.Name));
}
```
---
**Generated:** 2025-05-01
**Severity:** 🔴 CRITICAL - BLOCKING
**Action Required:** Immediate resolution needed before application can run
-335
View File
@@ -1,335 +0,0 @@
# Visual Schema Mismatch Comparison
## Side-by-Side Comparison Examples
### Example 1: ActivityLog Entity
```
┌─────────────────────────────────────────────────────────────────────────┐
│ ACTIVITYLOG Schema Mismatch │
├────────────────────────────────┬────────────────────────────────────────┤
│ SQL Schema File │ C# Code Configuration │
├────────────────────────────────┼────────────────────────────────────────┤
│ CREATE TABLE │ modelBuilder │
│ activitylog."ActivityLogs" │ .Entity<ActivityLog>() │
│ ( │ .ToTable("activity_logs", │
│ "Id" integer, │ Schemas.ActivityLog); │
│ "Name" varchar(512), │ │
│ "Overview" varchar(512), │ // Column Mapping (via │
│ "ShortOverview" varchar, │ // SnakeCaseNamingConvention) │
│ "Type" varchar(256), │ │
│ "UserId" uuid, │ public class ActivityLog │
│ "ItemId" varchar(256), │ { │
│ "DateCreated" timestamp, │ public int Id { get; set; } │
│ "LogSeverity" integer, │ public string Name { get; set; } │
│ "RowVersion" bigint │ public string Overview { get; set; } │
│ ); │ public string ShortOverview { get; } │
│ │ public string Type { get; set; } │
│ │ public Guid UserId { get; set; } │
│ │ public string ItemId { get; set; } │
│ │ public DateTime DateCreated { get; } │
│ │ public int LogSeverity { get; set; } │
│ │ public long RowVersion { get; set; } │
│ │ } │
└────────────────────────────────┴────────────────────────────────────────┘
QUERY EXECUTION FLOW:
┌─────────────────────────────────────────────────────────────────┐
│ C# Code: await context.ActivityLogs.ToListAsync(); │
├─────────────────────────────────────────────────────────────────┤
│ EF Core Translates To: │
│ SELECT * FROM activitylog.activity_logs │
│ (Lower case! ─────────────────) │
├─────────────────────────────────────────────────────────────────┤
│ PostgreSQL Searches For: activitylog.activity_logs │
│ PostgreSQL Finds: activitylog."ActivityLogs" │
│ (Quoted PascalCase!) │
├─────────────────────────────────────────────────────────────────┤
│ RESULT: ERROR: relation "activity_logs" does not exist │
│ ERROR CODE: 42P01 (UNDEFINED TABLE) │
└─────────────────────────────────────────────────────────────────┘
```
---
### Example 2: BaseItem Entity (Most Complex)
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ LIBRARY.BaseItems Schema Mismatch (73 Columns!) │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ SQL Schema Creates: │
│ CREATE TABLE library."BaseItems" ( │
│ "Id" uuid PRIMARY KEY, │
│ "Type" text NOT NULL, │
│ "IsMovie" boolean NOT NULL, │
│ "DateCreated" timestamp with time zone NOT NULL, │
│ "DateModified" timestamp with time zone NOT NULL, │
│ ... 68 more columns all in QUOTED PASCALCASE ... │
│ ); │
│ │
│ C# Code Expects: │
│ SELECT │
│ id, │
│ type, │
│ is_movie, ← Converted from IsMovie │
│ date_created, ← Converted from DateCreated │
│ date_modified, ← Converted from DateModified │
│ ... 68 more columns in snake_case ... │
│ FROM library.base_items; ← Also lowercase table! │
│ │
│ PostgreSQL Receives Query: │
│ SELECT id, type, is_movie, date_created, ... FROM library.base_items │
│ ↓ │
│ Searches for columns: id, type, is_movie, date_created │
│ (all lowercase) │
│ ↓ │
│ But table only has: "Id", "Type", "IsMovie", "DateCreated" │
│ (all QUOTED PASCALCASE) │
│ ↓ │
│ ERROR: column "id" does not exist │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
```
---
## Mismatch Pattern Visualization
```
SCHEMA LAYER:
┌─────────────────────────────────────────────────────────────────────────┐
│ PostgreSQL Database │
│ │
│ Schema: library │
│ ├── Table: "BaseItems" (quoted identifier - preserves case) │
│ │ ├── Column: "Id" │
│ │ ├── Column: "Type" │
│ │ ├── Column: "IsMovie" │
│ │ ├── Column: "DateCreated" │
│ │ └── ... 70 more columns in QUOTED PASCALCASE │
│ │ │
│ ├── Table: "ActivityLogs" (PascalCase) │
│ ├── Table: "ApiKeys" (PascalCase) │
│ ├── Table: "Devices" (PascalCase) │
│ └── ... 28 more tables in QUOTED PASCALCASE │
└─────────────────────────────────────────────────────────────────────────┘
↕ MISMATCH!
APPLICATION LAYER:
┌─────────────────────────────────────────────────────────────────────────┐
│ C# / EF Core │
│ │
│ SnakeCaseNamingConvention ENABLED │
│ ├── Entity: BaseItemEntity │
│ │ ├── Property: Id → Column: id (lowercase) │
│ │ ├── Property: Type → Column: type (lowercase) │
│ │ ├── Property: IsMovie → Column: is_movie (snake_case) │
│ │ ├── Property: DateCreated → Column: date_created (snake_case) │
│ │ └── ... 70 more properties → SNAKE_CASE COLUMNS │
│ │ │
│ ├── Entity: ActivityLog → Table: activity_logs (lowercase) │
│ ├── Entity: ApiKey → Table: api_keys (snake_case) │
│ ├── Entity: Device → Table: devices (lowercase) │
│ └── ... 28 more entities → SNAKE_CASE TABLE NAMES │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## PostgreSQL Identifier Resolution
```
How PostgreSQL resolves identifiers:
WITHOUT quotes: my_table
├─ Converted to: my_table (lowercase by default)
├─ Searches for: my_table
└─ Result: ✓ Found
WITH quotes: "MyTable"
├─ NOT converted
├─ Searches for: MyTable (case-sensitive!)
└─ Result: ✓ Found (only if exact case matches)
CURRENT SITUATION:
┌────────────────────────────────────────────────────────────┐
│ SQL Definition: library."BaseItems" │
│ ↑ │
│ EF Core Query: SELECT * FROM library.base_items │
│ ↑ │
│ PostgreSQL Logic: │
│ - Sees: "base_items" (unquoted, lowercase by default) │
│ - Searches for: base_items │
│ - Available table: "BaseItems" (quoted PascalCase) │
│ - Match? NO ❌ │
│ │
│ ERROR 42P01: relation "base_items" does not exist │
└────────────────────────────────────────────────────────────┘
```
---
## Resolution Decision Tree
```
┌─── DECISION: How to Fix the Mismatch? ───────┐
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ Option 1: Update SQL to Lowercase │ │
│ ├─────────────────────────────────────────┤ │
│ │ Action: │ │
│ │ ✓ Regenerate create_database_schema.sql│ │
│ │ ✓ Convert all "TableName" → table_name │ │
│ │ ✓ Convert all "ColumnName" → column_name│ │
│ │ ✓ Remove double quotes │ │
│ │ │ │
│ │ Pros: │ │
│ │ ✓ Matches PostgreSQL best practices │ │
│ │ ✓ Matches C# configuration (recommended)│ │
│ │ ✓ Cleaner, more maintainable │ │
│ │ │ │
│ │ Cons: │ │
│ │ ✗ Requires full database migration │ │
│ │ ✗ Data loss risk if not done carefully│ │
│ │ ✗ Downtime required │ │
│ │ │ │
│ │ Effort: HIGH │ │
│ │ Risk: HIGH │ │
│ │ Recommendation: ⭐ BEST LONG-TERM │ │
│ └─────────────────────────────────────────┘ │
│ ↓ (Recommended) │
│ ┌─────────────────────────────────────────┐ │
│ │ Option 2: Update C# to PascalCase │ │
│ ├─────────────────────────────────────────┤ │
│ │ Action: │ │
│ │ ✓ Remove SnakeCaseNamingConvention │ │
│ │ ✓ Update all ToTable() mappings │ │
│ │ ✓ Update all HasColumnName() configs │ │
│ │ │ │
│ │ Pros: │ │
│ │ ✓ Quick fix (code changes only) │ │
│ │ ✓ No database changes needed │ │
│ │ ✓ No data loss │ │
│ │ │ │
│ │ Cons: │ │
│ │ ✗ Violates PostgreSQL conventions │ │
│ │ ✗ Harder to maintain │ │
│ │ ✗ Non-standard naming scheme │ │
│ │ │ │
│ │ Effort: MEDIUM │ │
│ │ Risk: LOW │ │
│ │ Recommendation: ⚠️ QUICK FIX │ │
│ └─────────────────────────────────────────┘ │
│ ↓ (Not Recommended) │
│ ┌─────────────────────────────────────────┐ │
│ │ Option 3: Custom Naming Convention │ │
│ ├─────────────────────────────────────────┤ │
│ │ Action: │ │
│ │ ✓ Create hybrid convention │ │
│ │ ✓ Recognize existing table patterns │ │
│ │ │ │
│ │ Pros: │ │
│ │ ✓ Works with existing database │ │
│ │ ✓ Minimal code changes │ │
│ │ │ │
│ │ Cons: │ │
│ │ ✗ Very complex to implement │ │
│ │ ✗ Difficult to maintain │ │
│ │ ✗ Fragile (pattern-dependent) │ │
│ │ ✗ Non-standard solution │ │
│ │ │ │
│ │ Effort: VERY HIGH │ │
│ │ Risk: VERY HIGH │ │
│ │ Recommendation: ❌ NOT RECOMMENDED │ │
│ └─────────────────────────────────────────┘ │
└───────────────────────────────────────────────┘
```
---
## Column Naming Rule Examples
```
SnakeCaseNamingConvention applies these rules:
(From SnakeCaseNamingConvention.cs - Lines 35-40)
Input (C# Property) → Output (SQL Column) → SQL Actually Has
──────────────────────────────────────────────────────────────────────────
Id → id → "Id" ❌
UserId → user_id → "UserId" ❌
DateCreated → date_created → "DateCreated" ❌
IsMovie → is_movie → "IsMovie" ❌
DvVersionMajor → dv_version_major → "DvVersionMajor"❌
SeriesPresentationUniqueKey → series_presentation_unique_key → "SeriesPresentationUniqueKey" ❌
The Regex Rules Applied:
┌─────────────────────────────────────────────────────────────────┐
│ Rule 1: ([A-Z]+)([A-Z][a-z]) → "$1_$2" │
│ Example: "HTTPServer" → "HTTP_Server" │
│ │
│ Rule 2: ([a-z\d])([A-Z]) → "$1_$2" │
│ Example: "DateCreated" → "Date_Created" │
│ │
│ Rule 3: Convert to lowercase (.ToLowerInvariant()) │
│ Example: "Date_Created" → "date_created" │
└─────────────────────────────────────────────────────────────────┘
```
---
## Query Execution Failure Timeline
```
STARTUP SEQUENCE:
1. ✓ 0ms - Connection opened
2. ✓ 50ms - Migration history table checked (__EFMigrationsHistory exists)
3. ✓ 150ms - Migrations applied
4. ✓ 500ms - Application configuration loaded
5. ✓ 1000ms - First request received
6. ❌ 1050ms - Query executes: SELECT * FROM activitylog.activity_logs
ERROR: 42P01 - relation "activity_logs" does not exist
7. 💥 1051ms - Application crashes
STACK TRACE:
at Npgsql.NpgsqlDataReader.NextResult()
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReader()
at Microsoft.EntityFrameworkCore.Query.QueryingEnumerable`1.Enumerator.MoveNext()
at System.Linq.Enumerable.ToList() // User called .ToList() or .ToListAsync()
at YourRepositoryClass.GetActivityLogs() in YourRepository.cs:line X
```
---
## File Locations Summary
```
AFFECTED FILES:
SQL Schema:
└─ Jellyfin.Server/sql/schema_init/create_database_schema.sql
Lines: 92-886 (31 tables with PascalCase names)
C# Configuration:
├─ src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/
│ └─ PostgresDatabaseProvider.cs
│ Lines 763-823: OnModelCreating() - Table mappings
│ Lines 872-875: ConfigureConventions() - SnakeCaseNamingConvention
├─ src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/
│ └─ SnakeCaseNamingConvention.cs
│ Lines 16-30: Column name conversion logic
└─ src/Jellyfin.Database/Jellyfin.Database.Implementations/
└─ ModelConfiguration/
├─ ActivityLogConfiguration.cs
├─ ApiKeyConfiguration.cs
├─ DeviceConfiguration.cs
└─ ... (31 configuration files total)
```
---
**Visual Reference Generated:** 2025-05-01
**Status:** Ready for review and decision
-493
View File
@@ -1,493 +0,0 @@
# PostgreSQL Database Schema Mismatch Report
**Generated:** 2025-05-01
**Status:** ⚠️ CRITICAL MISMATCH FOUND
**Severity:** HIGH - Code cannot query database tables correctly
---
## Executive Summary
There is a **critical mismatch** between:
1. **SQL Schema File** (`create_database_schema.sql`) - Uses **PascalCase** table and column names with double quotes
2. **C# Code Configuration** (`PostgresDatabaseProvider.cs`) - Expects **snake_case** table names with SnakeCaseNamingConvention for columns
This will cause **runtime failures** when EF Core tries to query tables that don't exist at the expected snake_case names.
---
## Detailed Mismatch Analysis
### ACTIVITYLOG Schema
#### SQL Definition:
```sql
CREATE TABLE activitylog."ActivityLogs" (
"Id" integer NOT NULL,
"Name" character varying(512) NOT NULL,
"Overview" character varying(512),
"ShortOverview" character varying(512),
"Type" character varying(256) NOT NULL,
"UserId" uuid NOT NULL,
"ItemId" character varying(256),
"DateCreated" timestamp with time zone NOT NULL,
"LogSeverity" integer NOT NULL,
"RowVersion" bigint NOT NULL
);
```
#### C# Code Mapping:
```csharp
modelBuilder.Entity<ActivityLog>().ToTable("activity_logs", Schemas.ActivityLog);
```
#### Mismatch Details:
| Aspect | SQL Schema | C# Code | Status |
|--------|-----------|---------|--------|
| Table Name | `"ActivityLogs"` | `activity_logs` | ❌ MISMATCH |
| Column: Id | `"Id"` | `id` (via convention) | ❌ MISMATCH |
| Column: Name | `"Name"` | `name` (via convention) | ❌ MISMATCH |
| Column: Overview | `"Overview"` | `overview` (via convention) | ❌ MISMATCH |
| Column: ShortOverview | `"ShortOverview"` | `short_overview` (via convention) | ❌ MISMATCH |
| Column: Type | `"Type"` | `type` (via convention) | ❌ MISMATCH |
| Column: UserId | `"UserId"` | `user_id` (via convention) | ❌ MISMATCH |
| Column: ItemId | `"ItemId"` | `item_id` (via convention) | ❌ MISMATCH |
| Column: DateCreated | `"DateCreated"` | `date_created` (via convention) | ❌ MISMATCH |
| Column: LogSeverity | `"LogSeverity"` | `log_severity` (via convention) | ❌ MISMATCH |
| Column: RowVersion | `"RowVersion"` | `row_version` (via convention) | ❌ MISMATCH |
**Root Cause:** Table name is PascalCase; columns are PascalCase but SnakeCaseNamingConvention converts them to snake_case.
---
### AUTHENTICATION Schema
#### APIKEYS Table
**SQL Definition:**
```sql
CREATE TABLE authentication."ApiKeys" (
"Id" integer NOT NULL,
"DateCreated" timestamp with time zone NOT NULL,
"DateLastActivity" timestamp with time zone NOT NULL,
"Name" character varying(64) NOT NULL,
"AccessToken" text NOT NULL
);
```
**C# Mapping:**
```csharp
modelBuilder.Entity<ApiKey>().ToTable("api_keys", Schemas.Authentication);
```
**Mismatch:** Table name `"ApiKeys"` vs `api_keys`
---
#### DEVICEOPTIONS Table
**SQL Definition:**
```sql
CREATE TABLE authentication."DeviceOptions" (
"Id" integer NOT NULL,
"DeviceId" text NOT NULL,
"CustomName" text
);
```
**C# Mapping:**
```csharp
modelBuilder.Entity<DeviceOptions>().ToTable("device_options", Schemas.Authentication);
```
**Mismatch:** Table name `"DeviceOptions"` vs `device_options`
---
#### DEVICES Table
**SQL Definition:**
```sql
CREATE TABLE authentication."Devices" (
"Id" integer NOT NULL,
"UserId" uuid NOT NULL,
"AccessToken" text NOT NULL,
"AppName" character varying(64) NOT NULL,
"AppVersion" character varying(32) NOT NULL,
"DeviceName" character varying(64) NOT NULL,
"DeviceId" character varying(256) NOT NULL,
"IsActive" boolean NOT NULL,
"DateCreated" timestamp with time zone NOT NULL,
"DateModified" timestamp with time zone NOT NULL,
"DateLastActivity" timestamp with time zone NOT NULL
);
```
**C# Mapping:**
```csharp
modelBuilder.Entity<Device>().ToTable("devices", Schemas.Authentication);
```
**Mismatch:** Table name `"Devices"` vs `devices`
---
### DISPLAYPREFERENCES Schema
#### CUSTOMITEMDISPLAYPREFERENCES Table
**SQL:** `"CustomItemDisplayPreferences"`
**C# Code:** `custom_item_display_preferences`
**Mismatch:** ❌
#### DISPLAYPREFERENCES Table
**SQL:** `"DisplayPreferences"`
**C# Code:** `display_preferences`
**Mismatch:** ❌
#### HOMESECTION Table
**SQL:** `"HomeSection"`
**C# Code:** `home_sections`
**Mismatch:** ❌ (Plural mismatch too!)
#### ITEMDISPLAYPREFERENCES Table
**SQL:** `"ItemDisplayPreferences"`
**C# Code:** `item_display_preferences`
**Mismatch:** ❌
---
### LIBRARY Schema (Most Critical - 15+ Tables)
#### ANCESTORIDS Table
**SQL:** `"AncestorIds"`
**C# Code:** `ancestor_ids`
**Mismatch:** ❌
#### ATTACHMENTSTREAMINFOS Table
**SQL:** `"AttachmentStreamInfos"`
**C# Code:** `attachment_stream_infos`
**Mismatch:** ❌
#### BASEITEMIMAGINFOS Table
**SQL:** `"BaseItemImageInfos"`
**C# Code:** `base_item_image_infos`
**Mismatch:** ❌
#### BASEITEMMETADATAFIELDS Table
**SQL:** `"BaseItemMetadataFields"`
**C# Code:** `base_item_metadata_fields`
**Mismatch:** ❌
#### BASEITEMPROVIDERS Table
**SQL:** `"BaseItemProviders"`
**C# Code:** `base_item_providers`
**Mismatch:** ❌
#### BASEITEMTRAILERTYPES Table
**SQL:** `"BaseItemTrailerTypes"`
**C# Code:** `base_item_trailer_types`
**Mismatch:** ❌
#### BASEITEMS Table
**SQL:** `"BaseItems"`
**C# Code:** `base_items`
**Mismatch:** ❌
#### CHAPTERS Table
**SQL:** `"Chapters"`
**C# Code:** `chapters`
**Mismatch:** ❌
#### IMAGEINFOS Table
**SQL:** `"ImageInfos"`
**C# Code:** `image_infos`
**Mismatch:** ❌
#### ITEMVALUES Table
**SQL:** `"ItemValues"`
**C# Code:** `item_values`
**Mismatch:** ❌
#### ITEMVALUESMAP Table
**SQL:** `"ItemValuesMap"`
**C# Code:** `item_values_map`
**Mismatch:** ❌
#### KEYFRAMEDATA Table
**SQL:** `"KeyframeData"`
**C# Code:** `keyframe_data`
**Mismatch:** ❌
#### MEDIASEGMENTS Table
**SQL:** `"MediaSegments"`
**C# Code:** `media_segments`
**Mismatch:** ❌
#### MEDIASTREAMINFOS Table
**SQL:** `"MediaStreamInfos"`
**C# Code:** `media_stream_infos`
**Mismatch:** ❌
#### PEOPLEBASEITEMMAP Table
**SQL:** `"PeopleBaseItemMap"`
**C# Code:** `people_base_item_map`
**Mismatch:** ❌
#### PEOPLES Table
**SQL:** `"Peoples"`
**C# Code:** `peoples`
**Mismatch:** ❌
#### TRICKPLAYINFOS Table
**SQL:** `"TrickplayInfos"`
**C# Code:** `trickplay_infos`
**Mismatch:** ❌
#### USERDATA Table
**SQL:** `"UserData"`
**C# Code:** `user_data`
**Mismatch:** ❌
---
### USERS Schema
#### ACCESSSCHEDULES Table
**SQL:** `"AccessSchedules"`
**C# Code:** `access_schedules`
**Mismatch:** ❌
#### PERMISSIONS Table
**SQL:** `"Permissions"`
**C# Code:** `permissions`
**Mismatch:** ❌
#### PREFERENCES Table
**SQL:** `"Preferences"`
**C# Code:** `preferences`
**Mismatch:** ❌
#### USERS Table
**SQL:** `"Users"`
**C# Code:** `users`
**Mismatch:** ❌
---
### SPECIAL TABLES
#### __EFMigrationsHistory Table
**SQL Definition (Line 738):**
```sql
CREATE TABLE public."__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL
);
```
**Status:** ✓ Correct (EF Core will query `public.__EFMigrationsHistory`)
**Note:** Columns are PascalCase in SQL but EF Core's built-in history repository handles this correctly.
---
## Complete Mismatch Summary Table
| Schema | Entity | SQL Table Name | C# Expected Name | Match? |
|--------|--------|----------------|------------------|--------|
| activitylog | ActivityLog | `"ActivityLogs"` | `activity_logs` | ❌ |
| authentication | ApiKey | `"ApiKeys"` | `api_keys` | ❌ |
| authentication | Device | `"Devices"` | `devices` | ❌ |
| authentication | DeviceOptions | `"DeviceOptions"` | `device_options` | ❌ |
| displaypreferences | CustomItemDisplayPreferences | `"CustomItemDisplayPreferences"` | `custom_item_display_preferences` | ❌ |
| displaypreferences | DisplayPreferences | `"DisplayPreferences"` | `display_preferences` | ❌ |
| displaypreferences | HomeSection | `"HomeSection"` | `home_sections` | ❌ |
| displaypreferences | ItemDisplayPreferences | `"ItemDisplayPreferences"` | `item_display_preferences` | ❌ |
| library | AncestorId | `"AncestorIds"` | `ancestor_ids` | ❌ |
| library | AttachmentStreamInfo | `"AttachmentStreamInfos"` | `attachment_stream_infos` | ❌ |
| library | BaseItemImageInfo | `"BaseItemImageInfos"` | `base_item_image_infos` | ❌ |
| library | BaseItemMetadataField | `"BaseItemMetadataFields"` | `base_item_metadata_fields` | ❌ |
| library | BaseItemProvider | `"BaseItemProviders"` | `base_item_providers` | ❌ |
| library | BaseItemTrailerType | `"BaseItemTrailerTypes"` | `base_item_trailer_types` | ❌ |
| library | BaseItemEntity | `"BaseItems"` | `base_items` | ❌ |
| library | Chapter | `"Chapters"` | `chapters` | ✓ |
| library | ImageInfo | `"ImageInfos"` | `image_infos` | ❌ |
| library | ItemValue | `"ItemValues"` | `item_values` | ❌ |
| library | ItemValueMap | `"ItemValuesMap"` | `item_values_map` | ❌ |
| library | KeyframeData | `"KeyframeData"` | `keyframe_data` | ❌ |
| library | MediaSegment | `"MediaSegments"` | `media_segments` | ❌ |
| library | MediaStreamInfo | `"MediaStreamInfos"` | `media_stream_infos` | ❌ |
| library | PeopleBaseItemMap | `"PeopleBaseItemMap"` | `people_base_item_map` | ❌ |
| library | People | `"Peoples"` | `peoples` | ✓ |
| library | TrickplayInfo | `"TrickplayInfos"` | `trickplay_infos` | ❌ |
| library | UserData | `"UserData"` | `user_data` | ❌ |
| users | AccessSchedule | `"AccessSchedules"` | `access_schedules` | ❌ |
| users | Permission | `"Permissions"` | `permissions` | ✓ |
| users | Preference | `"Preferences"` | `preferences` | ✓ |
| users | User | `"Users"` | `users` | ✓ |
| public | __EFMigrationsHistory | `"__EFMigrationsHistory"` | `__EFMigrationsHistory` | ✓ |
**Summary:**
- ❌ **31 table mismatches found**
- ✓ **5 tables with correct lowercase names**
- **96% tables have naming conflicts**
---
## Impact Analysis
### When EF Core Runs
1. **Query Execution:** EF Core generates queries using snake_case table names
```sql
SELECT * FROM library.base_items -- EF Core generates this
```
2. **Database Response:** PostgreSQL looks for table named `base_items` in lowercase
3. **Result:** Table not found because actual table is `"BaseItems"` (quoted PascalCase)
```
ERROR: relation "base_items" does not exist
```
### Error Pattern You Will See
```
Npgsql.PostgresException (0x80004005): 42P01: relation "activity_logs" does not exist
Npgsql.PostgresException (0x80004005): 42P01: relation "api_keys" does not exist
Npgsql.PostgresException (0x80004005): 42P01: relation "base_items" does not exist
```
---
## Root Cause Analysis
### The Problem
The SQL schema file was generated from a `pg_dump` of an existing database that uses **PascalCase with double quotes** for case preservation. However, the C# code was configured to map to **lowercase snake_case** table names.
### Configuration Details
**PostgresDatabaseProvider.cs Line 763-819:**
```csharp
public void OnModelCreating(ModelBuilder modelBuilder)
{
// ... example mappings ...
modelBuilder.Entity<ActivityLog>().ToTable("activity_logs", Schemas.ActivityLog);
modelBuilder.Entity<ApiKey>().ToTable("api_keys", Schemas.Authentication);
modelBuilder.Entity<Device>().ToTable("devices", Schemas.Authentication);
// ... etc ...
}
```
**PostgresDatabaseProvider.cs Line 872-875:**
```csharp
public void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.Conventions.Add(_ => new SnakeCaseNamingConvention());
}
```
**SnakeCaseNamingConvention.cs:**
- Converts all PascalCase property names to snake_case column names
- Example: `DateCreated` property → `date_created` column
---
## Recommended Resolution
### Option 1: Update SQL Schema to Use Lowercase (RECOMMENDED)
- **Pros:** Matches PostgreSQL best practices, cleaner, matches C# configuration
- **Cons:** Requires recreating all tables
- **Effort:** High (full database migration required)
### Option 2: Update C# Code to Match PascalCase SQL
- **Pros:** Quick fix, no database changes needed
- **Cons:** Goes against PostgreSQL conventions, harder to maintain
- **Effort:** Low (code changes only)
### Option 3: Custom Naming Convention
- Create a hybrid convention that recognizes existing PascalCase tables
- **Pros:** Can work with existing database
- **Cons:** Complex, non-standard, harder to maintain
- **Effort:** Very High (custom infrastructure needed)
---
## Files Affected
### C# Code Files
1. `src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/PostgresDatabaseProvider.cs`
- Lines 763-823 (OnModelCreating table mappings)
2. `src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/SnakeCaseNamingConvention.cs`
- Controls column name conversion
3. All Entity Configuration files in:
- `src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/`
### Database Schema Files
1. `Jellyfin.Server/sql/schema_init/create_database_schema.sql`
- Lines 92-886 (all table definitions)
---
## Next Steps
1. **Decision Required:** Which resolution option do you prefer?
- Option 1: Update SQL to lowercase (Recommended)
- Option 2: Update C# to PascalCase
- Option 3: Create custom convention
2. **If Option 1:** Generate a comprehensive SQL migration script
3. **If Option 2:** Generate C# code changes
4. **If Option 3:** Design custom naming convention
---
## Appendix: Column Name Conversion Examples
The SnakeCaseNamingConvention converts column names as follows:
| Original (C#) | Converted (SQL) | Status |
|---------------|-----------------|--------|
| Id | id | ✓ |
| UserId | user_id | ✓ |
| DateCreated | date_created | ✓ |
| LogSeverity | log_severity | ✓ |
| AccessToken | access_token | ✓ |
| APIKey | api_key | ✓ |
| DVVersionMajor | dv_version_major | ✓ |
**Note:** All column names in SQL schema are PascalCase (e.g., `"UserId"`, `"DateCreated"`), but C# code expects them in snake_case (e.g., `user_id`, `date_created`).
---
**Report Generated:** 2025-05-01
**Status:** Awaiting decision on resolution approach
-233
View File
@@ -1,233 +0,0 @@
# PostgreSQL Database Views
All views live in the `library` schema unless otherwise noted. They are read-only and safe to query from pgAdmin, BI tools, or any reporting/debugging context without risk of affecting the application.
---
## `library."MediaStreamInfos_v"`
Reassembles the original flat `MediaStreamInfos` table shape that existed before the `SplitMediaStreamInfos` migration split audio- and video-specific columns into dedicated tables.
**Use when:** you want a single flat result of all stream data without writing the three-way JOIN yourself.
**Columns of note:**
| Column | Source |
|---|---|
| `ItemId`, `StreamIndex`, `StreamType`, `Codec`, … | `MediaStreamInfos` (always present) |
| `ChannelLayout`, `Channels`, `SampleRate`, `IsHearingImpaired` | `AudioStreamDetails``NULL` for non-audio streams |
| `Height`, `Width`, `AverageFrameRate`, `BitDepth`, `ColorTransfer`, `DvProfile`, … | `VideoStreamDetails``NULL` for non-video streams |
**Example:**
```sql
-- All audio streams for a specific item
SELECT "StreamIndex", "Codec", "Language", "Channels", "SampleRate", "ChannelLayout"
FROM library."MediaStreamInfos_v"
WHERE "ItemId" = '<item-uuid>'
AND "StreamType" = 0 -- 0 = Audio
ORDER BY "StreamIndex";
```
---
## `library."VideoItems_v"`
Every non-folder, non-virtual item that has at least one video stream (movies, episodes, home videos, etc.), joined with the primary video stream's technical details.
**Use when:** auditing codec/resolution/HDR distribution across the library, or finding items that need re-encoding.
**Columns of note:**
| Column | Description |
|---|---|
| `Type` | Fully-qualified Jellyfin type name (e.g. `MediaBrowser.Controller.Entities.Movies.Movie`) |
| `VideoCodec` | Codec of the primary video stream (e.g. `hevc`, `h264`) |
| `Width` / `Height` | Resolution of the primary video stream |
| `BitDepth` | Colour bit depth (8, 10, 12) |
| `HDRFormat` | Derived string: `Dolby Vision`, `HDR10+`, `HDR10`, `HLG`, or `SDR` |
| `IsDolbyVision` | Boolean shortcut for `DvProfile IS NOT NULL` |
| `AudioTrackCount` | Number of audio streams on the item |
| `SubtitleTrackCount` | Number of subtitle streams on the item |
**Example queries:**
```sql
-- All 4K HDR movies
SELECT "Name", "ProductionYear", "VideoCodec", "Width", "Height", "HDRFormat", "TotalBitrate"
FROM library."VideoItems_v"
WHERE "Type" LIKE '%Movie%'
AND "Height" >= 2160
AND "HDRFormat" <> 'SDR'
ORDER BY "Name";
-- Episodes still encoded in H.264 (candidates for HEVC re-encode)
SELECT "SeriesName", "SeasonName", "EpisodeNumber", "Name", "Width", "Height"
FROM library."VideoItems_v"
WHERE "Type" LIKE '%Episode%'
AND "VideoCodec" = 'h264'
ORDER BY "SeriesName", "SeasonNumber", "EpisodeNumber";
```
---
## `library."UserPlaybackHistory_v"`
Per-user watch history joined with item metadata and a calculated playback progress percentage.
**Use when:** reporting on viewing activity, finding in-progress items, or checking which users have watched what.
**Columns of note:**
| Column | Description |
|---|---|
| `Username` | Jellyfin username |
| `ItemName` | Name of the item |
| `ItemType` | Jellyfin type name |
| `Played` | `true` if the item has been marked as played |
| `PlayCount` | Number of times fully played |
| `LastPlayedDate` | Timestamp of the most recent playback |
| `PlaybackPositionTicks` | Raw position (divide by `10,000,000` for seconds) |
| `ProgressPct` | Calculated percentage through the item (0100), `NULL` if no runtime |
| `AudioStreamIndex` | Last-used audio track index |
| `SubtitleStreamIndex` | Last-used subtitle track index |
**Example queries:**
```sql
-- All in-progress items for a user (started but not finished)
SELECT "ItemName", "ItemType", "SeriesName", "ProgressPct", "LastPlayedDate"
FROM library."UserPlaybackHistory_v"
WHERE "Username" = 'alice'
AND "Played" = false
AND "ProgressPct" > 0
ORDER BY "LastPlayedDate" DESC;
-- Top 10 most-played items across all users
SELECT "ItemName", "ItemType", SUM("PlayCount") AS "TotalPlays"
FROM library."UserPlaybackHistory_v"
GROUP BY "ItemName", "ItemType"
ORDER BY "TotalPlays" DESC
LIMIT 10;
```
---
## `library."ItemProviders_v"`
Items with their external provider IDs (IMDb, TMDB, TVDB, etc.) pivoted into named columns. Providers not explicitly named appear in a JSONB `OtherProviders` column.
**Use when:** cross-referencing Jellyfin items against external databases, finding items with missing metadata IDs, or bulk-exporting IDs for external tools.
**Columns of note:**
| Column | Description |
|---|---|
| `ImdbId` | IMDb identifier (e.g. `tt0111161`) |
| `TmdbId` | The Movie Database ID |
| `TvdbId` | TheTVDB series/episode ID |
| `TvRageId` | TVRage ID (legacy) |
| `MusicBrainzAlbumId` | MusicBrainz album MBID |
| `MusicBrainzArtistId` | MusicBrainz artist MBID |
| `OtherProviders` | JSONB object of any remaining provider key/value pairs |
**Example queries:**
```sql
-- Movies missing a TMDB ID
SELECT "Name", "ProductionYear", "ImdbId"
FROM library."ItemProviders_v"
WHERE "Type" LIKE '%Movie%'
AND "TmdbId" IS NULL
ORDER BY "Name";
-- Look up an item by IMDb ID
SELECT "ItemId", "Type", "Name", "ProductionYear"
FROM library."ItemProviders_v"
WHERE "ImdbId" = 'tt0111161';
```
---
## `library."ItemPeople_v"`
All cast and crew entries linked to the items they appear in.
**Use when:** browsing which items feature a specific actor or director, building cast-focused reports, or auditing people metadata.
**Columns of note:**
| Column | Description |
|---|---|
| `PersonName` | Name of the person |
| `PersonType` | Role type: `Actor`, `Director`, `Writer`, `Producer`, etc. |
| `Role` | Specific character name or role description (may be empty) |
| `SortOrder` / `ListOrder` | Display ordering within the item's people list |
**Example queries:**
```sql
-- All items featuring a specific actor
SELECT "ItemType", "ItemName", "SeriesName", "ProductionYear", "Role"
FROM library."ItemPeople_v"
WHERE "PersonName" = 'Bryan Cranston'
AND "PersonType" = 'Actor'
ORDER BY "ProductionYear";
-- Directors with the most items in the library
SELECT "PersonName", COUNT(*) AS "DirectedItems"
FROM library."ItemPeople_v"
WHERE "PersonType" = 'Director'
GROUP BY "PersonName"
ORDER BY "DirectedItems" DESC
LIMIT 20;
```
---
## `library."LibrarySummary_v"`
Aggregate statistics broken down by item type — one row per type. Provides a quick dashboard overview of the entire library.
**Use when:** getting a high-level snapshot of library size, total runtime, storage usage, and video quality distribution.
**Columns of note:**
| Column | Description |
|---|---|
| `Type` | Jellyfin item type |
| `ItemCount` | Total number of items of this type |
| `TotalRuntimeHours` | Sum of all runtimes in hours |
| `TotalSizeGB` | Sum of all file sizes in gigabytes |
| `AvgCommunityRating` | Average community rating across items with a rating |
| `Count4K` | Items with primary video height ≥ 2160 |
| `Count1080p` | Items with height 10802159 |
| `Count720p` | Items with height 7201079 |
| `CountSD` | Items with height < 720 |
| `CountDolbyVision` | Items with a Dolby Vision video stream |
| `CountHDR10Plus` | Items with HDR10+ flag set |
| `CountHDR10` | Items with HDR10 (`smpte2084` transfer, no DV or HDR10+) |
**Example:**
```sql
-- Full summary
SELECT "Type", "ItemCount", "TotalRuntimeHours", "TotalSizeGB",
"Count4K", "Count1080p", "Count720p", "CountSD",
"CountDolbyVision", "CountHDR10Plus", "CountHDR10"
FROM library."LibrarySummary_v";
```
---
## StreamType Reference
The `StreamType` integer used in `MediaStreamInfos` and `MediaStreamInfos_v` maps to:
| Value | Type |
|---|---|
| 0 | Audio |
| 1 | Video |
| 2 | Subtitle |
| 3 | EmbeddedImage |
| 4 | Data / Attachment |

Some files were not shown because too many files have changed in this diff Show More