Przeglądaj źródła

Enhance Trickplay (#11883)

Tim Eisele 8 miesięcy temu
rodzic
commit
c56dbc1c44

+ 23 - 15
Emby.Server.Implementations/IO/ManagedFileSystem.cs

@@ -149,6 +149,26 @@ namespace Emby.Server.Implementations.IO
             }
         }
 
+        /// <inheritdoc />
+        public void MoveDirectory(string source, string destination)
+        {
+            try
+            {
+                Directory.Move(source, destination);
+            }
+            catch (IOException)
+            {
+                // Cross device move requires a copy
+                Directory.CreateDirectory(destination);
+                foreach (string file in Directory.GetFiles(source))
+                {
+                    File.Copy(file, Path.Combine(destination, Path.GetFileName(file)), true);
+                }
+
+                Directory.Delete(source, true);
+            }
+        }
+
         /// <summary>
         /// Returns a <see cref="FileSystemMetadata"/> object for the specified file or directory path.
         /// </summary>
@@ -327,11 +347,7 @@ namespace Emby.Server.Implementations.IO
             }
         }
 
-        /// <summary>
-        /// Gets the creation time UTC.
-        /// </summary>
-        /// <param name="path">The path.</param>
-        /// <returns>DateTime.</returns>
+        /// <inheritdoc />
         public virtual DateTime GetCreationTimeUtc(string path)
         {
             return GetCreationTimeUtc(GetFileSystemInfo(path));
@@ -368,11 +384,7 @@ namespace Emby.Server.Implementations.IO
             }
         }
 
-        /// <summary>
-        /// Gets the last write time UTC.
-        /// </summary>
-        /// <param name="path">The path.</param>
-        /// <returns>DateTime.</returns>
+        /// <inheritdoc />
         public virtual DateTime GetLastWriteTimeUtc(string path)
         {
             return GetLastWriteTimeUtc(GetFileSystemInfo(path));
@@ -446,11 +458,7 @@ namespace Emby.Server.Implementations.IO
             File.SetAttributes(path, attributes);
         }
 
-        /// <summary>
-        /// Swaps the files.
-        /// </summary>
-        /// <param name="file1">The file1.</param>
-        /// <param name="file2">The file2.</param>
+        /// <inheritdoc />
         public virtual void SwapFiles(string file1, string file2)
         {
             ArgumentException.ThrowIfNullOrEmpty(file1);

+ 3 - 1
Emby.Server.Implementations/Localization/Core/en-US.json

@@ -131,5 +131,7 @@
     "TaskKeyframeExtractor": "Keyframe Extractor",
     "TaskKeyframeExtractorDescription": "Extracts keyframes from video files to create more precise HLS playlists. This task may run for a long time.",
     "TaskCleanCollectionsAndPlaylists": "Clean up collections and playlists",
-    "TaskCleanCollectionsAndPlaylistsDescription": "Removes items from collections and playlists that no longer exist."
+    "TaskCleanCollectionsAndPlaylistsDescription": "Removes items from collections and playlists that no longer exist.",
+    "TaskMoveTrickplayImages": "Migrate Trickplay Image Location",
+    "TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings."
 }

+ 4 - 3
Jellyfin.Api/Controllers/TrickplayController.cs

@@ -80,7 +80,7 @@ public class TrickplayController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status200OK)]
     [ProducesResponseType(StatusCodes.Status404NotFound)]
     [ProducesImageFile]
-    public ActionResult GetTrickplayTileImage(
+    public async Task<ActionResult> GetTrickplayTileImageAsync(
         [FromRoute, Required] Guid itemId,
         [FromRoute, Required] int width,
         [FromRoute, Required] int index,
@@ -92,8 +92,9 @@ public class TrickplayController : BaseJellyfinApiController
             return NotFound();
         }
 
-        var path = _trickplayManager.GetTrickplayTilePath(item, width, index);
-        if (System.IO.File.Exists(path))
+        var saveWithMedia = _libraryManager.GetLibraryOptions(item).SaveTrickplayWithMedia;
+        var path = await _trickplayManager.GetTrickplayTilePathAsync(item, width, index, saveWithMedia).ConfigureAwait(false);
+        if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path))
         {
             Response.Headers.ContentDisposition = "attachment";
             return PhysicalFile(path, MediaTypeNames.Image.Jpeg);

+ 155 - 28
Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs

@@ -76,7 +76,65 @@ public class TrickplayManager : ITrickplayManager
     }
 
     /// <inheritdoc />
-    public async Task RefreshTrickplayDataAsync(Video video, bool replace, CancellationToken cancellationToken)
+    public async Task MoveGeneratedTrickplayDataAsync(Video video, LibraryOptions? libraryOptions, CancellationToken cancellationToken)
+    {
+        var options = _config.Configuration.TrickplayOptions;
+        if (!CanGenerateTrickplay(video, options.Interval))
+        {
+            return;
+        }
+
+        var existingTrickplayResolutions = await GetTrickplayResolutions(video.Id).ConfigureAwait(false);
+        foreach (var resolution in existingTrickplayResolutions)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+            var existingResolution = resolution.Key;
+            var tileWidth = resolution.Value.TileWidth;
+            var tileHeight = resolution.Value.TileHeight;
+            var shouldBeSavedWithMedia = libraryOptions is null ? false : libraryOptions.SaveTrickplayWithMedia;
+            var localOutputDir = GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolution, false);
+            var mediaOutputDir = GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolution, true);
+            if (shouldBeSavedWithMedia && Directory.Exists(localOutputDir))
+            {
+                var localDirFiles = Directory.GetFiles(localOutputDir);
+                var mediaDirExists = Directory.Exists(mediaOutputDir);
+                if (localDirFiles.Length > 0 && ((mediaDirExists && Directory.GetFiles(mediaOutputDir).Length == 0) || !mediaDirExists))
+                {
+                    // Move images from local dir to media dir
+                    MoveContent(localOutputDir, mediaOutputDir);
+                    _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, mediaOutputDir);
+                }
+            }
+            else if (Directory.Exists(mediaOutputDir))
+            {
+                var mediaDirFiles = Directory.GetFiles(mediaOutputDir);
+                var localDirExists = Directory.Exists(localOutputDir);
+                if (mediaDirFiles.Length > 0 && ((localDirExists && Directory.GetFiles(localOutputDir).Length == 0) || !localDirExists))
+                {
+                    // Move images from media dir to local dir
+                    MoveContent(mediaOutputDir, localOutputDir);
+                    _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, localOutputDir);
+                }
+            }
+        }
+    }
+
+    private void MoveContent(string sourceFolder, string destinationFolder)
+    {
+        _fileSystem.MoveDirectory(sourceFolder, destinationFolder);
+        var parent = Directory.GetParent(sourceFolder);
+        if (parent is not null)
+        {
+            var parentContent = Directory.GetDirectories(parent.FullName);
+            if (parentContent.Length == 0)
+            {
+                Directory.Delete(parent.FullName);
+            }
+        }
+    }
+
+    /// <inheritdoc />
+    public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions? libraryOptions, CancellationToken cancellationToken)
     {
         _logger.LogDebug("Trickplay refresh for {ItemId} (replace existing: {Replace})", video.Id, replace);
 
@@ -95,6 +153,7 @@ public class TrickplayManager : ITrickplayManager
                 replace,
                 width,
                 options,
+                libraryOptions,
                 cancellationToken).ConfigureAwait(false);
         }
     }
@@ -104,6 +163,7 @@ public class TrickplayManager : ITrickplayManager
         bool replace,
         int width,
         TrickplayOptions options,
+        LibraryOptions? libraryOptions,
         CancellationToken cancellationToken)
     {
         if (!CanGenerateTrickplay(video, options.Interval))
@@ -144,14 +204,53 @@ public class TrickplayManager : ITrickplayManager
                     actualWidth = 2 * ((int)mediaSource.VideoStream.Width / 2);
                 }
 
-                var outputDir = GetTrickplayDirectory(video, actualWidth);
+                var tileWidth = options.TileWidth;
+                var tileHeight = options.TileHeight;
+                var saveWithMedia = libraryOptions is null ? false : libraryOptions.SaveTrickplayWithMedia;
+                var outputDir = GetTrickplayDirectory(video, tileWidth, tileHeight, actualWidth, saveWithMedia);
 
-                if (!replace && Directory.Exists(outputDir) && (await GetTrickplayResolutions(video.Id).ConfigureAwait(false)).ContainsKey(actualWidth))
+                // Import existing trickplay tiles
+                if (!replace && Directory.Exists(outputDir))
                 {
-                    _logger.LogDebug("Found existing trickplay files for {ItemId}. Exiting", video.Id);
-                    return;
+                    var existingFiles = Directory.GetFiles(outputDir);
+                    if (existingFiles.Length > 0)
+                    {
+                        var hasTrickplayResolution = await HasTrickplayResolutionAsync(video.Id, actualWidth).ConfigureAwait(false);
+                        if (hasTrickplayResolution)
+                        {
+                            _logger.LogDebug("Found existing trickplay files for {ItemId}.", video.Id);
+                            return;
+                        }
+
+                        // Import tiles
+                        var localTrickplayInfo = new TrickplayInfo
+                        {
+                            ItemId = video.Id,
+                            Width = width,
+                            Interval = options.Interval,
+                            TileWidth = options.TileWidth,
+                            TileHeight = options.TileHeight,
+                            ThumbnailCount = existingFiles.Length,
+                            Height = 0,
+                            Bandwidth = 0
+                        };
+
+                        foreach (var tile in existingFiles)
+                        {
+                            var image = _imageEncoder.GetImageSize(tile);
+                            localTrickplayInfo.Height = Math.Max(localTrickplayInfo.Height, image.Height);
+                            var bitrate = (int)Math.Ceiling((decimal)new FileInfo(tile).Length * 8 / localTrickplayInfo.TileWidth / localTrickplayInfo.TileHeight / (localTrickplayInfo.Interval / 1000));
+                            localTrickplayInfo.Bandwidth = Math.Max(localTrickplayInfo.Bandwidth, bitrate);
+                        }
+
+                        await SaveTrickplayInfo(localTrickplayInfo).ConfigureAwait(false);
+
+                        _logger.LogDebug("Imported existing trickplay files for {ItemId}.", video.Id);
+                        return;
+                    }
                 }
 
+                // Generate trickplay tiles
                 var mediaStream = mediaSource.VideoStream;
                 var container = mediaSource.Container;
 
@@ -224,7 +323,7 @@ public class TrickplayManager : ITrickplayManager
     }
 
     /// <inheritdoc />
-    public TrickplayInfo CreateTiles(List<string> images, int width, TrickplayOptions options, string outputDir)
+    public TrickplayInfo CreateTiles(IReadOnlyList<string> images, int width, TrickplayOptions options, string outputDir)
     {
         if (images.Count == 0)
         {
@@ -264,7 +363,7 @@ public class TrickplayManager : ITrickplayManager
             var tilePath = Path.Combine(workDir, $"{i}.jpg");
 
             imageOptions.OutputPath = tilePath;
-            imageOptions.InputPaths = images.GetRange(i * thumbnailsPerTile, Math.Min(thumbnailsPerTile, images.Count - (i * thumbnailsPerTile)));
+            imageOptions.InputPaths = images.Skip(i * thumbnailsPerTile).Take(Math.Min(thumbnailsPerTile, images.Count - (i * thumbnailsPerTile))).ToList();
 
             // Generate image and use returned height for tiles info
             var height = _imageEncoder.CreateTrickplayTile(imageOptions, options.JpegQuality, trickplayInfo.Width, trickplayInfo.Height != 0 ? trickplayInfo.Height : null);
@@ -289,7 +388,7 @@ public class TrickplayManager : ITrickplayManager
             Directory.Delete(outputDir, true);
         }
 
-        MoveDirectory(workDir, outputDir);
+        _fileSystem.MoveDirectory(workDir, outputDir);
 
         return trickplayInfo;
     }
@@ -355,6 +454,24 @@ public class TrickplayManager : ITrickplayManager
         return trickplayResolutions;
     }
 
+    /// <inheritdoc />
+    public async Task<IReadOnlyList<Guid>> GetTrickplayItemsAsync()
+    {
+        List<Guid> trickplayItems;
+
+        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+        await using (dbContext.ConfigureAwait(false))
+        {
+            trickplayItems = await dbContext.TrickplayInfos
+                .AsNoTracking()
+                .Select(i => i.ItemId)
+                .ToListAsync()
+                .ConfigureAwait(false);
+        }
+
+        return trickplayItems;
+    }
+
     /// <inheritdoc />
     public async Task SaveTrickplayInfo(TrickplayInfo info)
     {
@@ -392,9 +509,15 @@ public class TrickplayManager : ITrickplayManager
     }
 
     /// <inheritdoc />
-    public string GetTrickplayTilePath(BaseItem item, int width, int index)
+    public async Task<string> GetTrickplayTilePathAsync(BaseItem item, int width, int index, bool saveWithMedia)
     {
-        return Path.Combine(GetTrickplayDirectory(item, width), index + ".jpg");
+        var trickplayResolutions = await GetTrickplayResolutions(item.Id).ConfigureAwait(false);
+        if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo))
+        {
+            return Path.Combine(GetTrickplayDirectory(item, trickplayInfo.TileWidth, trickplayInfo.TileHeight, width, saveWithMedia), index + ".jpg");
+        }
+
+        return string.Empty;
     }
 
     /// <inheritdoc />
@@ -470,29 +593,33 @@ public class TrickplayManager : ITrickplayManager
         return null;
     }
 
-    private string GetTrickplayDirectory(BaseItem item, int? width = null)
+    /// <inheritdoc />
+    public string GetTrickplayDirectory(BaseItem item, int tileWidth, int tileHeight, int width, bool saveWithMedia = false)
     {
-        var path = Path.Combine(item.GetInternalMetadataPath(), "trickplay");
-
-        return width.HasValue ? Path.Combine(path, width.Value.ToString(CultureInfo.InvariantCulture)) : path;
+        var path = saveWithMedia
+            ? Path.Combine(item.ContainingFolderPath, Path.ChangeExtension(item.Path, ".trickplay"))
+            : Path.Combine(item.GetInternalMetadataPath(), "trickplay");
+
+        var subdirectory = string.Format(
+            CultureInfo.InvariantCulture,
+            "{0} - {1}x{2}",
+            width.ToString(CultureInfo.InvariantCulture),
+            tileWidth.ToString(CultureInfo.InvariantCulture),
+            tileHeight.ToString(CultureInfo.InvariantCulture));
+
+        return Path.Combine(path, subdirectory);
     }
 
-    private void MoveDirectory(string source, string destination)
+    private async Task<bool> HasTrickplayResolutionAsync(Guid itemId, int width)
     {
-        try
-        {
-            Directory.Move(source, destination);
-        }
-        catch (IOException)
+        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+        await using (dbContext.ConfigureAwait(false))
         {
-            // Cross device move requires a copy
-            Directory.CreateDirectory(destination);
-            foreach (string file in Directory.GetFiles(source))
-            {
-                File.Copy(file, Path.Join(destination, Path.GetFileName(file)), true);
-            }
-
-            Directory.Delete(source, true);
+            return await dbContext.TrickplayInfos
+                .AsNoTracking()
+                .Where(i => i.ItemId.Equals(itemId))
+                .AnyAsync(i => i.Width == width)
+                .ConfigureAwait(false);
         }
     }
 }

+ 1 - 0
Jellyfin.Server/Migrations/MigrationRunner.cs

@@ -46,6 +46,7 @@ namespace Jellyfin.Server.Migrations
             typeof(Routines.AddDefaultCastReceivers),
             typeof(Routines.UpdateDefaultPluginRepository),
             typeof(Routines.FixAudioData),
+            typeof(Routines.MoveTrickplayFiles)
         };
 
         /// <summary>

+ 73 - 0
Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs

@@ -0,0 +1,73 @@
+using System;
+using System.Globalization;
+using System.IO;
+using DiscUtils;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Trickplay;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to move trickplay files to the new directory.
+/// </summary>
+public class MoveTrickplayFiles : IMigrationRoutine
+{
+    private readonly ITrickplayManager _trickplayManager;
+    private readonly IFileSystem _fileSystem;
+    private readonly ILibraryManager _libraryManager;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="MoveTrickplayFiles"/> class.
+    /// </summary>
+    /// <param name="trickplayManager">Instance of the <see cref="ITrickplayManager"/> interface.</param>
+    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    public MoveTrickplayFiles(ITrickplayManager trickplayManager, IFileSystem fileSystem, ILibraryManager libraryManager)
+    {
+        _trickplayManager = trickplayManager;
+        _fileSystem = fileSystem;
+        _libraryManager = libraryManager;
+    }
+
+    /// <inheritdoc />
+    public Guid Id => new("4EF123D5-8EFF-4B0B-869D-3AED07A60E1B");
+
+    /// <inheritdoc />
+    public string Name => "MoveTrickplayFiles";
+
+    /// <inheritdoc />
+    public bool PerformOnNewInstall => true;
+
+    /// <inheritdoc />
+    public void Perform()
+    {
+        var trickplayItems = _trickplayManager.GetTrickplayItemsAsync().GetAwaiter().GetResult();
+        foreach (var itemId in trickplayItems)
+        {
+            var resolutions = _trickplayManager.GetTrickplayResolutions(itemId).GetAwaiter().GetResult();
+            var item = _libraryManager.GetItemById(itemId);
+            if (item is null)
+            {
+                continue;
+            }
+
+            foreach (var resolution in resolutions)
+            {
+                var oldPath = GetOldTrickplayDirectory(item, resolution.Key);
+                var newPath = _trickplayManager.GetTrickplayDirectory(item, resolution.Value.TileWidth, resolution.Value.TileHeight, resolution.Value.Width, false);
+                if (_fileSystem.DirectoryExists(oldPath))
+                {
+                    _fileSystem.MoveDirectory(oldPath, newPath);
+                }
+            }
+        }
+    }
+
+    private string GetOldTrickplayDirectory(BaseItem item, int? width = null)
+    {
+        var path = Path.Combine(item.GetInternalMetadataPath(), "trickplay");
+
+        return width.HasValue ? Path.Combine(path, width.Value.ToString(CultureInfo.InvariantCulture)) : path;
+    }
+}

+ 7 - 0
MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs

@@ -29,6 +29,7 @@ namespace MediaBrowser.Controller.Providers
             IsAutomated = copy.IsAutomated;
             ImageRefreshMode = copy.ImageRefreshMode;
             ReplaceAllImages = copy.ReplaceAllImages;
+            RegenerateTrickplay = copy.RegenerateTrickplay;
             ReplaceImages = copy.ReplaceImages;
             SearchResult = copy.SearchResult;
             RemoveOldMetadata = copy.RemoveOldMetadata;
@@ -47,6 +48,12 @@ namespace MediaBrowser.Controller.Providers
         /// </summary>
         public bool ReplaceAllMetadata { get; set; }
 
+        /// <summary>
+        /// Gets or sets a value indicating whether all existing trickplay images should be overwritten
+        /// when paired with MetadataRefreshMode=FullRefresh.
+        /// </summary>
+        public bool RegenerateTrickplay { get; set; }
+
         public MetadataRefreshMode MetadataRefreshMode { get; set; }
 
         public RemoteSearchResult SearchResult { get; set; }

+ 31 - 3
MediaBrowser.Controller/Trickplay/ITrickplayManager.cs

@@ -18,9 +18,10 @@ public interface ITrickplayManager
     /// </summary>
     /// <param name="video">The video.</param>
     /// <param name="replace">Whether or not existing data should be replaced.</param>
+    /// <param name="libraryOptions">The library options.</param>
     /// <param name="cancellationToken">CancellationToken to use for operation.</param>
     /// <returns>Task.</returns>
-    Task RefreshTrickplayDataAsync(Video video, bool replace, CancellationToken cancellationToken);
+    Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions? libraryOptions, CancellationToken cancellationToken);
 
     /// <summary>
     /// Creates trickplay tiles out of individual thumbnails.
@@ -33,7 +34,7 @@ public interface ITrickplayManager
     /// <remarks>
     /// The output directory will be DELETED and replaced if it already exists.
     /// </remarks>
-    TrickplayInfo CreateTiles(List<string> images, int width, TrickplayOptions options, string outputDir);
+    TrickplayInfo CreateTiles(IReadOnlyList<string> images, int width, TrickplayOptions options, string outputDir);
 
     /// <summary>
     /// Get available trickplay resolutions and corresponding info.
@@ -42,6 +43,12 @@ public interface ITrickplayManager
     /// <returns>Map of width resolutions to trickplay tiles info.</returns>
     Task<Dictionary<int, TrickplayInfo>> GetTrickplayResolutions(Guid itemId);
 
+    /// <summary>
+    /// Gets the item ids of all items with trickplay info.
+    /// </summary>
+    /// <returns>The list of item ids that have trickplay info.</returns>
+    public Task<IReadOnlyList<Guid>> GetTrickplayItemsAsync();
+
     /// <summary>
     /// Saves trickplay info.
     /// </summary>
@@ -62,8 +69,29 @@ public interface ITrickplayManager
     /// <param name="item">The item.</param>
     /// <param name="width">The width of a single thumbnail.</param>
     /// <param name="index">The tile's index.</param>
+    /// <param name="saveWithMedia">Whether or not the tile should be saved next to the media file.</param>
+    /// <returns>The absolute path.</returns>
+    Task<string> GetTrickplayTilePathAsync(BaseItem item, int width, int index, bool saveWithMedia);
+
+    /// <summary>
+    /// Gets the path to a trickplay tile image.
+    /// </summary>
+    /// <param name="item">The item.</param>
+    /// <param name="tileWidth">The amount of images for the tile width.</param>
+    /// <param name="tileHeight">The amount of images for the tile height.</param>
+    /// <param name="width">The width of a single thumbnail.</param>
+    /// <param name="saveWithMedia">Whether or not the tile should be saved next to the media file.</param>
     /// <returns>The absolute path.</returns>
-    string GetTrickplayTilePath(BaseItem item, int width, int index);
+    string GetTrickplayDirectory(BaseItem item, int tileWidth, int tileHeight, int width, bool saveWithMedia = false);
+
+    /// <summary>
+    /// Migrates trickplay images between local and media directories.
+    /// </summary>
+    /// <param name="video">The video.</param>
+    /// <param name="libraryOptions">The library options.</param>
+    /// <param name="cancellationToken">CancellationToken to use for operation.</param>
+    /// <returns>Task.</returns>
+    Task MoveGeneratedTrickplayDataAsync(Video video, LibraryOptions? libraryOptions, CancellationToken cancellationToken);
 
     /// <summary>
     /// Gets the trickplay HLS playlist.

+ 4 - 0
MediaBrowser.Model/Configuration/LibraryOptions.cs

@@ -24,6 +24,7 @@ namespace MediaBrowser.Model.Configuration
             EnablePhotos = true;
             SaveSubtitlesWithMedia = true;
             SaveLyricsWithMedia = false;
+            SaveTrickplayWithMedia = false;
             PathInfos = Array.Empty<MediaPathInfo>();
             EnableAutomaticSeriesGrouping = true;
             SeasonZeroDisplayName = "Specials";
@@ -99,6 +100,9 @@ namespace MediaBrowser.Model.Configuration
         [DefaultValue(false)]
         public bool SaveLyricsWithMedia { get; set; }
 
+        [DefaultValue(false)]
+        public bool SaveTrickplayWithMedia { get; set; }
+
         public string[] DisabledLyricFetchers { get; set; }
 
         public string[] LyricFetcherOrder { get; set; }

+ 7 - 0
MediaBrowser.Model/IO/IFileSystem.cs

@@ -33,6 +33,13 @@ namespace MediaBrowser.Model.IO
 
         string MakeAbsolutePath(string folderPath, string filePath);
 
+        /// <summary>
+        /// Moves a directory to a new location.
+        /// </summary>
+        /// <param name="source">Source directory.</param>
+        /// <param name="destination">Destination directory.</param>
+        void MoveDirectory(string source, string destination);
+
         /// <summary>
         /// Returns a <see cref="FileSystemMetadata" /> object for the specified file or directory path.
         /// </summary>

+ 2 - 1
MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs

@@ -98,7 +98,8 @@ public class TrickplayImagesTask : IScheduledTask
 
                 try
                 {
-                    await _trickplayManager.RefreshTrickplayDataAsync(video, false, cancellationToken).ConfigureAwait(false);
+                    var libraryOptions = _libraryManager.GetLibraryOptions(video);
+                    await _trickplayManager.RefreshTrickplayDataAsync(video, false, libraryOptions, cancellationToken).ConfigureAwait(false);
                 }
                 catch (Exception ex)
                 {

+ 110 - 0
MediaBrowser.Providers/Trickplay/TrickplayMoveImagesTask.cs

@@ -0,0 +1,110 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Trickplay;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Providers.Trickplay;
+
+/// <summary>
+/// Class TrickplayMoveImagesTask.
+/// </summary>
+public class TrickplayMoveImagesTask : IScheduledTask
+{
+    private const int QueryPageLimit = 100;
+
+    private readonly ILogger<TrickplayMoveImagesTask> _logger;
+    private readonly ILibraryManager _libraryManager;
+    private readonly ILocalizationManager _localization;
+    private readonly ITrickplayManager _trickplayManager;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="TrickplayMoveImagesTask"/> class.
+    /// </summary>
+    /// <param name="logger">The logger.</param>
+    /// <param name="libraryManager">The library manager.</param>
+    /// <param name="localization">The localization manager.</param>
+    /// <param name="trickplayManager">The trickplay manager.</param>
+    public TrickplayMoveImagesTask(
+        ILogger<TrickplayMoveImagesTask> logger,
+        ILibraryManager libraryManager,
+        ILocalizationManager localization,
+        ITrickplayManager trickplayManager)
+    {
+        _libraryManager = libraryManager;
+        _logger = logger;
+        _localization = localization;
+        _trickplayManager = trickplayManager;
+    }
+
+    /// <inheritdoc />
+    public string Name => _localization.GetLocalizedString("TaskMoveTrickplayImages");
+
+    /// <inheritdoc />
+    public string Description => _localization.GetLocalizedString("TaskMoveTrickplayImagesDescription");
+
+    /// <inheritdoc />
+    public string Key => "MoveTrickplayImages";
+
+    /// <inheritdoc />
+    public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory");
+
+    /// <inheritdoc />
+    public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => [];
+
+    /// <inheritdoc />
+    public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
+    {
+        var trickplayItems = await _trickplayManager.GetTrickplayItemsAsync().ConfigureAwait(false);
+        var query = new InternalItemsQuery
+        {
+            MediaTypes = [MediaType.Video],
+            SourceTypes = [SourceType.Library],
+            IsVirtualItem = false,
+            IsFolder = false,
+            Recursive = true,
+            Limit = QueryPageLimit
+        };
+
+        var numberOfVideos = _libraryManager.GetCount(query);
+
+        var startIndex = 0;
+        var numComplete = 0;
+
+        while (startIndex < numberOfVideos)
+        {
+            query.StartIndex = startIndex;
+            var videos = _libraryManager.GetItemList(query).OfType<Video>().ToList();
+            videos.RemoveAll(i => !trickplayItems.Contains(i.Id));
+
+            foreach (var video in videos)
+            {
+                cancellationToken.ThrowIfCancellationRequested();
+
+                try
+                {
+                    var libraryOptions = _libraryManager.GetLibraryOptions(video);
+                    await _trickplayManager.MoveGeneratedTrickplayDataAsync(video, libraryOptions, cancellationToken).ConfigureAwait(false);
+                }
+                catch (Exception ex)
+                {
+                    _logger.LogError(ex, "Error moving trickplay files for {ItemName}", video.Name);
+                }
+
+                numComplete++;
+                progress.Report(100d * numComplete / numberOfVideos);
+            }
+
+            startIndex += QueryPageLimit;
+        }
+
+        progress.Report(100);
+    }
+}

+ 3 - 3
MediaBrowser.Providers/Trickplay/TrickplayProvider.cs

@@ -99,7 +99,7 @@ public class TrickplayProvider : ICustomMetadataProvider<Episode>,
     {
         var libraryOptions = _libraryManager.GetLibraryOptions(video);
         bool? enableDuringScan = libraryOptions?.ExtractTrickplayImagesDuringLibraryScan;
-        bool replace = options.ReplaceAllImages;
+        bool replace = options.RegenerateTrickplay && options.MetadataRefreshMode > MetadataRefreshMode.Default;
 
         if (!enableDuringScan.GetValueOrDefault(false))
         {
@@ -108,11 +108,11 @@ public class TrickplayProvider : ICustomMetadataProvider<Episode>,
 
         if (_config.Configuration.TrickplayOptions.ScanBehavior == TrickplayScanBehavior.Blocking)
         {
-            await _trickplayManager.RefreshTrickplayDataAsync(video, replace, cancellationToken).ConfigureAwait(false);
+            await _trickplayManager.RefreshTrickplayDataAsync(video, replace, libraryOptions, cancellationToken).ConfigureAwait(false);
         }
         else
         {
-            _ = _trickplayManager.RefreshTrickplayDataAsync(video, replace, cancellationToken).ConfigureAwait(false);
+            _ = _trickplayManager.RefreshTrickplayDataAsync(video, replace, libraryOptions, cancellationToken).ConfigureAwait(false);
         }
 
         // The core doesn't need to trigger any save operations over this