< Summary - Jellyfin

Information
Class: Jellyfin.Server.Implementations.Trickplay.TrickplayManager
Assembly: Jellyfin.Server.Implementations
File(s): /srv/git/jellyfin/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
Line coverage
4%
Covered lines: 21
Uncovered lines: 411
Coverable lines: 432
Total lines: 874
Line coverage: 4.8%
Branch coverage
0%
Covered branches: 0
Total branches: 178
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 3/14/2026 - 12:13:58 AM Line coverage: 13.3% (10/75) Branch coverage: 0% (0/30) Total lines: 6953/31/2026 - 12:14:24 AM Line coverage: 12.6% (10/79) Branch coverage: 0% (0/30) Total lines: 7034/19/2026 - 12:14:27 AM Line coverage: 6% (21/348) Branch coverage: 0% (0/140) Total lines: 7036/8/2026 - 12:16:15 AM Line coverage: 4.8% (21/432) Branch coverage: 0% (0/178) Total lines: 874 3/14/2026 - 12:13:58 AM Line coverage: 13.3% (10/75) Branch coverage: 0% (0/30) Total lines: 6953/31/2026 - 12:14:24 AM Line coverage: 12.6% (10/79) Branch coverage: 0% (0/30) Total lines: 7034/19/2026 - 12:14:27 AM Line coverage: 6% (21/348) Branch coverage: 0% (0/140) Total lines: 7036/8/2026 - 12:16:15 AM Line coverage: 4.8% (21/432) Branch coverage: 0% (0/178) Total lines: 874

Coverage delta

Coverage delta 7 -7

Metrics

File(s)

/srv/git/jellyfin/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Globalization;
 4using System.IO;
 5using System.Linq;
 6using System.Text;
 7using System.Text.RegularExpressions;
 8using System.Threading;
 9using System.Threading.Tasks;
 10using AsyncKeyedLock;
 11using J2N.Collections.Generic.Extensions;
 12using Jellyfin.Database.Implementations;
 13using Jellyfin.Database.Implementations.Entities;
 14using MediaBrowser.Common.Configuration;
 15using MediaBrowser.Controller.Configuration;
 16using MediaBrowser.Controller.Drawing;
 17using MediaBrowser.Controller.Entities;
 18using MediaBrowser.Controller.IO;
 19using MediaBrowser.Controller.MediaEncoding;
 20using MediaBrowser.Controller.Trickplay;
 21using MediaBrowser.Model.Configuration;
 22using MediaBrowser.Model.Entities;
 23using MediaBrowser.Model.IO;
 24using Microsoft.EntityFrameworkCore;
 25using Microsoft.Extensions.Logging;
 26
 27namespace Jellyfin.Server.Implementations.Trickplay;
 28
 29/// <summary>
 30/// ITrickplayManager implementation.
 31/// </summary>
 32public partial class TrickplayManager : ITrickplayManager
 33{
 34    private readonly ILogger<TrickplayManager> _logger;
 35    private readonly IMediaEncoder _mediaEncoder;
 36    private readonly IFileSystem _fileSystem;
 37    private readonly EncodingHelper _encodingHelper;
 38    private readonly IServerConfigurationManager _config;
 39    private readonly IImageEncoder _imageEncoder;
 40    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 41    private readonly IApplicationPaths _appPaths;
 42    private readonly IPathManager _pathManager;
 43
 044    private static readonly AsyncNonKeyedLocker _resourcePool = new(1);
 045    private static readonly string[] _trickplayImgExtensions = [".jpg"];
 46
 47    /// <summary>
 48    /// Initializes a new instance of the <see cref="TrickplayManager"/> class.
 49    /// </summary>
 50    /// <param name="logger">The logger.</param>
 51    /// <param name="mediaEncoder">The media encoder.</param>
 52    /// <param name="fileSystem">The file system.</param>
 53    /// <param name="encodingHelper">The encoding helper.</param>
 54    /// <param name="config">The server configuration manager.</param>
 55    /// <param name="imageEncoder">The image encoder.</param>
 56    /// <param name="dbProvider">The database provider.</param>
 57    /// <param name="appPaths">The application paths.</param>
 58    /// <param name="pathManager">The path manager.</param>
 59    public TrickplayManager(
 60        ILogger<TrickplayManager> logger,
 61        IMediaEncoder mediaEncoder,
 62        IFileSystem fileSystem,
 63        EncodingHelper encodingHelper,
 64        IServerConfigurationManager config,
 65        IImageEncoder imageEncoder,
 66        IDbContextFactory<JellyfinDbContext> dbProvider,
 67        IApplicationPaths appPaths,
 68        IPathManager pathManager)
 69    {
 2170        _logger = logger;
 2171        _mediaEncoder = mediaEncoder;
 2172        _fileSystem = fileSystem;
 2173        _encodingHelper = encodingHelper;
 2174        _config = config;
 2175        _imageEncoder = imageEncoder;
 2176        _dbProvider = dbProvider;
 2177        _appPaths = appPaths;
 2178        _pathManager = pathManager;
 2179    }
 80
 81    /// <inheritdoc />
 82    public async Task MoveGeneratedTrickplayDataAsync(Video video, LibraryOptions libraryOptions, CancellationToken canc
 83    {
 084        var options = _config.Configuration.TrickplayOptions;
 085        if (libraryOptions is null || !libraryOptions.EnableTrickplayImageExtraction || !CanGenerateTrickplay(video, opt
 86        {
 087            return;
 88        }
 89
 090        var existingTrickplayResolutions = await GetTrickplayResolutions(video.Id).ConfigureAwait(false);
 091        foreach (var resolution in existingTrickplayResolutions)
 92        {
 093            cancellationToken.ThrowIfCancellationRequested();
 094            var existingResolution = resolution.Key;
 095            var tileWidth = resolution.Value.TileWidth;
 096            var tileHeight = resolution.Value.TileHeight;
 097            var shouldBeSavedWithMedia = libraryOptions is not null && libraryOptions.SaveTrickplayWithMedia;
 098            var localOutputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolutio
 099            var mediaOutputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolutio
 0100            if (shouldBeSavedWithMedia && localOutputDir.Exists)
 101            {
 0102                var localDirFiles = localOutputDir.EnumerateFiles();
 0103                var mediaDirExists = mediaOutputDir.Exists;
 0104                if (localDirFiles.Any() && ((mediaDirExists && mediaOutputDir.EnumerateFiles().Any()) || !mediaDirExists
 105                {
 106                    // Move images from local dir to media dir
 0107                    MoveContent(localOutputDir.FullName, mediaOutputDir.FullName);
 0108                    _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, mediaOutpu
 109                }
 110            }
 0111            else if (!shouldBeSavedWithMedia && mediaOutputDir.Exists)
 112            {
 0113                var mediaDirFiles = mediaOutputDir.EnumerateFiles();
 0114                var localDirExists = localOutputDir.Exists;
 0115                if (mediaDirFiles.Any() && ((localDirExists && localOutputDir.EnumerateFiles().Any()) || !localDirExists
 116                {
 117                    // Move images from media dir to local dir
 0118                    MoveContent(mediaOutputDir.FullName, localOutputDir.FullName);
 0119                    _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, localOutpu
 120                }
 121            }
 122        }
 0123    }
 124
 125    private void MoveContent(string sourceFolder, string destinationFolder)
 126    {
 0127        _fileSystem.MoveDirectory(sourceFolder, destinationFolder);
 0128        var parent = Directory.GetParent(sourceFolder);
 0129        if (parent is not null)
 130        {
 0131            var parentContent = parent.EnumerateDirectories();
 0132            if (!parentContent.Any())
 133            {
 0134                parent.Delete();
 135            }
 136        }
 0137    }
 138
 139    private async Task DiscoverExistingTrickplayAsync(Video video, bool saveWithMedia, CancellationToken cancellationTok
 140    {
 0141        var options = _config.Configuration.TrickplayOptions;
 0142        var existing = await GetTrickplayResolutions(video.Id).ConfigureAwait(false);
 143
 144        // Remove DB rows whose on-disk folder no longer exists in either possible location.
 145        // Checking both locations avoids dropping rows mid-`SaveTrickplayWithMedia` migration.
 0146        var orphanedWidths = new List<int>();
 0147        foreach (var (width, info) in existing)
 148        {
 0149            cancellationToken.ThrowIfCancellationRequested();
 0150            var localDir = GetTrickplayDirectory(video, info.TileWidth, info.TileHeight, info.Width, false);
 0151            var mediaDir = GetTrickplayDirectory(video, info.TileWidth, info.TileHeight, info.Width, true);
 0152            if (!HasTrickplayTiles(localDir) && !HasTrickplayTiles(mediaDir))
 153            {
 0154                orphanedWidths.Add(width);
 155            }
 156        }
 157
 0158        if (orphanedWidths.Count > 0)
 159        {
 0160            var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 0161            await using (dbContext.ConfigureAwait(false))
 162            {
 0163                await dbContext.TrickplayInfos
 0164                    .Where(i => i.ItemId.Equals(video.Id) && orphanedWidths.Contains(i.Width))
 0165                    .ExecuteDeleteAsync(cancellationToken)
 0166                    .ConfigureAwait(false);
 167            }
 168
 0169            foreach (var width in orphanedWidths)
 170            {
 0171                _logger.LogInformation("Removed orphaned trickplay DB entry width={Width} for {Path}", width, video.Path
 0172                existing.Remove(width);
 173            }
 174        }
 175
 0176        var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia);
 0177        if (!Directory.Exists(trickplayDirectory))
 178        {
 0179            return;
 180        }
 181
 0182        foreach (var subdir in new DirectoryInfo(trickplayDirectory).EnumerateDirectories())
 183        {
 0184            cancellationToken.ThrowIfCancellationRequested();
 185
 0186            var match = TrickplaySubdirRegex().Match(subdir.Name);
 0187            if (!match.Success)
 188            {
 189                continue;
 190            }
 191
 0192            var width = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
 0193            var tileWidth = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
 0194            var tileHeight = int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture);
 195
 0196            if (existing.ContainsKey(width))
 197            {
 198                continue;
 199            }
 200
 0201            var tiles = subdir.GetFiles("*.jpg")
 0202                .OrderBy(t => t.Name, StringComparer.Ordinal)
 0203                .ToArray();
 0204            if (tiles.Length == 0)
 205            {
 206                continue;
 207            }
 208
 209            // The encoder pads the last tile to a full TileWidth*TileHeight grid, so the real
 210            // thumbnail count cannot be read from tile dimensions. Instead, bound the count from
 211            // the tile count and per-tile capacity, then pick an interval consistent with the
 212            // video runtime - snapping to the server's configured interval when it fits.
 0213            var thumbsPerTile = tileWidth * tileHeight;
 0214            var maxThumbs = tiles.Length * thumbsPerTile;
 0215            var minThumbs = tiles.Length > 1 ? ((tiles.Length - 1) * thumbsPerTile) + 1 : 1;
 216
 217            int interval;
 218            int thumbnailCount;
 0219            if (video.RunTimeTicks is long ticks)
 220            {
 0221                var runtimeMs = ticks / TimeSpan.TicksPerMillisecond;
 0222                var minInterval = Math.Max(1000L, (long)Math.Ceiling(runtimeMs / (double)maxThumbs));
 0223                var maxInterval = Math.Max(minInterval, (long)Math.Floor(runtimeMs / (double)minThumbs));
 224
 0225                if (options.Interval >= minInterval && options.Interval <= maxInterval)
 226                {
 0227                    interval = options.Interval;
 228                }
 229                else
 230                {
 0231                    var midpoint = (minInterval + maxInterval) / 2.0;
 0232                    var snapped = (long)Math.Round(midpoint / 1000d) * 1000L;
 0233                    interval = (int)Math.Clamp(snapped, minInterval, maxInterval);
 234                }
 235
 0236                thumbnailCount = Math.Clamp(
 0237                    (int)Math.Round(runtimeMs / (double)interval),
 0238                    minThumbs,
 0239                    maxThumbs);
 240            }
 241            else
 242            {
 0243                interval = Math.Max(1000, options.Interval);
 0244                thumbnailCount = maxThumbs;
 245            }
 246
 0247            var firstSize = _imageEncoder.GetImageSize(tiles[0].FullName);
 0248            var thumbPxH = Math.Max(1, (int)Math.Ceiling((double)firstSize.Height / tileHeight));
 249
 0250            var info = new TrickplayInfo
 0251            {
 0252                ItemId = video.Id,
 0253                Width = width,
 0254                Interval = interval,
 0255                TileWidth = tileWidth,
 0256                TileHeight = tileHeight,
 0257                ThumbnailCount = thumbnailCount,
 0258                Height = thumbPxH,
 0259                Bandwidth = 0,
 0260            };
 261
 0262            foreach (var tile in tiles)
 263            {
 0264                var bitrate = (int)Math.Ceiling((decimal)tile.Length * 8 / tileWidth / tileHeight / (interval / 1000m));
 0265                info.Bandwidth = Math.Max(info.Bandwidth, bitrate);
 266            }
 267
 0268            await SaveTrickplayInfo(info).ConfigureAwait(false);
 0269            _logger.LogInformation(
 0270                "Discovered existing trickplay {Width} - {TileWidth}x{TileHeight} ({ThumbnailCount} thumbnails, {Interva
 0271                width,
 0272                tileWidth,
 0273                tileHeight,
 0274                thumbnailCount,
 0275                interval,
 0276                video.Path);
 277        }
 0278    }
 279
 280    /// <inheritdoc />
 281    public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationTo
 282    {
 0283        var options = _config.Configuration.TrickplayOptions;
 0284        if (!CanGenerateTrickplay(video, options.Interval) || libraryOptions is null)
 285        {
 0286            return;
 287        }
 288
 0289        var saveWithMedia = libraryOptions.SaveTrickplayWithMedia;
 290
 291        // Catalog any existing trickplay folders on disk before any prune/generate. This picks up
 292        // user-placed files even when their (width, tile dims) don't match the server's configured values.
 0293        if (!replace)
 294        {
 0295            await DiscoverExistingTrickplayAsync(video, saveWithMedia, cancellationToken).ConfigureAwait(false);
 296        }
 297
 0298        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 0299        await using (dbContext.ConfigureAwait(false))
 300        {
 0301            var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia);
 302
 303            // When extraction is disabled and files live next to media, treat them as user-managed:
 304            // discovery above already catalogued whatever is on disk, leave it alone.
 0305            if (!libraryOptions.EnableTrickplayImageExtraction && !replace && saveWithMedia)
 306            {
 307                return;
 308            }
 309
 0310            if (!libraryOptions.EnableTrickplayImageExtraction || replace)
 311            {
 312                // Prune existing data
 0313                if (Directory.Exists(trickplayDirectory))
 314                {
 315                    try
 316                    {
 0317                        Directory.Delete(trickplayDirectory, true);
 0318                    }
 0319                    catch (Exception ex)
 320                    {
 0321                        _logger.LogWarning("Unable to clear trickplay directory: {Directory}: {Exception}", trickplayDir
 0322                    }
 323                }
 324
 0325                await dbContext.TrickplayInfos
 0326                        .Where(i => i.ItemId.Equals(video.Id))
 0327                        .ExecuteDeleteAsync(cancellationToken)
 0328                        .ConfigureAwait(false);
 329
 0330                if (!replace)
 331                {
 332                    return;
 333                }
 334            }
 335
 0336            _logger.LogDebug("Trickplay refresh for {ItemId} (replace existing: {Replace})", video.Id, replace);
 337
 0338            if (options.Interval < 1000)
 339            {
 0340                _logger.LogWarning("Trickplay image interval {Interval} is too small, reset to the minimum valid value o
 0341                options.Interval = 1000;
 342            }
 343
 0344            foreach (var width in options.WidthResolutions)
 345            {
 0346                cancellationToken.ThrowIfCancellationRequested();
 0347                await RefreshTrickplayDataInternal(
 0348                    video,
 0349                    replace,
 0350                    width,
 0351                    options,
 0352                    saveWithMedia,
 0353                    cancellationToken).ConfigureAwait(false);
 354            }
 355
 356            // Cleanup old trickplay files
 0357            if (Directory.Exists(trickplayDirectory))
 358            {
 0359                var existingFolders = Directory.GetDirectories(trickplayDirectory);
 0360                var trickplayInfos = await dbContext.TrickplayInfos
 0361                        .AsNoTracking()
 0362                        .Where(i => i.ItemId.Equals(video.Id))
 0363                        .ToListAsync(cancellationToken)
 0364                        .ConfigureAwait(false);
 0365                var expectedFolders = trickplayInfos.Select(i => GetTrickplayDirectory(video, i.TileWidth, i.TileHeight,
 0366                var foldersToRemove = existingFolders.Except(expectedFolders);
 0367                foreach (var folder in foldersToRemove)
 368                {
 369                    try
 370                    {
 0371                        _logger.LogWarning("Pruning trickplay files for {Item}", video.Path);
 0372                        Directory.Delete(folder, true);
 0373                    }
 0374                    catch (Exception ex)
 375                    {
 0376                        _logger.LogWarning("Unable to remove trickplay directory: {Directory}: {Exception}", folder, ex)
 0377                    }
 378                }
 0379            }
 0380        }
 0381    }
 382
 383    private async Task RefreshTrickplayDataInternal(
 384        Video video,
 385        bool replace,
 386        int width,
 387        TrickplayOptions options,
 388        bool saveWithMedia,
 389        CancellationToken cancellationToken)
 390    {
 0391        var imgTempDir = string.Empty;
 392
 0393        using (await _resourcePool.LockAsync(cancellationToken).ConfigureAwait(false))
 394        {
 395            try
 396            {
 397                // Extract images
 398                // Note: Media sources under parent items exist as their own video/item as well. Only use this video str
 0399                var mediaSource = video.GetMediaSources(false).FirstOrDefault(source => Guid.Parse(source.Id).Equals(vid
 400
 0401                if (mediaSource is null)
 402                {
 0403                    _logger.LogDebug("Found no matching media source for item {ItemId}", video.Id);
 0404                    return;
 405                }
 406
 0407                var mediaPath = mediaSource.Path;
 0408                if (!File.Exists(mediaPath))
 409                {
 0410                    _logger.LogWarning("Media not found at {Path} for item {ItemID}", mediaPath, video.Id);
 0411                    return;
 412                }
 413
 414                // We support video backdrops, but we should not generate trickplay images for them
 0415                var parentDirectory = Directory.GetParent(video.Path);
 0416                if (parentDirectory is not null && string.Equals(parentDirectory.Name, "backdrops", StringComparison.Ord
 417                {
 0418                    _logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", video.Path, video.Id);
 0419                    return;
 420                }
 421
 422                // The width has to be even, otherwise a lot of filters will not be able to sample it
 0423                var actualWidth = 2 * (width / 2);
 424
 425                // Force using the video width when the trickplay setting has a too large width
 0426                if (mediaSource.VideoStream.Width is not null && mediaSource.VideoStream.Width < width)
 427                {
 0428                    _logger.LogWarning("Video width {VideoWidth} is smaller than trickplay setting {TrickPlayWidth}, usi
 0429                    actualWidth = 2 * ((int)mediaSource.VideoStream.Width / 2);
 430                }
 431
 0432                var tileWidth = options.TileWidth;
 0433                var tileHeight = options.TileHeight;
 0434                var outputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, actualWidth, saveW
 435
 436                // Import existing trickplay tiles
 0437                if (!replace && outputDir.Exists)
 438                {
 0439                    var existingFiles = outputDir.GetFiles();
 0440                    if (existingFiles.Length > 0)
 441                    {
 0442                        var hasTrickplayResolution = await HasTrickplayResolutionAsync(video.Id, actualWidth).ConfigureA
 0443                        if (hasTrickplayResolution)
 444                        {
 0445                            _logger.LogDebug("Found existing trickplay files for {ItemId}.", video.Id);
 0446                            return;
 447                        }
 448
 449                        // Import tiles
 0450                        var localTrickplayInfo = new TrickplayInfo
 0451                        {
 0452                            ItemId = video.Id,
 0453                            Width = width,
 0454                            Interval = options.Interval,
 0455                            TileWidth = options.TileWidth,
 0456                            TileHeight = options.TileHeight,
 0457                            ThumbnailCount = existingFiles.Length,
 0458                            Height = 0,
 0459                            Bandwidth = 0
 0460                        };
 461
 0462                        foreach (var tile in existingFiles)
 463                        {
 0464                            var image = _imageEncoder.GetImageSize(tile.FullName);
 0465                            localTrickplayInfo.Height = Math.Max(localTrickplayInfo.Height, (int)Math.Ceiling((double)im
 0466                            var bitrate = (int)Math.Ceiling((decimal)tile.Length * 8 / localTrickplayInfo.TileWidth / lo
 0467                            localTrickplayInfo.Bandwidth = Math.Max(localTrickplayInfo.Bandwidth, bitrate);
 468                        }
 469
 0470                        await SaveTrickplayInfo(localTrickplayInfo).ConfigureAwait(false);
 471
 0472                        _logger.LogDebug("Imported existing trickplay files for {ItemId}.", video.Id);
 0473                        return;
 474                    }
 0475                }
 476
 477                // Generate trickplay tiles
 0478                var mediaStream = mediaSource.VideoStream;
 0479                var container = mediaSource.Container;
 480
 0481                _logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", actualWid
 0482                imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated(
 0483                    mediaPath,
 0484                    container,
 0485                    mediaSource,
 0486                    mediaStream,
 0487                    actualWidth,
 0488                    TimeSpan.FromMilliseconds(options.Interval),
 0489                    options.EnableHwAcceleration,
 0490                    options.EnableHwEncoding,
 0491                    options.ProcessThreads,
 0492                    options.Qscale,
 0493                    options.ProcessPriority,
 0494                    options.EnableKeyFrameOnlyExtraction,
 0495                    _encodingHelper,
 0496                    cancellationToken).ConfigureAwait(false);
 497
 0498                if (string.IsNullOrEmpty(imgTempDir) || !Directory.Exists(imgTempDir))
 499                {
 0500                    throw new InvalidOperationException("Null or invalid directory from media encoder.");
 501                }
 502
 0503                var images = _fileSystem.GetFiles(imgTempDir, _trickplayImgExtensions, false, false)
 0504                    .Select(i => i.FullName)
 0505                    .OrderBy(i => i)
 0506                    .ToList();
 507
 508                // Create tiles
 0509                var trickplayInfo = CreateTiles(images, actualWidth, options, outputDir.FullName);
 510
 511                // Save tiles info
 512                try
 513                {
 0514                    if (trickplayInfo is not null)
 515                    {
 0516                        trickplayInfo.ItemId = video.Id;
 0517                        await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false);
 518
 0519                        _logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath);
 520                    }
 521                    else
 522                    {
 0523                        throw new InvalidOperationException("Null trickplay tiles info from CreateTiles.");
 524                    }
 0525                }
 0526                catch (Exception ex)
 527                {
 0528                    _logger.LogError(ex, "Error while saving trickplay tiles info.");
 529
 530                    // Make sure no files stay in metadata folders on failure
 531                    // if tiles info wasn't saved.
 0532                    outputDir.Delete(true);
 0533                }
 0534            }
 0535            catch (Exception ex)
 536            {
 0537                _logger.LogError(ex, "Error creating trickplay images.");
 0538            }
 539            finally
 540            {
 0541                if (!string.IsNullOrEmpty(imgTempDir))
 542                {
 0543                    Directory.Delete(imgTempDir, true);
 544                }
 545            }
 0546        }
 0547    }
 548
 549    /// <inheritdoc />
 550    public TrickplayInfo CreateTiles(IReadOnlyList<string> images, int width, TrickplayOptions options, string outputDir
 551    {
 0552        if (images.Count == 0)
 553        {
 0554            throw new ArgumentException("Can't create trickplay from 0 images.");
 555        }
 556
 0557        var workDir = Path.Combine(_appPaths.TempDirectory, "trickplay_" + Guid.NewGuid().ToString("N"));
 0558        Directory.CreateDirectory(workDir);
 559
 560        try
 561        {
 0562            var trickplayInfo = new TrickplayInfo
 0563            {
 0564                Width = width,
 0565                Interval = options.Interval,
 0566                TileWidth = options.TileWidth,
 0567                TileHeight = options.TileHeight,
 0568                ThumbnailCount = images.Count,
 0569                // Set during image generation
 0570                Height = 0,
 0571                Bandwidth = 0
 0572            };
 573
 574            /*
 575             * Generate trickplay tiles from sets of thumbnails
 576             */
 0577            var imageOptions = new ImageCollageOptions
 0578            {
 0579                Width = trickplayInfo.TileWidth,
 0580                Height = trickplayInfo.TileHeight
 0581            };
 582
 0583            var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight;
 0584            var requiredTiles = (int)Math.Ceiling((double)images.Count / thumbnailsPerTile);
 585
 0586            for (int i = 0; i < requiredTiles; i++)
 587            {
 588                // Set output/input paths
 0589                var tilePath = Path.Combine(workDir, $"{i}.jpg");
 590
 0591                imageOptions.OutputPath = tilePath;
 0592                imageOptions.InputPaths = images.Skip(i * thumbnailsPerTile).Take(Math.Min(thumbnailsPerTile, images.Cou
 593
 594                // Generate image and use returned height for tiles info
 0595                var height = _imageEncoder.CreateTrickplayTile(imageOptions, options.JpegQuality, trickplayInfo.Width, t
 0596                if (trickplayInfo.Height == 0)
 597                {
 0598                    trickplayInfo.Height = height;
 599                }
 600
 601                // Update bitrate
 0602                var bitrate = (int)Math.Ceiling(new FileInfo(tilePath).Length * 8m / trickplayInfo.TileWidth / trickplay
 0603                trickplayInfo.Bandwidth = Math.Max(trickplayInfo.Bandwidth, bitrate);
 604            }
 605
 606            /*
 607             * Move trickplay tiles to output directory
 608             */
 0609            Directory.CreateDirectory(Directory.GetParent(outputDir)!.FullName);
 610
 611            // Replace existing tiles if they already exist
 0612            if (Directory.Exists(outputDir))
 613            {
 0614                Directory.Delete(outputDir, true);
 615            }
 616
 0617            _fileSystem.MoveDirectory(workDir, outputDir);
 618
 0619            return trickplayInfo;
 620        }
 0621        catch
 622        {
 0623            Directory.Delete(workDir, true);
 0624            throw;
 625        }
 0626    }
 627
 628    private bool CanGenerateTrickplay(Video video, int interval)
 629    {
 0630        var videoType = video.VideoType;
 0631        if (videoType == VideoType.Iso || videoType == VideoType.Dvd || videoType == VideoType.BluRay)
 632        {
 0633            return false;
 634        }
 635
 0636        if (video.IsPlaceHolder)
 637        {
 0638            return false;
 639        }
 640
 0641        if (video.IsShortcut)
 642        {
 0643            return false;
 644        }
 645
 0646        if (!video.IsCompleteMedia)
 647        {
 0648            return false;
 649        }
 650
 0651        if (!video.RunTimeTicks.HasValue || video.RunTimeTicks.Value < TimeSpan.FromMilliseconds(interval).Ticks)
 652        {
 0653            return false;
 654        }
 655
 656        // Can't extract images if there are no video streams
 0657        return video.GetMediaStreams().Count > 0;
 658    }
 659
 660    /// <inheritdoc />
 661    public async Task<Dictionary<int, TrickplayInfo>> GetTrickplayResolutions(Guid itemId)
 662    {
 0663        var trickplayResolutions = new Dictionary<int, TrickplayInfo>();
 664
 0665        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 0666        await using (dbContext.ConfigureAwait(false))
 667        {
 0668            var trickplayInfos = await dbContext.TrickplayInfos
 0669                .AsNoTracking()
 0670                .Where(i => i.ItemId.Equals(itemId))
 0671                .ToListAsync()
 0672                .ConfigureAwait(false);
 673
 0674            foreach (var info in trickplayInfos)
 675            {
 0676                trickplayResolutions[info.Width] = info;
 677            }
 678        }
 679
 0680        return trickplayResolutions;
 0681    }
 682
 683    /// <inheritdoc />
 684    public async Task<IReadOnlyList<TrickplayInfo>> GetTrickplayItemsAsync(int limit, int offset)
 685    {
 686        IReadOnlyList<TrickplayInfo> trickplayItems;
 687
 21688        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 21689        await using (dbContext.ConfigureAwait(false))
 690        {
 21691            trickplayItems = await dbContext.TrickplayInfos
 21692                .AsNoTracking()
 21693                .OrderBy(i => i.ItemId)
 21694                .Skip(offset)
 21695                .Take(limit)
 21696                .ToListAsync()
 21697                .ConfigureAwait(false);
 698        }
 699
 21700        return trickplayItems;
 21701    }
 702
 703    /// <inheritdoc />
 704    public async Task SaveTrickplayInfo(TrickplayInfo info)
 705    {
 0706        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 0707        await using (dbContext.ConfigureAwait(false))
 708        {
 0709            var oldInfo = await dbContext.TrickplayInfos.FindAsync(info.ItemId, info.Width).ConfigureAwait(false);
 0710            if (oldInfo is not null)
 711            {
 0712                dbContext.TrickplayInfos.Remove(oldInfo);
 713            }
 714
 0715            dbContext.Add(info);
 716
 0717            await dbContext.SaveChangesAsync().ConfigureAwait(false);
 718        }
 0719    }
 720
 721    /// <inheritdoc />
 722    public async Task DeleteTrickplayDataAsync(Guid itemId, CancellationToken cancellationToken)
 723    {
 0724        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 0725        await dbContext.TrickplayInfos.Where(i => i.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).Configu
 0726    }
 727
 728    /// <inheritdoc />
 729    public async Task<Dictionary<string, Dictionary<int, TrickplayInfo>>> GetTrickplayManifest(BaseItem item)
 730    {
 0731        var trickplayManifest = new Dictionary<string, Dictionary<int, TrickplayInfo>>();
 0732        foreach (var mediaSource in item.GetMediaSources(false))
 733        {
 0734            if (mediaSource.IsRemote || !Guid.TryParse(mediaSource.Id, out var mediaSourceId))
 735            {
 736                continue;
 737            }
 738
 0739            var trickplayResolutions = await GetTrickplayResolutions(mediaSourceId).ConfigureAwait(false);
 740
 0741            if (trickplayResolutions.Count > 0)
 742            {
 0743                trickplayManifest[mediaSource.Id] = trickplayResolutions;
 744            }
 0745        }
 746
 0747        return trickplayManifest;
 0748    }
 749
 750    /// <inheritdoc />
 751    public async Task<string> GetTrickplayTilePathAsync(BaseItem item, int width, int index, bool saveWithMedia)
 752    {
 0753        var trickplayResolutions = await GetTrickplayResolutions(item.Id).ConfigureAwait(false);
 0754        if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo))
 755        {
 0756            return Path.Combine(GetTrickplayDirectory(item, trickplayInfo.TileWidth, trickplayInfo.TileHeight, width, sa
 757        }
 758
 0759        return string.Empty;
 0760    }
 761
 762    /// <inheritdoc />
 763    public async Task<string?> GetHlsPlaylist(Guid itemId, int width, string? apiKey)
 764    {
 0765        var trickplayResolutions = await GetTrickplayResolutions(itemId).ConfigureAwait(false);
 0766        if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo))
 767        {
 0768            var builder = new StringBuilder(128);
 769
 0770            if (trickplayInfo.ThumbnailCount > 0)
 771            {
 772                const string urlFormat = "{0}.jpg?MediaSourceId={1}&ApiKey={2}";
 773                const string decimalFormat = "{0:0.###}";
 774
 0775                var resolution = $"{trickplayInfo.Width}x{trickplayInfo.Height}";
 0776                var layout = $"{trickplayInfo.TileWidth}x{trickplayInfo.TileHeight}";
 0777                var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight;
 0778                var thumbnailDuration = trickplayInfo.Interval / 1000d;
 0779                var infDuration = thumbnailDuration * thumbnailsPerTile;
 0780                var tileCount = (int)Math.Ceiling((decimal)trickplayInfo.ThumbnailCount / thumbnailsPerTile);
 781
 0782                builder
 0783                    .AppendLine("#EXTM3U")
 0784                    .Append("#EXT-X-TARGETDURATION:")
 0785                    .AppendLine(tileCount.ToString(CultureInfo.InvariantCulture))
 0786                    .AppendLine("#EXT-X-VERSION:7")
 0787                    .AppendLine("#EXT-X-MEDIA-SEQUENCE:1")
 0788                    .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD")
 0789                    .AppendLine("#EXT-X-IMAGES-ONLY");
 790
 0791                for (int i = 0; i < tileCount; i++)
 792                {
 793                    // All tiles prior to the last must contain full amount of thumbnails (no black).
 0794                    if (i == tileCount - 1)
 795                    {
 0796                        thumbnailsPerTile = trickplayInfo.ThumbnailCount - (i * thumbnailsPerTile);
 0797                        infDuration = thumbnailDuration * thumbnailsPerTile;
 798                    }
 799
 800                    // EXTINF
 0801                    builder
 0802                        .Append("#EXTINF:")
 0803                        .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, infDuration)
 0804                        .AppendLine(",");
 805
 806                    // EXT-X-TILES
 0807                    builder
 0808                        .Append("#EXT-X-TILES:RESOLUTION=")
 0809                        .Append(resolution)
 0810                        .Append(",LAYOUT=")
 0811                        .Append(layout)
 0812                        .Append(",DURATION=")
 0813                        .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, thumbnailDuration)
 0814                        .AppendLine();
 815
 816                    // URL
 0817                    builder
 0818                        .AppendFormat(
 0819                            CultureInfo.InvariantCulture,
 0820                            urlFormat,
 0821                            i.ToString(CultureInfo.InvariantCulture),
 0822                            itemId.ToString("N"),
 0823                            apiKey)
 0824                        .AppendLine();
 825                }
 826
 0827                builder.AppendLine("#EXT-X-ENDLIST");
 0828                return builder.ToString();
 829            }
 830        }
 831
 0832        return null;
 0833    }
 834
 835    /// <inheritdoc />
 836    public string GetTrickplayDirectory(BaseItem item, int tileWidth, int tileHeight, int width, bool saveWithMedia = fa
 837    {
 0838        var path = _pathManager.GetTrickplayDirectory(item, saveWithMedia);
 0839        var subdirectory = string.Format(
 0840            CultureInfo.InvariantCulture,
 0841            "{0} - {1}x{2}",
 0842            width.ToString(CultureInfo.InvariantCulture),
 0843            tileWidth.ToString(CultureInfo.InvariantCulture),
 0844            tileHeight.ToString(CultureInfo.InvariantCulture));
 845
 0846        return Path.Combine(path, subdirectory);
 847    }
 848
 849    [GeneratedRegex(@"^(\d+) - (\d+)x(\d+)$")]
 850    private static partial Regex TrickplaySubdirRegex();
 851
 852    private static bool HasTrickplayTiles(string directory)
 853    {
 0854        if (!Directory.Exists(directory))
 855        {
 0856            return false;
 857        }
 858
 0859        return new DirectoryInfo(directory).EnumerateFiles("*.jpg").Any();
 860    }
 861
 862    private async Task<bool> HasTrickplayResolutionAsync(Guid itemId, int width)
 863    {
 0864        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 0865        await using (dbContext.ConfigureAwait(false))
 866        {
 0867            return await dbContext.TrickplayInfos
 0868                .AsNoTracking()
 0869                .Where(i => i.ItemId.Equals(itemId))
 0870                .AnyAsync(i => i.Width == width)
 0871                .ConfigureAwait(false);
 872        }
 0873    }
 874}