aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs41
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs17
-rw-r--r--Emby.Server.Implementations/Library/PathManager.cs40
-rw-r--r--Jellyfin.Server/Migrations/MigrationRunner.cs1
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs66
-rw-r--r--Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs299
-rw-r--r--MediaBrowser.Controller/IO/IPathManager.cs32
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs8
-rw-r--r--MediaBrowser.Controller/MediaEncoding/IAttachmentExtractor.cs47
-rw-r--r--MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs418
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs33
-rw-r--r--MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs11
-rw-r--r--MediaBrowser.Model/Entities/MediaAttachment.cs80
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/AttachmentStreamInfo.cs2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.Designer.cs1657
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.cs36
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs1
17 files changed, 2385 insertions, 404 deletions
diff --git a/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs b/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs
index 63481b1f8..9a80eafe5 100644
--- a/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs
+++ b/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs
@@ -1,14 +1,14 @@
#pragma warning disable CS1591
using System;
+using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
-using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Trickplay;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -19,15 +19,18 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
private readonly ILibraryManager _libraryManager;
private readonly ILogger<CleanDatabaseScheduledTask> _logger;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+ private readonly IPathManager _pathManager;
public CleanDatabaseScheduledTask(
ILibraryManager libraryManager,
ILogger<CleanDatabaseScheduledTask> logger,
- IDbContextFactory<JellyfinDbContext> dbProvider)
+ IDbContextFactory<JellyfinDbContext> dbProvider,
+ IPathManager pathManager)
{
_libraryManager = libraryManager;
_logger = logger;
_dbProvider = dbProvider;
+ _pathManager = pathManager;
}
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
@@ -56,6 +59,38 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
{
_logger.LogInformation("Cleaning item {Item} type: {Type} path: {Path}", item.Name, item.GetType().Name, item.Path ?? string.Empty);
+ foreach (var mediaSource in item.GetMediaSources(false))
+ {
+ // Delete extracted subtitles
+ try
+ {
+ var subtitleFolder = _pathManager.GetSubtitleFolderPath(mediaSource.Id);
+ if (Directory.Exists(subtitleFolder))
+ {
+ Directory.Delete(subtitleFolder, true);
+ }
+ }
+ catch (Exception e)
+ {
+ _logger.LogWarning("Failed to remove subtitle cache folder for {Item}: {Exception}", item.Id, e.Message);
+ }
+
+ // Delete extracted attachments
+ try
+ {
+ var attachmentFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
+ if (Directory.Exists(attachmentFolder))
+ {
+ Directory.Delete(attachmentFolder, true);
+ }
+ }
+ catch (Exception e)
+ {
+ _logger.LogWarning("Failed to remove attachment cache folder for {Item}: {Exception}", item.Id, e.Message);
+ }
+ }
+
+ // Delete item
_libraryManager.DeleteItem(item, new DeleteOptions
{
DeleteFileLocation = false
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index ab8884f17..1303bb3cb 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -492,7 +492,24 @@ namespace Emby.Server.Implementations.Library
if (item is Video video)
{
+ // Trickplay
list.Add(_pathManager.GetTrickplayDirectory(video));
+
+ // Subtitles and attachments
+ foreach (var mediaSource in item.GetMediaSources(false))
+ {
+ var subtitleFolder = _pathManager.GetSubtitleFolderPath(mediaSource.Id);
+ if (subtitleFolder is not null)
+ {
+ list.Add(subtitleFolder);
+ }
+
+ var attachmentFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
+ if (attachmentFolder is not null)
+ {
+ list.Add(attachmentFolder);
+ }
+ }
}
return list;
diff --git a/Emby.Server.Implementations/Library/PathManager.cs b/Emby.Server.Implementations/Library/PathManager.cs
index c910abadb..ac004b413 100644
--- a/Emby.Server.Implementations/Library/PathManager.cs
+++ b/Emby.Server.Implementations/Library/PathManager.cs
@@ -1,5 +1,7 @@
+using System;
using System.Globalization;
using System.IO;
+using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
@@ -12,22 +14,56 @@ namespace Emby.Server.Implementations.Library;
public class PathManager : IPathManager
{
private readonly IServerConfigurationManager _config;
+ private readonly IApplicationPaths _appPaths;
/// <summary>
/// Initializes a new instance of the <see cref="PathManager"/> class.
/// </summary>
/// <param name="config">The server configuration manager.</param>
+ /// <param name="appPaths">The application paths.</param>
public PathManager(
- IServerConfigurationManager config)
+ IServerConfigurationManager config,
+ IApplicationPaths appPaths)
{
_config = config;
+ _appPaths = appPaths;
+ }
+
+ private string SubtitleCachePath => Path.Combine(_appPaths.DataPath, "subtitles");
+
+ private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments");
+
+ /// <inheritdoc />
+ public string GetAttachmentPath(string mediaSourceId, string fileName)
+ {
+ return Path.Join(GetAttachmentFolderPath(mediaSourceId), fileName);
+ }
+
+ /// <inheritdoc />
+ public string GetAttachmentFolderPath(string mediaSourceId)
+ {
+ var id = Guid.Parse(mediaSourceId);
+ return Path.Join(AttachmentCachePath, id.ToString("D", CultureInfo.InvariantCulture));
+ }
+
+ /// <inheritdoc />
+ public string GetSubtitleFolderPath(string mediaSourceId)
+ {
+ var id = Guid.Parse(mediaSourceId);
+ return Path.Join(SubtitleCachePath, id.ToString("D", CultureInfo.InvariantCulture));
+ }
+
+ /// <inheritdoc />
+ public string GetSubtitlePath(string mediaSourceId, int streamIndex, string extension)
+ {
+ return Path.Join(GetSubtitleFolderPath(mediaSourceId), streamIndex.ToString(CultureInfo.InvariantCulture) + extension);
}
/// <inheritdoc />
public string GetTrickplayDirectory(BaseItem item, bool saveWithMedia = false)
{
var basePath = _config.ApplicationPaths.TrickplayPath;
- var idString = item.Id.ToString("N", CultureInfo.InvariantCulture);
+ var idString = item.Id.ToString("D", CultureInfo.InvariantCulture);
return saveWithMedia
? Path.Combine(item.ContainingFolderPath, Path.ChangeExtension(item.Path, ".trickplay"))
diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs
index baeea2c14..c3a2e1bc4 100644
--- a/Jellyfin.Server/Migrations/MigrationRunner.cs
+++ b/Jellyfin.Server/Migrations/MigrationRunner.cs
@@ -54,6 +54,7 @@ namespace Jellyfin.Server.Migrations
typeof(Routines.FixAudioData),
typeof(Routines.RemoveDuplicatePlaylistChildren),
typeof(Routines.MigrateLibraryDb),
+ typeof(Routines.MoveExtractedFiles),
typeof(Routines.MigrateRatingLevels),
typeof(Routines.MoveTrickplayFiles),
typeof(Routines.MigrateKeyframeData),
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
index f414b6e39..3fc9bea84 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
@@ -80,6 +80,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
using (var operation = GetPreparedDbContext("Cleanup database"))
{
+ operation.JellyfinDbContext.AttachmentStreamInfos.ExecuteDelete();
operation.JellyfinDbContext.BaseItems.ExecuteDelete();
operation.JellyfinDbContext.ItemValues.ExecuteDelete();
operation.JellyfinDbContext.UserData.ExecuteDelete();
@@ -251,6 +252,29 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
}
}
+ using (var operation = GetPreparedDbContext("moving AttachmentStreamInfos"))
+ {
+ const string mediaAttachmentQuery =
+ """
+ SELECT ItemId, AttachmentIndex, Codec, CodecTag, Comment, filename, MIMEType
+ FROM mediaattachments
+ WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = mediaattachments.ItemId)
+ """;
+
+ using (new TrackedMigrationStep("loading AttachmentStreamInfos", _logger))
+ {
+ foreach (SqliteDataReader dto in connection.Query(mediaAttachmentQuery))
+ {
+ operation.JellyfinDbContext.AttachmentStreamInfos.Add(GetMediaAttachment(dto));
+ }
+ }
+
+ using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.AttachmentStreamInfos.Local.Count} AttachmentStreamInfos entries", _logger))
+ {
+ operation.JellyfinDbContext.SaveChanges();
+ }
+ }
+
using (var operation = GetPreparedDbContext("moving People"))
{
const string personsQuery =
@@ -709,6 +733,48 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
return item;
}
+ /// <summary>
+ /// Gets the attachment.
+ /// </summary>
+ /// <param name="reader">The reader.</param>
+ /// <returns>MediaAttachment.</returns>
+ private AttachmentStreamInfo GetMediaAttachment(SqliteDataReader reader)
+ {
+ var item = new AttachmentStreamInfo
+ {
+ Index = reader.GetInt32(1),
+ Item = null!,
+ ItemId = reader.GetGuid(0),
+ };
+
+ if (reader.TryGetString(2, out var codec))
+ {
+ item.Codec = codec;
+ }
+
+ if (reader.TryGetString(3, out var codecTag))
+ {
+ item.CodecTag = codecTag;
+ }
+
+ if (reader.TryGetString(4, out var comment))
+ {
+ item.Comment = comment;
+ }
+
+ if (reader.TryGetString(5, out var fileName))
+ {
+ item.Filename = fileName;
+ }
+
+ if (reader.TryGetString(6, out var mimeType))
+ {
+ item.MimeType = mimeType;
+ }
+
+ return item;
+ }
+
private (BaseItemEntity BaseItem, string[] LegacyUserDataKey) GetItem(SqliteDataReader reader)
{
var entity = new BaseItemEntity()
diff --git a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs
new file mode 100644
index 000000000..f63c5fd40
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs
@@ -0,0 +1,299 @@
+#pragma warning disable CA5351 // Do Not Use Broken Cryptographic Algorithms
+
+using System;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.MediaInfo;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to move extracted files to the new directories.
+/// </summary>
+public class MoveExtractedFiles : IDatabaseMigrationRoutine
+{
+ private readonly IApplicationPaths _appPaths;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILogger<MoveExtractedFiles> _logger;
+ private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly IPathManager _pathManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="MoveExtractedFiles"/> class.
+ /// </summary>
+ /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="logger">The logger.</param>
+ /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+ /// <param name="pathManager">Instance of the <see cref="IPathManager"/> interface.</param>
+ public MoveExtractedFiles(
+ IApplicationPaths appPaths,
+ ILibraryManager libraryManager,
+ ILogger<MoveExtractedFiles> logger,
+ IMediaSourceManager mediaSourceManager,
+ IPathManager pathManager)
+ {
+ _appPaths = appPaths;
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _mediaSourceManager = mediaSourceManager;
+ _pathManager = pathManager;
+ }
+
+ private string SubtitleCachePath => Path.Combine(_appPaths.DataPath, "subtitles");
+
+ private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments");
+
+ /// <inheritdoc />
+ public Guid Id => new("9063b0Ef-CFF1-4EDC-9A13-74093681A89B");
+
+ /// <inheritdoc />
+ public string Name => "MoveExtractedFiles";
+
+ /// <inheritdoc />
+ public bool PerformOnNewInstall => false;
+
+ /// <inheritdoc />
+ public void Perform()
+ {
+ const int Limit = 500;
+ int itemCount = 0, offset = 0;
+
+ var sw = Stopwatch.StartNew();
+ var itemsQuery = new InternalItemsQuery
+ {
+ MediaTypes = [MediaType.Video],
+ SourceTypes = [SourceType.Library],
+ IsVirtualItem = false,
+ IsFolder = false,
+ Limit = Limit,
+ StartIndex = offset,
+ EnableTotalRecordCount = true,
+ };
+
+ var records = _libraryManager.GetItemsResult(itemsQuery).TotalRecordCount;
+ _logger.LogInformation("Checking {Count} items for movable extracted files.", records);
+
+ // Make sure directories exist
+ Directory.CreateDirectory(SubtitleCachePath);
+ Directory.CreateDirectory(AttachmentCachePath);
+
+ itemsQuery.EnableTotalRecordCount = false;
+ do
+ {
+ itemsQuery.StartIndex = offset;
+ var result = _libraryManager.GetItemsResult(itemsQuery);
+
+ var items = result.Items;
+ foreach (var item in items)
+ {
+ if (MoveSubtitleAndAttachmentFiles(item))
+ {
+ itemCount++;
+ }
+ }
+
+ offset += Limit;
+ if (offset % 5_000 == 0)
+ {
+ _logger.LogInformation("Checked extracted files for {Count} items in {Time}.", offset, sw.Elapsed);
+ }
+ } while (offset < records);
+
+ _logger.LogInformation("Checked {Checked} items - Moved files for {Items} items in {Time}.", records, itemCount, sw.Elapsed);
+
+ // Get all subdirectories with 1 character names (those are the legacy directories)
+ var subdirectories = Directory.GetDirectories(SubtitleCachePath, "*", SearchOption.AllDirectories).Where(s => s.Length == SubtitleCachePath.Length + 2).ToList();
+ subdirectories.AddRange(Directory.GetDirectories(AttachmentCachePath, "*", SearchOption.AllDirectories).Where(s => s.Length == AttachmentCachePath.Length + 2));
+
+ // Remove all legacy subdirectories
+ foreach (var subdir in subdirectories)
+ {
+ Directory.Delete(subdir, true);
+ }
+
+ // Remove old cache path
+ var attachmentCachePath = Path.Join(_appPaths.CachePath, "attachments");
+ if (Directory.Exists(attachmentCachePath))
+ {
+ Directory.Delete(attachmentCachePath, true);
+ }
+
+ _logger.LogInformation("Cleaned up left over subtitles and attachments.");
+ }
+
+ private bool MoveSubtitleAndAttachmentFiles(BaseItem item)
+ {
+ var mediaStreams = item.GetMediaStreams().Where(s => s.Type == MediaStreamType.Subtitle && !s.IsExternal);
+ var itemIdString = item.Id.ToString("N", CultureInfo.InvariantCulture);
+ var modified = false;
+ foreach (var mediaStream in mediaStreams)
+ {
+ if (mediaStream.Codec is null)
+ {
+ continue;
+ }
+
+ var mediaStreamIndex = mediaStream.Index;
+ var extension = GetSubtitleExtension(mediaStream.Codec);
+ var oldSubtitleCachePath = GetOldSubtitleCachePath(item.Path, mediaStream.Index, extension);
+ if (string.IsNullOrEmpty(oldSubtitleCachePath) || !File.Exists(oldSubtitleCachePath))
+ {
+ continue;
+ }
+
+ var newSubtitleCachePath = _pathManager.GetSubtitlePath(itemIdString, mediaStreamIndex, extension);
+ if (File.Exists(newSubtitleCachePath))
+ {
+ File.Delete(oldSubtitleCachePath);
+ }
+ else
+ {
+ var newDirectory = Path.GetDirectoryName(newSubtitleCachePath);
+ if (newDirectory is not null)
+ {
+ Directory.CreateDirectory(newDirectory);
+ File.Move(oldSubtitleCachePath, newSubtitleCachePath, false);
+ _logger.LogDebug("Moved subtitle {Index} for {Item} from {Source} to {Destination}", mediaStreamIndex, item.Id, oldSubtitleCachePath, newSubtitleCachePath);
+
+ modified = true;
+ }
+ }
+ }
+
+ var attachments = _mediaSourceManager.GetMediaAttachments(item.Id).Where(a => !string.Equals(a.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase)).ToList();
+ var shouldExtractOneByOne = attachments.Any(a => !string.IsNullOrEmpty(a.FileName)
+ && (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
+ foreach (var attachment in attachments)
+ {
+ var attachmentIndex = attachment.Index;
+ var oldAttachmentPath = GetOldAttachmentDataPath(item.Path, attachmentIndex);
+ if (string.IsNullOrEmpty(oldAttachmentPath) || !File.Exists(oldAttachmentPath))
+ {
+ oldAttachmentPath = GetOldAttachmentCachePath(itemIdString, attachment, shouldExtractOneByOne);
+ if (string.IsNullOrEmpty(oldAttachmentPath) || !File.Exists(oldAttachmentPath))
+ {
+ continue;
+ }
+ }
+
+ var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.FileName ?? attachmentIndex.ToString(CultureInfo.InvariantCulture));
+ if (File.Exists(newAttachmentPath))
+ {
+ File.Delete(oldAttachmentPath);
+ }
+ else
+ {
+ var newDirectory = Path.GetDirectoryName(newAttachmentPath);
+ if (newDirectory is not null)
+ {
+ Directory.CreateDirectory(newDirectory);
+ File.Move(oldAttachmentPath, newAttachmentPath, false);
+ _logger.LogDebug("Moved attachment {Index} for {Item} from {Source} to {Destination}", attachmentIndex, item.Id, oldAttachmentPath, newAttachmentPath);
+
+ modified = true;
+ }
+ }
+ }
+
+ return modified;
+ }
+
+ private string? GetOldAttachmentDataPath(string? mediaPath, int attachmentStreamIndex)
+ {
+ if (mediaPath is null)
+ {
+ return null;
+ }
+
+ string filename;
+ var protocol = _mediaSourceManager.GetPathProtocol(mediaPath);
+ if (protocol == MediaProtocol.File)
+ {
+ DateTime? date;
+ try
+ {
+ date = File.GetLastWriteTimeUtc(mediaPath);
+ }
+ catch (IOException e)
+ {
+ _logger.LogDebug("Skipping attachment at index {Index} for {Path}: {Exception}", attachmentStreamIndex, mediaPath, e.Message);
+
+ return null;
+ }
+
+ filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Value.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture);
+ }
+ else
+ {
+ filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture);
+ }
+
+ return Path.Join(_appPaths.DataPath, "attachments", filename[..1], filename);
+ }
+
+ private string? GetOldAttachmentCachePath(string mediaSourceId, MediaAttachment attachment, bool shouldExtractOneByOne)
+ {
+ var attachmentFolderPath = Path.Join(_appPaths.CachePath, "attachments", mediaSourceId);
+ if (shouldExtractOneByOne)
+ {
+ return Path.Join(attachmentFolderPath, attachment.Index.ToString(CultureInfo.InvariantCulture));
+ }
+
+ if (string.IsNullOrEmpty(attachment.FileName))
+ {
+ return null;
+ }
+
+ return Path.Join(attachmentFolderPath, attachment.FileName);
+ }
+
+ private string? GetOldSubtitleCachePath(string path, int streamIndex, string outputSubtitleExtension)
+ {
+ DateTime? date;
+ try
+ {
+ date = File.GetLastWriteTimeUtc(path);
+ }
+ catch (IOException e)
+ {
+ _logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);
+
+ return null;
+ }
+
+ var ticksParam = string.Empty;
+ ReadOnlySpan<char> filename = new Guid(MD5.HashData(Encoding.Unicode.GetBytes(path + "_" + streamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Value.Ticks.ToString(CultureInfo.InvariantCulture) + ticksParam))) + outputSubtitleExtension;
+
+ return Path.Join(SubtitleCachePath, filename[..1], filename);
+ }
+
+ private static string GetSubtitleExtension(string codec)
+ {
+ if (codec.ToLower(CultureInfo.InvariantCulture).Equals("ass", StringComparison.OrdinalIgnoreCase)
+ || codec.ToLower(CultureInfo.InvariantCulture).Equals("ssa", StringComparison.OrdinalIgnoreCase))
+ {
+ return "." + codec;
+ }
+ else if (codec.Contains("pgs", StringComparison.OrdinalIgnoreCase))
+ {
+ return ".sup";
+ }
+ else
+ {
+ return ".srt";
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/IO/IPathManager.cs b/MediaBrowser.Controller/IO/IPathManager.cs
index 036889810..7c20164a6 100644
--- a/MediaBrowser.Controller/IO/IPathManager.cs
+++ b/MediaBrowser.Controller/IO/IPathManager.cs
@@ -1,4 +1,5 @@
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Dto;
namespace MediaBrowser.Controller.IO;
@@ -14,4 +15,35 @@ public interface IPathManager
/// <param name="saveWithMedia">Whether or not the tile should be saved next to the media file.</param>
/// <returns>The absolute path.</returns>
public string GetTrickplayDirectory(BaseItem item, bool saveWithMedia = false);
+
+ /// <summary>
+ /// Gets the path to the subtitle file.
+ /// </summary>
+ /// <param name="mediaSourceId">The media source id.</param>
+ /// <param name="streamIndex">The stream index.</param>
+ /// <param name="extension">The subtitle file extension.</param>
+ /// <returns>The absolute path.</returns>
+ public string GetSubtitlePath(string mediaSourceId, int streamIndex, string extension);
+
+ /// <summary>
+ /// Gets the path to the subtitle file.
+ /// </summary>
+ /// <param name="mediaSourceId">The media source id.</param>
+ /// <returns>The absolute path.</returns>
+ public string GetSubtitleFolderPath(string mediaSourceId);
+
+ /// <summary>
+ /// Gets the path to the attachment file.
+ /// </summary>
+ /// <param name="mediaSourceId">The media source id.</param>
+ /// <param name="fileName">The attachmentFileName index.</param>
+ /// <returns>The absolute path.</returns>
+ public string GetAttachmentPath(string mediaSourceId, string fileName);
+
+ /// <summary>
+ /// Gets the path to the attachment folder.
+ /// </summary>
+ /// <param name="mediaSourceId">The media source id.</param>
+ /// <returns>The absolute path.</returns>
+ public string GetAttachmentFolderPath(string mediaSourceId);
}
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index afa962a41..75b3f151d 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -19,6 +19,7 @@ using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Extensions;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Dto;
@@ -55,6 +56,7 @@ namespace MediaBrowser.Controller.MediaEncoding
private readonly ISubtitleEncoder _subtitleEncoder;
private readonly IConfiguration _config;
private readonly IConfigurationManager _configurationManager;
+ private readonly IPathManager _pathManager;
// i915 hang was fixed by linux 6.2 (3f882f2)
private readonly Version _minKerneli915Hang = new Version(5, 18);
@@ -153,13 +155,15 @@ namespace MediaBrowser.Controller.MediaEncoding
IMediaEncoder mediaEncoder,
ISubtitleEncoder subtitleEncoder,
IConfiguration config,
- IConfigurationManager configurationManager)
+ IConfigurationManager configurationManager,
+ IPathManager pathManager)
{
_appPaths = appPaths;
_mediaEncoder = mediaEncoder;
_subtitleEncoder = subtitleEncoder;
_config = config;
_configurationManager = configurationManager;
+ _pathManager = pathManager;
}
private enum DynamicHdrMetadataRemovalPlan
@@ -1785,7 +1789,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var alphaParam = enableAlpha ? ":alpha=1" : string.Empty;
var sub2videoParam = enableSub2video ? ":sub2video=1" : string.Empty;
- var fontPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id);
+ var fontPath = _pathManager.GetAttachmentFolderPath(state.MediaSource.Id);
var fontParam = string.Format(
CultureInfo.InvariantCulture,
":fontsdir='{0}'",
diff --git a/MediaBrowser.Controller/MediaEncoding/IAttachmentExtractor.cs b/MediaBrowser.Controller/MediaEncoding/IAttachmentExtractor.cs
index 09840d2ee..d8d136472 100644
--- a/MediaBrowser.Controller/MediaEncoding/IAttachmentExtractor.cs
+++ b/MediaBrowser.Controller/MediaEncoding/IAttachmentExtractor.cs
@@ -9,26 +9,33 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
-namespace MediaBrowser.Controller.MediaEncoding
-{
- public interface IAttachmentExtractor
- {
- Task<(MediaAttachment Attachment, Stream Stream)> GetAttachment(
- BaseItem item,
- string mediaSourceId,
- int attachmentStreamIndex,
- CancellationToken cancellationToken);
+namespace MediaBrowser.Controller.MediaEncoding;
- Task ExtractAllAttachments(
- string inputFile,
- MediaSourceInfo mediaSource,
- string outputPath,
- CancellationToken cancellationToken);
+public interface IAttachmentExtractor
+{
+ /// <summary>
+ /// Gets the path to the attachment file.
+ /// </summary>
+ /// <param name="item">The <see cref="BaseItem"/>.</param>
+ /// <param name="mediaSourceId">The media source id.</param>
+ /// <param name="attachmentStreamIndex">The attachment index.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The async task.</returns>
+ Task<(MediaAttachment Attachment, Stream Stream)> GetAttachment(
+ BaseItem item,
+ string mediaSourceId,
+ int attachmentStreamIndex,
+ CancellationToken cancellationToken);
- Task ExtractAllAttachmentsExternal(
- string inputArgument,
- string id,
- string outputPath,
- CancellationToken cancellationToken);
- }
+ /// <summary>
+ /// Gets the path to the attachment file.
+ /// </summary>
+ /// <param name="inputFile">The input file path.</param>
+ /// <param name="mediaSource">The <see cref="MediaSourceInfo" /> source id.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The async task.</returns>
+ Task ExtractAllAttachments(
+ string inputFile,
+ MediaSourceInfo mediaSource,
+ CancellationToken cancellationToken);
}
diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
index 431fc0b17..89291c73b 100644
--- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
+++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
@@ -1,7 +1,4 @@
-#pragma warning disable CS1591
-
using System;
-using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
@@ -9,28 +6,27 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
-using MediaBrowser.Common;
-using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.MediaEncoding.Encoder;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
-using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.MediaEncoding.Attachments
{
+ /// <inheritdoc cref="IAttachmentExtractor"/>
public sealed class AttachmentExtractor : IAttachmentExtractor, IDisposable
{
private readonly ILogger<AttachmentExtractor> _logger;
- private readonly IApplicationPaths _appPaths;
private readonly IFileSystem _fileSystem;
private readonly IMediaEncoder _mediaEncoder;
private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly IPathManager _pathManager;
private readonly AsyncKeyedLocker<string> _semaphoreLocks = new(o =>
{
@@ -38,18 +34,26 @@ namespace MediaBrowser.MediaEncoding.Attachments
o.PoolInitialFill = 1;
});
+ /// <summary>
+ /// Initializes a new instance of the <see cref="AttachmentExtractor"/> class.
+ /// </summary>
+ /// <param name="logger">The <see cref="ILogger{AttachmentExtractor}"/>.</param>
+ /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
+ /// <param name="mediaEncoder">The <see cref="IMediaEncoder"/>.</param>
+ /// <param name="mediaSourceManager">The <see cref="IMediaSourceManager"/>.</param>
+ /// <param name="pathManager">The <see cref="IPathManager"/>.</param>
public AttachmentExtractor(
ILogger<AttachmentExtractor> logger,
- IApplicationPaths appPaths,
IFileSystem fileSystem,
IMediaEncoder mediaEncoder,
- IMediaSourceManager mediaSourceManager)
+ IMediaSourceManager mediaSourceManager,
+ IPathManager pathManager)
{
_logger = logger;
- _appPaths = appPaths;
_fileSystem = fileSystem;
_mediaEncoder = mediaEncoder;
_mediaSourceManager = mediaSourceManager;
+ _pathManager = pathManager;
}
/// <inheritdoc />
@@ -77,350 +81,183 @@ namespace MediaBrowser.MediaEncoding.Attachments
throw new ResourceNotFoundException($"MediaSource {mediaSourceId} has no attachment with stream index {attachmentStreamIndex}");
}
+ if (string.Equals(mediaAttachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new ResourceNotFoundException($"Attachment with stream index {attachmentStreamIndex} can't be extracted for MediaSource {mediaSourceId}");
+ }
+
var attachmentStream = await GetAttachmentStream(mediaSource, mediaAttachment, cancellationToken)
.ConfigureAwait(false);
return (mediaAttachment, attachmentStream);
}
+ /// <inheritdoc />
public async Task ExtractAllAttachments(
string inputFile,
MediaSourceInfo mediaSource,
- string outputPath,
CancellationToken cancellationToken)
{
var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName)
&& (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
- if (shouldExtractOneByOne)
- {
- var attachmentIndexes = mediaSource.MediaAttachments.Select(a => a.Index);
- foreach (var i in attachmentIndexes)
- {
- var newName = Path.Join(outputPath, i.ToString(CultureInfo.InvariantCulture));
- await ExtractAttachment(inputFile, mediaSource, i, newName, cancellationToken).ConfigureAwait(false);
- }
- }
- else
+ if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
{
- using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
+ foreach (var attachment in mediaSource.MediaAttachments)
{
- if (!Directory.Exists(outputPath))
+ if (!string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
{
- await ExtractAllAttachmentsInternal(
- _mediaEncoder.GetInputArgument(inputFile, mediaSource),
- outputPath,
- false,
- cancellationToken).ConfigureAwait(false);
+ await ExtractAttachment(inputFile, mediaSource, attachment, cancellationToken).ConfigureAwait(false);
}
}
}
- }
-
- public async Task ExtractAllAttachmentsExternal(
- string inputArgument,
- string id,
- string outputPath,
- CancellationToken cancellationToken)
- {
- using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
+ else
{
- if (!File.Exists(Path.Join(outputPath, id)))
- {
- await ExtractAllAttachmentsInternal(
- inputArgument,
- outputPath,
- true,
- cancellationToken).ConfigureAwait(false);
-
- if (Directory.Exists(outputPath))
- {
- File.Create(Path.Join(outputPath, id));
- }
- }
+ await ExtractAllAttachmentsInternal(
+ inputFile,
+ mediaSource,
+ false,
+ cancellationToken).ConfigureAwait(false);
}
}
private async Task ExtractAllAttachmentsInternal(
- string inputPath,
- string outputPath,
+ string inputFile,
+ MediaSourceInfo mediaSource,
bool isExternal,
CancellationToken cancellationToken)
{
- ArgumentException.ThrowIfNullOrEmpty(inputPath);
- ArgumentException.ThrowIfNullOrEmpty(outputPath);
-
- Directory.CreateDirectory(outputPath);
-
- var processArgs = string.Format(
- CultureInfo.InvariantCulture,
- "-dump_attachment:t \"\" -y {0} -i {1} -t 0 -f null null",
- inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.Empty,
- inputPath);
-
- int exitCode;
-
- using (var process = new Process
- {
- StartInfo = new ProcessStartInfo
- {
- Arguments = processArgs,
- FileName = _mediaEncoder.EncoderPath,
- UseShellExecute = false,
- CreateNoWindow = true,
- WindowStyle = ProcessWindowStyle.Hidden,
- WorkingDirectory = outputPath,
- ErrorDialog = false
- },
- EnableRaisingEvents = true
- })
- {
- _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
-
- process.Start();
+ var inputPath = _mediaEncoder.GetInputArgument(inputFile, mediaSource);
- try
- {
- await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
- exitCode = process.ExitCode;
- }
- catch (OperationCanceledException)
- {
- process.Kill(true);
- exitCode = -1;
- }
- }
-
- var failed = false;
+ ArgumentException.ThrowIfNullOrEmpty(inputPath);
- if (exitCode != 0)
+ var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
+ using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false))
{
- if (isExternal && exitCode == 1)
+ if (!Directory.Exists(outputFolder))
{
- // ffmpeg returns exitCode 1 because there is no video or audio stream
- // this can be ignored
+ Directory.CreateDirectory(outputFolder);
}
else
{
- failed = true;
-
- _logger.LogWarning("Deleting extracted attachments {Path} due to failure: {ExitCode}", outputPath, exitCode);
- try
+ var fileNames = Directory.GetFiles(outputFolder, "*", SearchOption.TopDirectoryOnly).Select(f => Path.GetFileName(f));
+ var missingFiles = mediaSource.MediaAttachments.Where(a => !fileNames.Contains(a.FileName) && !string.Equals(a.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase));
+ if (!missingFiles.Any())
{
- Directory.Delete(outputPath);
- }
- catch (IOException ex)
- {
- _logger.LogError(ex, "Error deleting extracted attachments {Path}", outputPath);
+ // Skip extraction if all files already exist
+ return;
}
}
- }
- else if (!Directory.Exists(outputPath))
- {
- failed = true;
- }
-
- if (failed)
- {
- _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);
-
- throw new InvalidOperationException(
- string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", inputPath, outputPath));
- }
-
- _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
- }
- private async Task<Stream> GetAttachmentStream(
- MediaSourceInfo mediaSource,
- MediaAttachment mediaAttachment,
- CancellationToken cancellationToken)
- {
- var attachmentPath = await GetReadableFile(mediaSource.Path, mediaSource.Path, mediaSource, mediaAttachment, cancellationToken).ConfigureAwait(false);
- return AsyncFile.OpenRead(attachmentPath);
- }
-
- private async Task<string> GetReadableFile(
- string mediaPath,
- string inputFile,
- MediaSourceInfo mediaSource,
- MediaAttachment mediaAttachment,
- CancellationToken cancellationToken)
- {
- await CacheAllAttachments(mediaPath, inputFile, mediaSource, cancellationToken).ConfigureAwait(false);
-
- var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, mediaAttachment.Index);
- await ExtractAttachment(inputFile, mediaSource, mediaAttachment.Index, outputPath, cancellationToken)
- .ConfigureAwait(false);
-
- return outputPath;
- }
+ var processArgs = string.Format(
+ CultureInfo.InvariantCulture,
+ "-dump_attachment:t \"\" -y {0} -i {1} -t 0 -f null null",
+ inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.Empty,
+ inputPath);
- private async Task CacheAllAttachments(
- string mediaPath,
- string inputFile,
- MediaSourceInfo mediaSource,
- CancellationToken cancellationToken)
- {
- var outputFileLocks = new List<IDisposable>();
- var extractableAttachmentIds = new List<int>();
+ int exitCode;
- try
- {
- foreach (var attachment in mediaSource.MediaAttachments)
+ using (var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ Arguments = processArgs,
+ FileName = _mediaEncoder.EncoderPath,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ WindowStyle = ProcessWindowStyle.Hidden,
+ WorkingDirectory = outputFolder,
+ ErrorDialog = false
+ },
+ EnableRaisingEvents = true
+ })
{
- var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, attachment.Index);
+ _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
- var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
+ process.Start();
- if (File.Exists(outputPath))
+ try
{
- releaser.Dispose();
- continue;
+ await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
+ exitCode = process.ExitCode;
}
-
- outputFileLocks.Add(releaser);
- extractableAttachmentIds.Add(attachment.Index);
- }
-
- if (extractableAttachmentIds.Count > 0)
- {
- await CacheAllAttachmentsInternal(mediaPath, _mediaEncoder.GetInputArgument(inputFile, mediaSource), mediaSource, extractableAttachmentIds, cancellationToken).ConfigureAwait(false);
- }
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Unable to cache media attachments for File:{File}", mediaPath);
- }
- finally
- {
- outputFileLocks.ForEach(x => x.Dispose());
- }
- }
-
- private async Task CacheAllAttachmentsInternal(
- string mediaPath,
- string inputFile,
- MediaSourceInfo mediaSource,
- List<int> extractableAttachmentIds,
- CancellationToken cancellationToken)
- {
- var outputPaths = new List<string>();
- var processArgs = string.Empty;
-
- foreach (var attachmentId in extractableAttachmentIds)
- {
- var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, attachmentId);
-
- Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calculated path ({outputPath}) is not valid."));
-
- outputPaths.Add(outputPath);
- processArgs += string.Format(
- CultureInfo.InvariantCulture,
- " -dump_attachment:{0} \"{1}\"",
- attachmentId,
- EncodingUtils.NormalizePath(outputPath));
- }
-
- processArgs += string.Format(
- CultureInfo.InvariantCulture,
- " -i {0} -t 0 -f null null",
- inputFile);
-
- int exitCode;
-
- using (var process = new Process
- {
- StartInfo = new ProcessStartInfo
+ catch (OperationCanceledException)
{
- Arguments = processArgs,
- FileName = _mediaEncoder.EncoderPath,
- UseShellExecute = false,
- CreateNoWindow = true,
- WindowStyle = ProcessWindowStyle.Hidden,
- ErrorDialog = false
- },
- EnableRaisingEvents = true
- })
- {
- _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
-
- process.Start();
-
- try
- {
- await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
- exitCode = process.ExitCode;
- }
- catch (OperationCanceledException)
- {
- process.Kill(true);
- exitCode = -1;
+ process.Kill(true);
+ exitCode = -1;
+ }
}
- }
- var failed = false;
+ var failed = false;
- if (exitCode == -1)
- {
- failed = true;
-
- foreach (var outputPath in outputPaths)
+ if (exitCode != 0)
{
- try
+ if (isExternal && exitCode == 1)
{
- _logger.LogWarning("Deleting extracted media attachment due to failure: {Path}", outputPath);
- _fileSystem.DeleteFile(outputPath);
- }
- catch (FileNotFoundException)
- {
- // ffmpeg failed, so it is normal that one or more expected output files do not exist.
- // There is no need to log anything for the user here.
+ // ffmpeg returns exitCode 1 because there is no video or audio stream
+ // this can be ignored
}
- catch (IOException ex)
+ else
{
- _logger.LogError(ex, "Error deleting extracted media attachment {Path}", outputPath);
+ failed = true;
+
+ _logger.LogWarning("Deleting extracted attachments {Path} due to failure: {ExitCode}", outputFolder, exitCode);
+ try
+ {
+ Directory.Delete(outputFolder);
+ }
+ catch (IOException ex)
+ {
+ _logger.LogError(ex, "Error deleting extracted attachments {Path}", outputFolder);
+ }
}
}
- }
- else
- {
- foreach (var outputPath in outputPaths)
+ else if (!Directory.Exists(outputFolder))
{
- if (!File.Exists(outputPath))
- {
- _logger.LogError("ffmpeg media attachment extraction failed for {InputPath} to {OutputPath}", inputFile, outputPath);
- failed = true;
- continue;
- }
+ failed = true;
+ }
- _logger.LogInformation("ffmpeg media attachment extraction completed for {InputPath} to {OutputPath}", inputFile, outputPath);
+ if (failed)
+ {
+ _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outputFolder);
+
+ throw new InvalidOperationException(
+ string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", inputPath, outputFolder));
}
- }
- if (failed)
- {
- throw new FfmpegException(
- string.Format(CultureInfo.InvariantCulture, "ffmpeg media attachment extraction failed for {0}", inputFile));
+ _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputFolder);
}
}
- private async Task ExtractAttachment(
+ private async Task<Stream> GetAttachmentStream(
+ MediaSourceInfo mediaSource,
+ MediaAttachment mediaAttachment,
+ CancellationToken cancellationToken)
+ {
+ var attachmentPath = await ExtractAttachment(mediaSource.Path, mediaSource, mediaAttachment, cancellationToken)
+ .ConfigureAwait(false);
+ return AsyncFile.OpenRead(attachmentPath);
+ }
+
+ private async Task<string> ExtractAttachment(
string inputFile,
MediaSourceInfo mediaSource,
- int attachmentStreamIndex,
- string outputPath,
+ MediaAttachment mediaAttachment,
CancellationToken cancellationToken)
{
- using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
+ var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
+ using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
{
- if (!File.Exists(outputPath))
+ var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture));
+ if (!File.Exists(attachmentPath))
{
await ExtractAttachmentInternal(
_mediaEncoder.GetInputArgument(inputFile, mediaSource),
- attachmentStreamIndex,
- outputPath,
+ mediaAttachment.Index,
+ attachmentPath,
cancellationToken).ConfigureAwait(false);
}
+
+ return attachmentPath;
}
}
@@ -510,23 +347,6 @@ namespace MediaBrowser.MediaEncoding.Attachments
_logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
}
- private string GetAttachmentCachePath(string mediaPath, MediaSourceInfo mediaSource, int attachmentStreamIndex)
- {
- string filename;
- if (mediaSource.Protocol == MediaProtocol.File)
- {
- var date = _fileSystem.GetLastWriteTimeUtc(mediaPath);
- filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture);
- }
- else
- {
- filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture);
- }
-
- var prefix = filename.AsSpan(0, 1);
- return Path.Join(_appPaths.DataPath, "attachments", prefix, filename);
- }
-
/// <inheritdoc />
public void Dispose()
{
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
index a731d4785..777e33587 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
@@ -13,10 +13,10 @@ using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
using MediaBrowser.Common;
-using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Dto;
@@ -31,12 +31,12 @@ namespace MediaBrowser.MediaEncoding.Subtitles
public sealed class SubtitleEncoder : ISubtitleEncoder, IDisposable
{
private readonly ILogger<SubtitleEncoder> _logger;
- private readonly IApplicationPaths _appPaths;
private readonly IFileSystem _fileSystem;
private readonly IMediaEncoder _mediaEncoder;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMediaSourceManager _mediaSourceManager;
private readonly ISubtitleParser _subtitleParser;
+ private readonly IPathManager _pathManager;
/// <summary>
/// The _semaphoreLocks.
@@ -49,24 +49,22 @@ namespace MediaBrowser.MediaEncoding.Subtitles
public SubtitleEncoder(
ILogger<SubtitleEncoder> logger,
- IApplicationPaths appPaths,
IFileSystem fileSystem,
IMediaEncoder mediaEncoder,
IHttpClientFactory httpClientFactory,
IMediaSourceManager mediaSourceManager,
- ISubtitleParser subtitleParser)
+ ISubtitleParser subtitleParser,
+ IPathManager pathManager)
{
_logger = logger;
- _appPaths = appPaths;
_fileSystem = fileSystem;
_mediaEncoder = mediaEncoder;
_httpClientFactory = httpClientFactory;
_mediaSourceManager = mediaSourceManager;
_subtitleParser = subtitleParser;
+ _pathManager = pathManager;
}
- private string SubtitleCachePath => Path.Combine(_appPaths.DataPath, "subtitles");
-
private MemoryStream ConvertSubtitles(
Stream stream,
string inputFormat,
@@ -830,26 +828,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
private string GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleExtension)
{
- if (mediaSource.Protocol == MediaProtocol.File)
- {
- var ticksParam = string.Empty;
-
- var date = _fileSystem.GetLastWriteTimeUtc(mediaSource.Path);
-
- ReadOnlySpan<char> filename = (mediaSource.Path + "_" + subtitleStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Ticks.ToString(CultureInfo.InvariantCulture) + ticksParam).GetMD5() + outputSubtitleExtension;
-
- var prefix = filename.Slice(0, 1);
-
- return Path.Join(SubtitleCachePath, prefix, filename);
- }
- else
- {
- ReadOnlySpan<char> filename = (mediaSource.Path + "_" + subtitleStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5() + outputSubtitleExtension;
-
- var prefix = filename.Slice(0, 1);
-
- return Path.Join(SubtitleCachePath, prefix, filename);
- }
+ return _pathManager.GetSubtitlePath(mediaSource.Id, subtitleStreamIndex, outputSubtitleExtension);
}
/// <inheritdoc />
diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
index c7f9cf2cc..0cda803d6 100644
--- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
+++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
@@ -398,24 +398,19 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
// If subtitles get burned in fonts may need to be extracted from the media file
if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode)
{
- var attachmentPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id);
if (state.MediaSource.VideoType == VideoType.Dvd || state.MediaSource.VideoType == VideoType.BluRay)
{
var concatPath = Path.Join(_appPaths.CachePath, "concat", state.MediaSource.Id + ".concat");
- await _attachmentExtractor.ExtractAllAttachments(concatPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
+ await _attachmentExtractor.ExtractAllAttachments(concatPath, state.MediaSource, cancellationTokenSource.Token).ConfigureAwait(false);
}
else
{
- await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
+ await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, cancellationTokenSource.Token).ConfigureAwait(false);
}
if (state.SubtitleStream.IsExternal && Path.GetExtension(state.SubtitleStream.Path.AsSpan()).Equals(".mks", StringComparison.OrdinalIgnoreCase))
{
- string subtitlePath = state.SubtitleStream.Path;
- string subtitlePathArgument = string.Format(CultureInfo.InvariantCulture, "file:\"{0}\"", subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal));
- string subtitleId = subtitlePath.GetMD5().ToString("N", CultureInfo.InvariantCulture);
-
- await _attachmentExtractor.ExtractAllAttachmentsExternal(subtitlePathArgument, subtitleId, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
+ await _attachmentExtractor.ExtractAllAttachments(state.SubtitleStream.Path, state.MediaSource, cancellationTokenSource.Token).ConfigureAwait(false);
}
}
diff --git a/MediaBrowser.Model/Entities/MediaAttachment.cs b/MediaBrowser.Model/Entities/MediaAttachment.cs
index 34e3eabc9..f8f7ad0f9 100644
--- a/MediaBrowser.Model/Entities/MediaAttachment.cs
+++ b/MediaBrowser.Model/Entities/MediaAttachment.cs
@@ -1,51 +1,49 @@
-#nullable disable
-namespace MediaBrowser.Model.Entities
+namespace MediaBrowser.Model.Entities;
+
+/// <summary>
+/// Class MediaAttachment.
+/// </summary>
+public class MediaAttachment
{
/// <summary>
- /// Class MediaAttachment.
+ /// Gets or sets the codec.
/// </summary>
- public class MediaAttachment
- {
- /// <summary>
- /// Gets or sets the codec.
- /// </summary>
- /// <value>The codec.</value>
- public string Codec { get; set; }
+ /// <value>The codec.</value>
+ public string? Codec { get; set; }
- /// <summary>
- /// Gets or sets the codec tag.
- /// </summary>
- /// <value>The codec tag.</value>
- public string CodecTag { get; set; }
+ /// <summary>
+ /// Gets or sets the codec tag.
+ /// </summary>
+ /// <value>The codec tag.</value>
+ public string? CodecTag { get; set; }
- /// <summary>
- /// Gets or sets the comment.
- /// </summary>
- /// <value>The comment.</value>
- public string Comment { get; set; }
+ /// <summary>
+ /// Gets or sets the comment.
+ /// </summary>
+ /// <value>The comment.</value>
+ public string? Comment { get; set; }
- /// <summary>
- /// Gets or sets the index.
- /// </summary>
- /// <value>The index.</value>
- public int Index { get; set; }
+ /// <summary>
+ /// Gets or sets the index.
+ /// </summary>
+ /// <value>The index.</value>
+ public int Index { get; set; }
- /// <summary>
- /// Gets or sets the filename.
- /// </summary>
- /// <value>The filename.</value>
- public string FileName { get; set; }
+ /// <summary>
+ /// Gets or sets the filename.
+ /// </summary>
+ /// <value>The filename.</value>
+ public string? FileName { get; set; }
- /// <summary>
- /// Gets or sets the MIME type.
- /// </summary>
- /// <value>The MIME type.</value>
- public string MimeType { get; set; }
+ /// <summary>
+ /// Gets or sets the MIME type.
+ /// </summary>
+ /// <value>The MIME type.</value>
+ public string? MimeType { get; set; }
- /// <summary>
- /// Gets or sets the delivery URL.
- /// </summary>
- /// <value>The delivery URL.</value>
- public string DeliveryUrl { get; set; }
- }
+ /// <summary>
+ /// Gets or sets the delivery URL.
+ /// </summary>
+ /// <value>The delivery URL.</value>
+ public string? DeliveryUrl { get; set; }
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/AttachmentStreamInfo.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/AttachmentStreamInfo.cs
index aab3082b3..2f27d9389 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/AttachmentStreamInfo.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/AttachmentStreamInfo.cs
@@ -25,7 +25,7 @@ public class AttachmentStreamInfo
/// <summary>
/// Gets or Sets the codec of the attachment.
/// </summary>
- public required string Codec { get; set; }
+ public string? Codec { get; set; }
/// <summary>
/// Gets or Sets the codec tag of the attachment.
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.Designer.cs
new file mode 100644
index 000000000..d668eea92
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.Designer.cs
@@ -0,0 +1,1657 @@
+// <auto-generated />
+using System;
+using Jellyfin.Database.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ [DbContext(typeof(JellyfinDbContext))]
+ [Migration("20250331182844_FixAttachmentMigration")]
+ partial class FixAttachmentMigration
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "9.0.3");
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DayOfWeek")
+ .HasColumnType("INTEGER");
+
+ b.Property<double>("EndHour")
+ .HasColumnType("REAL");
+
+ b.Property<double>("StartHour")
+ .HasColumnType("REAL");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AccessSchedules");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ItemId")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("LogSeverity")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Overview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ShortOverview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DateCreated");
+
+ b.ToTable("ActivityLogs");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ParentItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ParentItemId");
+
+ b.HasIndex("ParentItemId");
+
+ b.ToTable("AncestorIds");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Index")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Codec")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTag")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Filename")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("MimeType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "Index");
+
+ b.ToTable("AttachmentStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Album")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AlbumArtists")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Artists")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Audio")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("ChannelId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CleanName")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("CommunityRating")
+ .HasColumnType("REAL");
+
+ b.Property<float?>("CriticRating")
+ .HasColumnType("REAL");
+
+ b.Property<string>("CustomRating")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Data")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastMediaAdded")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastRefreshed")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastSaved")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("EndDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("EpisodeTitle")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalSeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalServiceId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExtraIds")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ExtraType")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ForcedSortName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Genres")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("InheritedParentalRatingSubValue")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("InheritedParentalRatingValue")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsInMixedFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsMovie")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsRepeat")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsSeries")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsVirtualItem")
+ .HasColumnType("INTEGER");
+
+ b.Property<float?>("LUFS")
+ .HasColumnType("REAL");
+
+ b.Property<string>("MediaType")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("NormalizationGain")
+ .HasColumnType("REAL");
+
+ b.Property<string>("OfficialRating")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OriginalTitle")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OwnerId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("ParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ParentIndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Path")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PreferredMetadataCountryCode")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PreferredMetadataLanguage")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("PremiereDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PrimaryVersionId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProductionLocations")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ProductionYear")
+ .HasColumnType("INTEGER");
+
+ b.Property<long?>("RunTimeTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("SeasonId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeasonName")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeriesName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeriesPresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ShowId")
+ .HasColumnType("TEXT");
+
+ b.Property<long?>("Size")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortName")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("StartDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Studios")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Tagline")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Tags")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("TopParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("TotalBitrate")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("UnratedType")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ParentId");
+
+ b.HasIndex("Path");
+
+ b.HasIndex("PresentationUniqueKey");
+
+ b.HasIndex("TopParentId", "Id");
+
+ b.HasIndex("Type", "TopParentId", "Id");
+
+ b.HasIndex("Type", "TopParentId", "PresentationUniqueKey");
+
+ b.HasIndex("Type", "TopParentId", "StartDate");
+
+ b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem");
+
+ b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName");
+
+ b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.ToTable("BaseItems");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<byte[]>("Blurhash")
+ .HasColumnType("BLOB");
+
+ b.Property<DateTime>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ImageType")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.Property<int>("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemMetadataFields");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProviderId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProviderValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ProviderId");
+
+ b.HasIndex("ProviderId", "ProviderValue", "ItemId");
+
+ b.ToTable("BaseItemProviders");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.Property<int>("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemTrailerTypes");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ChapterIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("ImageDateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ImagePath")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT");
+
+ b.Property<long>("StartPositionTicks")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "ChapterIndex");
+
+ b.ToTable("Chapters");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Key")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client", "Key")
+ .IsUnique();
+
+ b.ToTable("CustomItemDisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ChromecastVersion")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DashboardTheme")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("EnableNextVideoInfoOverlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ScrollDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowBackdrop")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowSidebar")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipBackwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipForwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TvHome")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client")
+ .IsUnique();
+
+ b.ToTable("DisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DisplayPreferencesId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DisplayPreferencesId");
+
+ b.ToTable("HomeSection");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("ImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("RememberIndexing")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSorting")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortBy")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ViewType")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ItemDisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b =>
+ {
+ b.Property<Guid>("ItemValueId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CleanValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId");
+
+ b.HasIndex("Type", "CleanValue")
+ .IsUnique();
+
+ b.ToTable("ItemValues");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b =>
+ {
+ b.Property<Guid>("ItemValueId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("ItemValuesMap");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<long>("EndTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SegmentProviderId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<long>("StartTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("MediaSegments");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("StreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AspectRatio")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("AverageFrameRate")
+ .HasColumnType("REAL");
+
+ b.Property<int?>("BitDepth")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("BitRate")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("BlPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ChannelLayout")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Channels")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Codec")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTag")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTimeBase")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorPrimaries")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorSpace")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorTransfer")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("DvBlSignalCompatibilityId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvLevel")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvProfile")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvVersionMajor")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvVersionMinor")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("ElPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsAnamorphic")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsAvc")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsDefault")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsExternal")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsForced")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsHearingImpaired")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsInterlaced")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("KeyFrames")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Language")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("Level")
+ .HasColumnType("REAL");
+
+ b.Property<string>("NalLengthSize")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PixelFormat")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Profile")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("RealFrameRate")
+ .HasColumnType("REAL");
+
+ b.Property<int?>("RefFrames")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("Rotation")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RpuPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("SampleRate")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("StreamType")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TimeBase")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Title")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "StreamIndex");
+
+ b.HasIndex("StreamIndex");
+
+ b.HasIndex("StreamType");
+
+ b.HasIndex("StreamIndex", "StreamType");
+
+ b.HasIndex("StreamIndex", "StreamType", "Language");
+
+ b.ToTable("MediaStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PersonType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name");
+
+ b.ToTable("Peoples");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("PeopleId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ListOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Role")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "PeopleId");
+
+ b.HasIndex("PeopleId");
+
+ b.HasIndex("ItemId", "ListOrder");
+
+ b.HasIndex("ItemId", "SortOrder");
+
+ b.ToTable("PeopleBaseItemMap");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Permission_Permissions_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("Value")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Kind")
+ .IsUnique()
+ .HasFilter("[UserId] IS NOT NULL");
+
+ b.ToTable("Permissions");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Preference_Preferences_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Kind")
+ .IsUnique()
+ .HasFilter("[UserId] IS NOT NULL");
+
+ b.ToTable("Preferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AccessToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateLastActivity")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccessToken")
+ .IsUnique();
+
+ b.ToTable("ApiKeys");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AccessToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AppName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AppVersion")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateLastActivity")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceId")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("IsActive")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceId");
+
+ b.HasIndex("AccessToken", "DateLastActivity");
+
+ b.HasIndex("DeviceId", "DateLastActivity");
+
+ b.HasIndex("UserId", "DeviceId");
+
+ b.ToTable("Devices");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("CustomName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceId")
+ .IsUnique();
+
+ b.ToTable("DeviceOptions");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Width")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Bandwidth")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Interval")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ThumbnailCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("TileHeight")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("TileWidth")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "Width");
+
+ b.ToTable("TrickplayInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AudioLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AuthenticationProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CastReceiverId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("DisplayCollectionsView")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("DisplayMissingEpisodes")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableAutoLogin")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableLocalPassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableNextEpisodeAutoPlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableUserPreferenceAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("HidePlayedInLatest")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("InternalId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("InvalidLoginAttemptCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastActivityDate")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("LastLoginDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("LoginAttemptsBeforeLockout")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("MaxActiveSessions")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalRatingScore")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalRatingSubScore")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("MustUpdatePassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Password")
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PasswordResetProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("PlayDefaultAudioTrack")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberAudioSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSubtitleSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RemoteClientBitrateLimit")
+ .HasColumnType("INTEGER");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SubtitleLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SubtitleMode")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SyncPlayAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Username")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("Users");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CustomDataKey")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("AudioStreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsFavorite")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastPlayedDate")
+ .HasColumnType("TEXT");
+
+ b.Property<bool?>("Likes")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("PlayCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("PlaybackPositionTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("Played")
+ .HasColumnType("INTEGER");
+
+ b.Property<double?>("Rating")
+ .HasColumnType("REAL");
+
+ b.Property<int?>("SubtitleStreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "UserId", "CustomDataKey");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("ItemId", "UserId", "IsFavorite");
+
+ b.HasIndex("ItemId", "UserId", "LastPlayedDate");
+
+ b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks");
+
+ b.HasIndex("ItemId", "UserId", "Played");
+
+ b.ToTable("UserData");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("AccessSchedules")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Children")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem")
+ .WithMany("ParentAncestors")
+ .HasForeignKey("ParentItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("ParentItem");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Images")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("LockedFields")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Provider")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("TrailerTypes")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Chapters")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("DisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null)
+ .WithMany("HomeSections")
+ .HasForeignKey("DisplayPreferencesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithOne("ProfileImage")
+ .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("ItemDisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("ItemValues")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue")
+ .WithMany("BaseItemsMap")
+ .HasForeignKey("ItemValueId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("ItemValue");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("MediaStreams")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Peoples")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People")
+ .WithMany("BaseItems")
+ .HasForeignKey("PeopleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("People");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("Permissions")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("Preferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("UserData")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Navigation("Chapters");
+
+ b.Navigation("Children");
+
+ b.Navigation("Images");
+
+ b.Navigation("ItemValues");
+
+ b.Navigation("LockedFields");
+
+ b.Navigation("MediaStreams");
+
+ b.Navigation("ParentAncestors");
+
+ b.Navigation("Peoples");
+
+ b.Navigation("Provider");
+
+ b.Navigation("TrailerTypes");
+
+ b.Navigation("UserData");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.Navigation("HomeSections");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b =>
+ {
+ b.Navigation("BaseItemsMap");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b =>
+ {
+ b.Navigation("BaseItems");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b =>
+ {
+ b.Navigation("AccessSchedules");
+
+ b.Navigation("DisplayPreferences");
+
+ b.Navigation("ItemDisplayPreferences");
+
+ b.Navigation("Permissions");
+
+ b.Navigation("Preferences");
+
+ b.Navigation("ProfileImage");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.cs
new file mode 100644
index 000000000..f921856a2
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.cs
@@ -0,0 +1,36 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ /// <inheritdoc />
+ public partial class FixAttachmentMigration : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterColumn<string>(
+ name: "Codec",
+ table: "AttachmentStreamInfos",
+ type: "TEXT",
+ nullable: true,
+ oldClrType: typeof(string),
+ oldType: "TEXT");
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterColumn<string>(
+ name: "Codec",
+ table: "AttachmentStreamInfos",
+ type: "TEXT",
+ nullable: false,
+ defaultValue: string.Empty,
+ oldClrType: typeof(string),
+ oldType: "TEXT",
+ oldNullable: true);
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
index 0bb4b31b0..08c73217f 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
@@ -120,7 +120,6 @@ namespace Jellyfin.Server.Implementations.Migrations
.HasColumnType("INTEGER");
b.Property<string>("Codec")
- .IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CodecTag")