diff --git a/Jellyfin.Api/Middleware/WebSocketAuthenticationMiddleware.cs b/Jellyfin.Api/Middleware/WebSocketAuthenticationMiddleware.cs
index 7796eb64..883e36d0 100644
--- a/Jellyfin.Api/Middleware/WebSocketAuthenticationMiddleware.cs
+++ b/Jellyfin.Api/Middleware/WebSocketAuthenticationMiddleware.cs
@@ -44,7 +44,7 @@ public class WebSocketAuthenticationMiddleware
public async Task Invoke(HttpContext httpContext, IAuthService authService)
{
// Only process WebSocket upgrade requests on the /socket endpoint
- if (httpContext.WebSockets.IsWebSocketRequestUpgrade
+ if (httpContext.WebSockets.IsWebSocketRequest
&& httpContext.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase))
{
await AuthenticateWebSocketRequest(httpContext, authService).ConfigureAwait(false);
@@ -66,7 +66,8 @@ public class WebSocketAuthenticationMiddleware
// 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}",
+ _logger.LogDebug(
+ "WebSocket connection attempted without api_key query parameter. IP: {IP}",
httpContext.Connection.RemoteIpAddress);
return;
}
@@ -74,7 +75,8 @@ public class WebSocketAuthenticationMiddleware
var apiKey = apiKeyValues[0];
if (string.IsNullOrWhiteSpace(apiKey))
{
- _logger.LogDebug("WebSocket connection attempted with empty api_key. IP: {IP}",
+ _logger.LogDebug(
+ "WebSocket connection attempted with empty api_key. IP: {IP}",
httpContext.Connection.RemoteIpAddress);
return;
}
@@ -84,7 +86,8 @@ public class WebSocketAuthenticationMiddleware
if (!authorizationInfo.HasToken)
{
- _logger.LogDebug("WebSocket authentication failed: Invalid or expired token. IP: {IP}",
+ _logger.LogDebug(
+ "WebSocket authentication failed: Invalid or expired token. IP: {IP}",
httpContext.Connection.RemoteIpAddress);
return;
}
@@ -114,23 +117,19 @@ public class WebSocketAuthenticationMiddleware
var principal = new ClaimsPrincipal(identity);
httpContext.User = principal;
- _logger.LogDebug("WebSocket authentication successful for user {Username}. IP: {IP}",
+ _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}",
+ _logger.LogDebug(
+ ex,
+ "Error during WebSocket authentication. IP: {IP}",
httpContext.Connection.RemoteIpAddress);
}
}
}
- ///
- /// Adds WebSocket authentication to the application pipeline.
- ///
- /// The application builder.
- /// The updated application builder.
- public static IApplicationBuilder UseWebSocketAuthentication(this IApplicationBuilder appBuilder)
- {
- return appBuilder.UseMiddleware();
- }
+
+
diff --git a/Jellyfin.Api/Middleware/WebSocketAuthenticationMiddlewareExtensions.cs b/Jellyfin.Api/Middleware/WebSocketAuthenticationMiddlewareExtensions.cs
new file mode 100644
index 00000000..c6f21c8b
--- /dev/null
+++ b/Jellyfin.Api/Middleware/WebSocketAuthenticationMiddlewareExtensions.cs
@@ -0,0 +1,23 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+namespace Jellyfin.Api.Middleware;
+
+using Microsoft.AspNetCore.Builder;
+
+///
+/// Extension methods for adding WebSocket authentication to the pipeline.
+///
+public static class WebSocketAuthenticationMiddlewareExtensions
+{
+ ///
+ /// Adds WebSocket authentication to the application pipeline.
+ ///
+ /// The application builder.
+ /// The updated application builder.
+ public static IApplicationBuilder UseWebSocketAuthentication(this IApplicationBuilder appBuilder)
+ {
+ return appBuilder.UseMiddleware();
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
index ba288bc4..7cbd6794 100644
--- a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
@@ -68,6 +68,8 @@ 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);
@@ -131,46 +133,54 @@ 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;
- 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 (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;
+ }
if (dto.Type is MediaStreamType.Audio or MediaStreamType.Subtitle)
{
@@ -206,47 +216,65 @@ 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;
}
}
diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
index b9dcfda3..3b0fa690 100644
--- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
@@ -121,14 +121,5 @@ namespace Jellyfin.Server.Extensions
{
return appBuilder.UseMiddleware();
}
- ///
- /// Adds WebSocket authentication to the application pipeline.
- ///
- /// The application builder.
- /// The updated application builder.
- public static IApplicationBuilder UseWebSocketAuthentication(this IApplicationBuilder appBuilder)
- {
- return appBuilder.UseMiddleware();
- }
}
}
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
index c40b3b0f..8f476bb2 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
@@ -596,19 +596,16 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
/// MediaStream.
private MediaStreamInfo GetMediaStream(SqliteDataReader reader)
{
+ var streamType = Enum.Parse(reader.GetString(2));
+ var itemId = reader.GetGuid(0);
+ var streamIndex = reader.GetInt32(1);
+
var item = new MediaStreamInfo
{
- StreamIndex = reader.GetInt32(1),
- StreamType = Enum.Parse(reader.GetString(2)),
+ StreamIndex = streamIndex,
+ StreamType = streamType,
Item = null!,
- ItemId = reader.GetGuid(0),
- AspectRatio = null!,
- ChannelLayout = null!,
- Codec = null!,
- IsInterlaced = false,
- Language = null!,
- Path = null!,
- Profile = null!,
+ ItemId = itemId,
};
if (reader.TryGetString(3, out var codec))
@@ -621,92 +618,30 @@ 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;
@@ -717,11 +652,6 @@ 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;
@@ -742,68 +672,151 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
item.CodecTimeBase = codecTimeBase;
}
- if (reader.TryGetString(32, out var colorPrimaries))
+ if (streamType == MediaStreamTypeEntity.Audio)
{
- item.ColorPrimaries = colorPrimaries;
- }
+ var audio = new AudioStreamDetails
+ {
+ ItemId = itemId,
+ StreamIndex = streamIndex,
+ };
- if (reader.TryGetString(33, out var colorSpace))
+ 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;
+ }
+ else if (streamType == MediaStreamTypeEntity.Video)
{
- item.ColorSpace = 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;
}
- 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;
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/AudioStreamDetails.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/AudioStreamDetails.cs
new file mode 100644
index 00000000..11fd6456
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/AudioStreamDetails.cs
@@ -0,0 +1,27 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+namespace Jellyfin.Database.Implementations.Entities;
+
+#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
+#pragma warning disable SA1600
+
+using System;
+
+public class AudioStreamDetails
+{
+ public required Guid ItemId { get; set; }
+
+ public required int StreamIndex { get; set; }
+
+ public string? ChannelLayout { get; set; }
+
+ public int? Channels { get; set; }
+
+ public int? SampleRate { get; set; }
+
+ public bool? IsHearingImpaired { get; set; }
+
+ public MediaStreamInfo Stream { get; set; } = null!;
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaStreamInfo.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaStreamInfo.cs
index 0d8cf834..0537cb02 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaStreamInfo.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaStreamInfo.cs
@@ -23,52 +23,24 @@ public class MediaStreamInfo
public string? Language { get; set; }
- public string? ChannelLayout { get; set; }
-
public string? Profile { get; set; }
- public string? AspectRatio { get; set; }
-
public string? Path { get; set; }
- public bool? IsInterlaced { get; set; }
-
public int? BitRate { get; set; }
- public int? Channels { get; set; }
-
- public int? SampleRate { get; set; }
-
public bool IsDefault { get; set; }
public bool IsForced { get; set; }
public bool IsExternal { get; set; }
- public int? Height { get; set; }
-
- public int? Width { get; set; }
-
- public float? AverageFrameRate { get; set; }
-
- public float? RealFrameRate { get; set; }
-
public float? Level { get; set; }
- public string? PixelFormat { get; set; }
-
- public int? BitDepth { get; set; }
-
- public bool? IsAnamorphic { get; set; }
-
- public int? RefFrames { get; set; }
-
public string? CodecTag { get; set; }
public string? Comment { get; set; }
- public string? NalLengthSize { get; set; }
-
public bool? IsAvc { get; set; }
public string? Title { get; set; }
@@ -77,33 +49,13 @@ public class MediaStreamInfo
public string? CodecTimeBase { get; set; }
- public string? ColorPrimaries { get; set; }
+ ///
+ /// Gets or sets audio-specific stream details. Non-null only when is Audio.
+ ///
+ public AudioStreamDetails? AudioDetails { get; set; }
- public string? ColorSpace { get; set; }
-
- public string? ColorTransfer { get; set; }
-
- public int? DvVersionMajor { get; set; }
-
- public int? DvVersionMinor { get; set; }
-
- public int? DvProfile { get; set; }
-
- public int? DvLevel { get; set; }
-
- public int? RpuPresentFlag { get; set; }
-
- public int? ElPresentFlag { get; set; }
-
- public int? BlPresentFlag { get; set; }
-
- public int? DvBlSignalCompatibilityId { get; set; }
-
- public bool? IsHearingImpaired { get; set; }
-
- public int? Rotation { get; set; }
-
- public string? KeyFrames { get; set; }
-
- public bool? Hdr10PlusPresentFlag { get; set; }
+ ///
+ /// Gets or sets video-specific stream details. Non-null only when is Video.
+ ///
+ public VideoStreamDetails? VideoDetails { get; set; }
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/VideoStreamDetails.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/VideoStreamDetails.cs
new file mode 100644
index 00000000..885c5618
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/VideoStreamDetails.cs
@@ -0,0 +1,69 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+namespace Jellyfin.Database.Implementations.Entities;
+
+#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
+#pragma warning disable SA1600
+
+using System;
+
+public class VideoStreamDetails
+{
+ public required Guid ItemId { get; set; }
+
+ public required int StreamIndex { get; set; }
+
+ public string? AspectRatio { get; set; }
+
+ public bool? IsInterlaced { get; set; }
+
+ public int? Height { get; set; }
+
+ public int? Width { get; set; }
+
+ public float? AverageFrameRate { get; set; }
+
+ public float? RealFrameRate { get; set; }
+
+ public string? PixelFormat { get; set; }
+
+ public int? BitDepth { get; set; }
+
+ public bool? IsAnamorphic { get; set; }
+
+ public int? RefFrames { get; set; }
+
+ public string? NalLengthSize { get; set; }
+
+ public string? ColorPrimaries { get; set; }
+
+ public string? ColorSpace { get; set; }
+
+ public string? ColorTransfer { get; set; }
+
+ public int? DvVersionMajor { get; set; }
+
+ public int? DvVersionMinor { get; set; }
+
+ public int? DvProfile { get; set; }
+
+ public int? DvLevel { get; set; }
+
+ public int? RpuPresentFlag { get; set; }
+
+ public int? ElPresentFlag { get; set; }
+
+ public int? BlPresentFlag { get; set; }
+
+ public int? DvBlSignalCompatibilityId { get; set; }
+
+ public int? Rotation { get; set; }
+
+ public string? KeyFrames { get; set; }
+
+ public bool? Hdr10PlusPresentFlag { get; set; }
+
+ public MediaStreamInfo Stream { get; set; } = null!;
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs
index 4ef3bf02..9b01153b 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs
@@ -146,6 +146,16 @@ public class JellyfinDbContext(DbContextOptions options, ILog
///
public DbSet MediaStreamInfos => this.Set();
+ ///
+ /// Gets the containing audio-specific stream details.
+ ///
+ public DbSet AudioStreamDetails => this.Set();
+
+ ///
+ /// Gets the containing video-specific stream details.
+ ///
+ public DbSet VideoStreamDetails => this.Set();
+
///
/// Gets the .
///
@@ -353,6 +363,28 @@ public class JellyfinDbContext(DbContextOptions options, ILog
}
}
+ ///
+ /// Gets the recommended batch size for bulk operations based on the number of items to process.
+ /// Smaller batches during library scans reduce dead tuple generation in PostgreSQL.
+ ///
+ /// The total number of entities being processed.
+ /// The recommended batch size for optimal performance.
+ public int GetBatchSize(int entityCount)
+ {
+ // Smaller batches during media scans = fewer dead tuples
+ // < 100 items: batch 10
+ // 100-1000: batch 50
+ // 1000-10000: batch 100
+ // > 10000: batch 200
+ return entityCount switch
+ {
+ < 100 => 10,
+ < 1000 => 50,
+ < 10000 => 100,
+ _ => 200
+ };
+ }
+
///
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/AudioStreamDetailsConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/AudioStreamDetailsConfiguration.cs
new file mode 100644
index 00000000..89e751dd
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/AudioStreamDetailsConfiguration.cs
@@ -0,0 +1,24 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+namespace Jellyfin.Database.Implementations.ModelConfiguration;
+
+using System;
+using Jellyfin.Database.Implementations.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+///
+/// EF Core configuration for .
+///
+public class AudioStreamDetailsConfiguration : IEntityTypeConfiguration
+{
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ builder.HasKey(e => new { e.ItemId, e.StreamIndex });
+ builder.ToTable("AudioStreamDetails", "library");
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs
index 2b49c86b..14330a03 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs
@@ -23,5 +23,15 @@ public class MediaStreamInfoConfiguration : IEntityTypeConfiguration e.StreamType);
builder.HasIndex(e => new { e.StreamIndex, e.StreamType });
builder.HasIndex(e => new { e.StreamIndex, e.StreamType, e.Language });
+
+ builder.HasOne(e => e.AudioDetails)
+ .WithOne(a => a.Stream)
+ .HasForeignKey(a => new { a.ItemId, a.StreamIndex })
+ .OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasOne(e => e.VideoDetails)
+ .WithOne(v => v.Stream)
+ .HasForeignKey(v => new { v.ItemId, v.StreamIndex })
+ .OnDelete(DeleteBehavior.Cascade);
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/VideoStreamDetailsConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/VideoStreamDetailsConfiguration.cs
new file mode 100644
index 00000000..0995ad12
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/VideoStreamDetailsConfiguration.cs
@@ -0,0 +1,24 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+namespace Jellyfin.Database.Implementations.ModelConfiguration;
+
+using System;
+using Jellyfin.Database.Implementations.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+///
+/// EF Core configuration for .
+///
+public class VideoStreamDetailsConfiguration : IEntityTypeConfiguration
+{
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ builder.HasKey(e => new { e.ItemId, e.StreamIndex });
+ builder.ToTable("VideoStreamDetails", "library");
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/BulkOperationExtensions.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/BulkOperationExtensions.cs
new file mode 100644
index 00000000..b839c240
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/BulkOperationExtensions.cs
@@ -0,0 +1,295 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+namespace Jellyfin.Database.Providers.Postgres;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+
+///
+/// Extension methods for optimized bulk operations on PostgreSQL.
+/// These methods help reduce dead tuple generation during media library scans by batching
+/// operations and using more efficient transaction patterns.
+///
+public static class BulkOperationExtensions
+{
+ ///
+ /// Gets the recommended batch size for bulk operations based on the data size.
+ /// PostgreSQL works best with smaller batches to reduce dead tuples during scans.
+ ///
+ /// The total number of entities to process.
+ /// The recommended batch size.
+ public static int GetRecommendedBatchSize(int entityCount)
+ {
+ // For media library scans, smaller batches reduce dead tuples:
+ // < 100 entities: batch of 10 (light operations)
+ // 100-1000 entities: batch of 50 (medium operations)
+ // 1000-10000 entities: batch of 100 (heavy operations)
+ // > 10000 entities: batch of 200 (very heavy operations)
+ return entityCount switch
+ {
+ < 100 => 10,
+ < 1000 => 50,
+ < 10000 => 100,
+ _ => 200
+ };
+ }
+
+ ///
+ /// Saves changes in batches to reduce dead tuple generation during bulk operations.
+ /// This is especially useful during media library scans where many items are created/updated/deleted.
+ ///
+ /// The database context.
+ /// The batch size for SaveChanges calls. If 0 or negative, uses automatic sizing.
+ /// Cancellation token.
+ /// Total number of changes saved.
+ public static async Task SaveChangesInBatchesAsync(
+ this DbContext context,
+ int batchSize = 0,
+ CancellationToken cancellationToken = default)
+ {
+ var totalChanges = 0;
+ var currentBatch = 0;
+ var trackingCount = context.ChangeTracker.Entries().Count();
+
+ if (batchSize <= 0)
+ {
+ batchSize = GetRecommendedBatchSize(trackingCount);
+ }
+
+ // If the batch size is larger than what we're tracking, just save once
+ if (trackingCount <= batchSize)
+ {
+ return await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ var entries = context.ChangeTracker.Entries().ToList();
+ var changes = new List