Răsfoiți Sursa

Cleanup extracted files (#13760)

* Cleanup extracted files

* Pagination and fixes

* Add migration for attachments to MigrateLibraryDb

* Unify attachment handling

* Don't extract again if files were already extracted

* Fix MKS attachment extraction

* Always run full extraction on mks

* Don't try to extract mjpeg streams as attachments

* Fallback to check if attachments were extracted to cache folder

* Fixup
Tim Eisele 1 lună în urmă
părinte
comite
596b635511

+ 38 - 3
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

+ 17 - 0
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;

+ 38 - 2
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"))

+ 1 - 0
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),

+ 66 - 0
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()

+ 299 - 0
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";
+        }
+    }
+}

+ 32 - 0
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);
 }

+ 6 - 2
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}'",

+ 27 - 20
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);
 }

+ 119 - 299
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()
         {

+ 6 - 27
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 />

+ 3 - 8
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);
             }
         }
 

+ 39 - 41
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; }
 }

+ 1 - 1
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.

+ 1657 - 0
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
+        }
+    }
+}

+ 36 - 0
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);
+        }
+    }
+}

+ 0 - 1
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")