< 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
5%
Covered lines: 25
Uncovered lines: 412
Coverable lines: 437
Total lines: 886
Line coverage: 5.7%
Branch coverage
0%
Covered branches: 0
Total branches: 180
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/16/2026 - 12:15:55 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: 87410/29/2026 - 12:17:11 AM Line coverage: 5.7% (25/437) Branch coverage: 0% (0/180) Total lines: 886 5/16/2026 - 12:15:55 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: 87410/29/2026 - 12:17:11 AM Line coverage: 5.7% (25/437) Branch coverage: 0% (0/180) Total lines: 886

Coverage delta

Coverage delta 2 -2

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 Jellyfin.Extensions;
 15using MediaBrowser.Common.Configuration;
 16using MediaBrowser.Controller.Configuration;
 17using MediaBrowser.Controller.Drawing;
 18using MediaBrowser.Controller.Entities;
 19using MediaBrowser.Controller.IO;
 20using MediaBrowser.Controller.MediaEncoding;
 21using MediaBrowser.Controller.Trickplay;
 22using MediaBrowser.Model.Configuration;
 23using MediaBrowser.Model.Entities;
 24using MediaBrowser.Model.IO;
 25using Microsoft.EntityFrameworkCore;
 26using Microsoft.Extensions.Logging;
 27
 28namespace Jellyfin.Server.Implementations.Trickplay;
 29
 30/// <summary>
 31/// ITrickplayManager implementation.
 32/// </summary>
 33public partial class TrickplayManager : ITrickplayManager
 34{
 35    private readonly ILogger<TrickplayManager> _logger;
 36    private readonly IMediaEncoder _mediaEncoder;
 37    private readonly IFileSystem _fileSystem;
 38    private readonly EncodingHelper _encodingHelper;
 39    private readonly IServerConfigurationManager _config;
 40    private readonly IImageEncoder _imageEncoder;
 41    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 42    private readonly IApplicationPaths _appPaths;
 43    private readonly IPathManager _pathManager;
 44
 045    private static readonly AsyncNonKeyedLocker _resourcePool = new(1);
 046    private static readonly string[] _trickplayImgExtensions = [".jpg"];
 47
 48    /// <summary>
 49    /// Initializes a new instance of the <see cref="TrickplayManager"/> class.
 50    /// </summary>
 51    /// <param name="logger">The logger.</param>
 52    /// <param name="mediaEncoder">The media encoder.</param>
 53    /// <param name="fileSystem">The file system.</param>
 54    /// <param name="encodingHelper">The encoding helper.</param>
 55    /// <param name="config">The server configuration manager.</param>
 56    /// <param name="imageEncoder">The image encoder.</param>
 57    /// <param name="dbProvider">The database provider.</param>
 58    /// <param name="appPaths">The application paths.</param>
 59    /// <param name="pathManager">The path manager.</param>
 60    public TrickplayManager(
 61        ILogger<TrickplayManager> logger,
 62        IMediaEncoder mediaEncoder,
 63        IFileSystem fileSystem,
 64        EncodingHelper encodingHelper,
 65        IServerConfigurationManager config,
 66        IImageEncoder imageEncoder,
 67        IDbContextFactory<JellyfinDbContext> dbProvider,
 68        IApplicationPaths appPaths,
 69        IPathManager pathManager)
 70    {
 2871        _logger = logger;
 2872        _mediaEncoder = mediaEncoder;
 2873        _fileSystem = fileSystem;
 2874        _encodingHelper = encodingHelper;
 2875        _config = config;
 2876        _imageEncoder = imageEncoder;
 2877        _dbProvider = dbProvider;
 2878        _appPaths = appPaths;
 2879        _pathManager = pathManager;
 2880    }
 81
 82    /// <inheritdoc />
 83    public async Task MoveGeneratedTrickplayDataAsync(Video video, LibraryOptions libraryOptions, CancellationToken canc
 84    {
 085        var options = _config.Configuration.TrickplayOptions;
 086        if (libraryOptions is null || !libraryOptions.EnableTrickplayImageExtraction || !CanGenerateTrickplay(video, opt
 87        {
 088            return;
 89        }
 90
 091        var existingTrickplayResolutions = await GetTrickplayResolutions(video.Id).ConfigureAwait(false);
 092        foreach (var resolution in existingTrickplayResolutions)
 93        {
 094            cancellationToken.ThrowIfCancellationRequested();
 095            var existingResolution = resolution.Key;
 096            var tileWidth = resolution.Value.TileWidth;
 097            var tileHeight = resolution.Value.TileHeight;
 098            var shouldBeSavedWithMedia = libraryOptions is not null && libraryOptions.SaveTrickplayWithMedia;
 099            var localOutputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolutio
 0100            var mediaOutputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolutio
 0101            if (shouldBeSavedWithMedia && localOutputDir.Exists)
 102            {
 0103                var localDirFiles = localOutputDir.EnumerateFiles();
 0104                var mediaDirExists = mediaOutputDir.Exists;
 0105                if (localDirFiles.Any() && ((mediaDirExists && mediaOutputDir.EnumerateFiles().Any()) || !mediaDirExists
 106                {
 107                    // Move images from local dir to media dir
 0108                    MoveContent(localOutputDir.FullName, mediaOutputDir.FullName);
 0109                    _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, mediaOutpu
 110                }
 111            }
 0112            else if (!shouldBeSavedWithMedia && mediaOutputDir.Exists)
 113            {
 0114                var mediaDirFiles = mediaOutputDir.EnumerateFiles();
 0115                var localDirExists = localOutputDir.Exists;
 0116                if (mediaDirFiles.Any() && ((localDirExists && localOutputDir.EnumerateFiles().Any()) || !localDirExists
 117                {
 118                    // Move images from media dir to local dir
 0119                    MoveContent(mediaOutputDir.FullName, localOutputDir.FullName);
 0120                    _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, localOutpu
 121                }
 122            }
 123        }
 0124    }
 125
 126    private void MoveContent(string sourceFolder, string destinationFolder)
 127    {
 0128        _fileSystem.MoveDirectory(sourceFolder, destinationFolder);
 0129        var parent = Directory.GetParent(sourceFolder);
 0130        if (parent is not null)
 131        {
 0132            var parentContent = parent.EnumerateDirectories();
 0133            if (!parentContent.Any())
 134            {
 0135                parent.Delete();
 136            }
 137        }
 0138    }
 139
 140    private async Task DiscoverExistingTrickplayAsync(Video video, bool saveWithMedia, CancellationToken cancellationTok
 141    {
 0142        var options = _config.Configuration.TrickplayOptions;
 0143        var existing = await GetTrickplayResolutions(video.Id).ConfigureAwait(false);
 144
 145        // Remove DB rows whose on-disk folder no longer exists in either possible location.
 146        // Checking both locations avoids dropping rows mid-`SaveTrickplayWithMedia` migration.
 0147        var orphanedWidths = new List<int>();
 0148        foreach (var (width, info) in existing)
 149        {
 0150            cancellationToken.ThrowIfCancellationRequested();
 0151            var localDir = GetTrickplayDirectory(video, info.TileWidth, info.TileHeight, info.Width, false);
 0152            var mediaDir = GetTrickplayDirectory(video, info.TileWidth, info.TileHeight, info.Width, true);
 0153            if (!HasTrickplayTiles(localDir) && !HasTrickplayTiles(mediaDir))
 154            {
 0155                orphanedWidths.Add(width);
 156            }
 157        }
 158
 0159        if (orphanedWidths.Count > 0)
 160        {
 0161            var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 0162            await using (dbContext.ConfigureAwait(false))
 163            {
 0164                await dbContext.TrickplayInfos
 0165                    .Where(i => i.ItemId.Equals(video.Id) && orphanedWidths.Contains(i.Width))
 0166                    .ExecuteDeleteAsync(cancellationToken)
 0167                    .ConfigureAwait(false);
 168            }
 169
 0170            foreach (var width in orphanedWidths)
 171            {
 0172                _logger.LogInformation("Removed orphaned trickplay DB entry width={Width} for {Path}", width, video.Path
 0173                existing.Remove(width);
 174            }
 175        }
 176
 0177        var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia);
 0178        if (!Directory.Exists(trickplayDirectory))
 179        {
 0180            return;
 181        }
 182
 0183        foreach (var subdir in new DirectoryInfo(trickplayDirectory).EnumerateDirectories())
 184        {
 0185            cancellationToken.ThrowIfCancellationRequested();
 186
 0187            var match = TrickplaySubdirRegex().Match(subdir.Name);
 0188            if (!match.Success)
 189            {
 190                continue;
 191            }
 192
 0193            var width = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
 0194            var tileWidth = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
 0195            var tileHeight = int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture);
 196
 0197            if (existing.ContainsKey(width))
 198            {
 199                continue;
 200            }
 201
 0202            var tiles = subdir.GetFiles("*.jpg")
 0203                .OrderBy(t => t.Name, StringComparer.Ordinal)
 0204                .ToArray();
 0205            if (tiles.Length == 0)
 206            {
 207                continue;
 208            }
 209
 210            // The encoder pads the last tile to a full TileWidth*TileHeight grid, so the real
 211            // thumbnail count cannot be read from tile dimensions. Instead, bound the count from
 212            // the tile count and per-tile capacity, then pick an interval consistent with the
 213            // video runtime - snapping to the server's configured interval when it fits.
 0214            var thumbsPerTile = tileWidth * tileHeight;
 0215            var maxThumbs = tiles.Length * thumbsPerTile;
 0216            var minThumbs = tiles.Length > 1 ? ((tiles.Length - 1) * thumbsPerTile) + 1 : 1;
 217
 218            int interval;
 219            int thumbnailCount;
 0220            if (video.RunTimeTicks is long ticks)
 221            {
 0222                var runtimeMs = ticks / TimeSpan.TicksPerMillisecond;
 0223                var minInterval = Math.Max(1000L, (long)Math.Ceiling(runtimeMs / (double)maxThumbs));
 0224                var maxInterval = Math.Max(minInterval, (long)Math.Floor(runtimeMs / (double)minThumbs));
 225
 0226                if (options.Interval >= minInterval && options.Interval <= maxInterval)
 227                {
 0228                    interval = options.Interval;
 229                }
 230                else
 231                {
 0232                    var midpoint = (minInterval + maxInterval) / 2.0;
 0233                    var snapped = (long)Math.Round(midpoint / 1000d) * 1000L;
 0234                    interval = (int)Math.Clamp(snapped, minInterval, maxInterval);
 235                }
 236
 0237                thumbnailCount = Math.Clamp(
 0238                    (int)Math.Round(runtimeMs / (double)interval),
 0239                    minThumbs,
 0240                    maxThumbs);
 241            }
 242            else
 243            {
 0244                interval = Math.Max(1000, options.Interval);
 0245                thumbnailCount = maxThumbs;
 246            }
 247
 0248            var firstSize = _imageEncoder.GetImageSize(tiles[0].FullName);
 0249            var thumbPxH = Math.Max(1, (int)Math.Ceiling((double)firstSize.Height / tileHeight));
 250
 0251            var info = new TrickplayInfo
 0252            {
 0253                ItemId = video.Id,
 0254                Width = width,
 0255                Interval = interval,
 0256                TileWidth = tileWidth,
 0257                TileHeight = tileHeight,
 0258                ThumbnailCount = thumbnailCount,
 0259                Height = thumbPxH,
 0260                Bandwidth = 0,
 0261            };
 262
 0263            foreach (var tile in tiles)
 264            {
 0265                var bitrate = (int)Math.Ceiling((decimal)tile.Length * 8 / tileWidth / tileHeight / (interval / 1000m));
 0266                info.Bandwidth = Math.Max(info.Bandwidth, bitrate);
 267            }
 268
 0269            await SaveTrickplayInfo(info).ConfigureAwait(false);
 0270            _logger.LogInformation(
 0271                "Discovered existing trickplay {Width} - {TileWidth}x{TileHeight} ({ThumbnailCount} thumbnails, {Interva
 0272                width,
 0273                tileWidth,
 0274                tileHeight,
 0275                thumbnailCount,
 0276                interval,
 0277                video.Path);
 278        }
 0279    }
 280
 281    /// <inheritdoc />
 282    public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationTo
 283    {
 0284        var options = _config.Configuration.TrickplayOptions;
 0285        if (!CanGenerateTrickplay(video, options.Interval) || libraryOptions is null)
 286        {
 0287            return;
 288        }
 289
 0290        var saveWithMedia = libraryOptions.SaveTrickplayWithMedia;
 291
 292        // Catalog any existing trickplay folders on disk before any prune/generate. This picks up
 293        // user-placed files even when their (width, tile dims) don't match the server's configured values.
 0294        if (!replace)
 295        {
 0296            await DiscoverExistingTrickplayAsync(video, saveWithMedia, cancellationToken).ConfigureAwait(false);
 297        }
 298
 0299        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 0300        await using (dbContext.ConfigureAwait(false))
 301        {
 0302            var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia);
 303
 304            // When extraction is disabled and files live next to media, treat them as user-managed:
 305            // discovery above already catalogued whatever is on disk, leave it alone.
 0306            if (!libraryOptions.EnableTrickplayImageExtraction && !replace && saveWithMedia)
 307            {
 308                return;
 309            }
 310
 0311            if (!libraryOptions.EnableTrickplayImageExtraction || replace)
 312            {
 313                // Prune existing data
 0314                if (Directory.Exists(trickplayDirectory))
 315                {
 316                    try
 317                    {
 0318                        Directory.Delete(trickplayDirectory, true);
 0319                    }
 0320                    catch (Exception ex)
 321                    {
 0322                        _logger.LogWarning("Unable to clear trickplay directory: {Directory}: {Exception}", trickplayDir
 0323                    }
 324                }
 325
 0326                await dbContext.TrickplayInfos
 0327                        .Where(i => i.ItemId.Equals(video.Id))
 0328                        .ExecuteDeleteAsync(cancellationToken)
 0329                        .ConfigureAwait(false);
 330
 0331                if (!replace)
 332                {
 333                    return;
 334                }
 335            }
 336
 0337            _logger.LogDebug("Trickplay refresh for {ItemId} (replace existing: {Replace})", video.Id, replace);
 338
 0339            if (options.Interval < 1000)
 340            {
 0341                _logger.LogWarning("Trickplay image interval {Interval} is too small, reset to the minimum valid value o
 0342                options.Interval = 1000;
 343            }
 344
 0345            foreach (var width in options.WidthResolutions)
 346            {
 0347                cancellationToken.ThrowIfCancellationRequested();
 0348                await RefreshTrickplayDataInternal(
 0349                    video,
 0350                    replace,
 0351                    width,
 0352                    options,
 0353                    saveWithMedia,
 0354                    cancellationToken).ConfigureAwait(false);
 355            }
 356
 357            // Cleanup old trickplay files
 0358            if (Directory.Exists(trickplayDirectory))
 359            {
 0360                var existingFolders = Directory.GetDirectories(trickplayDirectory);
 0361                var trickplayInfos = await dbContext.TrickplayInfos
 0362                        .AsNoTracking()
 0363                        .Where(i => i.ItemId.Equals(video.Id))
 0364                        .ToListAsync(cancellationToken)
 0365                        .ConfigureAwait(false);
 0366                var expectedFolders = trickplayInfos.Select(i => GetTrickplayDirectory(video, i.TileWidth, i.TileHeight,
 0367                var foldersToRemove = existingFolders.Except(expectedFolders);
 0368                foreach (var folder in foldersToRemove)
 369                {
 370                    try
 371                    {
 0372                        _logger.LogWarning("Pruning trickplay files for {Item}", video.Path);
 0373                        Directory.Delete(folder, true);
 0374                    }
 0375                    catch (Exception ex)
 376                    {
 0377                        _logger.LogWarning("Unable to remove trickplay directory: {Directory}: {Exception}", folder, ex)
 0378                    }
 379                }
 0380            }
 0381        }
 0382    }
 383
 384    private async Task RefreshTrickplayDataInternal(
 385        Video video,
 386        bool replace,
 387        int width,
 388        TrickplayOptions options,
 389        bool saveWithMedia,
 390        CancellationToken cancellationToken)
 391    {
 0392        var imgTempDir = string.Empty;
 393
 0394        using (await _resourcePool.LockAsync(cancellationToken).ConfigureAwait(false))
 395        {
 396            try
 397            {
 398                // Extract images
 399                // Note: Media sources under parent items exist as their own video/item as well. Only use this video str
 0400                var mediaSource = video.GetMediaSources(false).FirstOrDefault(source => Guid.Parse(source.Id).Equals(vid
 401
 0402                if (mediaSource is null)
 403                {
 0404                    _logger.LogDebug("Found no matching media source for item {ItemId}", video.Id);
 0405                    return;
 406                }
 407
 0408                var mediaPath = mediaSource.Path;
 0409                if (!File.Exists(mediaPath))
 410                {
 0411                    _logger.LogWarning("Media not found at {Path} for item {ItemID}", mediaPath, video.Id);
 0412                    return;
 413                }
 414
 415                // We support video backdrops, but we should not generate trickplay images for them
 0416                var parentDirectory = Directory.GetParent(video.Path);
 0417                if (parentDirectory is not null && string.Equals(parentDirectory.Name, "backdrops", StringComparison.Ord
 418                {
 0419                    _logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", video.Path, video.Id);
 0420                    return;
 421                }
 422
 423                // The width has to be even, otherwise a lot of filters will not be able to sample it
 0424                var actualWidth = 2 * (width / 2);
 425
 426                // Force using the video width when the trickplay setting has a too large width
 0427                if (mediaSource.VideoStream.Width is not null && mediaSource.VideoStream.Width < width)
 428                {
 0429                    _logger.LogWarning("Video width {VideoWidth} is smaller than trickplay setting {TrickPlayWidth}, usi
 0430                    actualWidth = 2 * ((int)mediaSource.VideoStream.Width / 2);
 431                }
 432
 0433                var tileWidth = options.TileWidth;
 0434                var tileHeight = options.TileHeight;
 0435                var outputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, actualWidth, saveW
 436
 437                // Import existing trickplay tiles
 0438                if (!replace && outputDir.Exists)
 439                {
 0440                    var existingFiles = outputDir.GetFiles();
 0441                    if (existingFiles.Length > 0)
 442                    {
 0443                        var hasTrickplayResolution = await HasTrickplayResolutionAsync(video.Id, actualWidth).ConfigureA
 0444                        if (hasTrickplayResolution)
 445                        {
 0446                            _logger.LogDebug("Found existing trickplay files for {ItemId}.", video.Id);
 0447                            return;
 448                        }
 449
 450                        // Import tiles
 0451                        var localTrickplayInfo = new TrickplayInfo
 0452                        {
 0453                            ItemId = video.Id,
 0454                            Width = width,
 0455                            Interval = options.Interval,
 0456                            TileWidth = options.TileWidth,
 0457                            TileHeight = options.TileHeight,
 0458                            ThumbnailCount = existingFiles.Length,
 0459                            Height = 0,
 0460                            Bandwidth = 0
 0461                        };
 462
 0463                        foreach (var tile in existingFiles)
 464                        {
 0465                            var image = _imageEncoder.GetImageSize(tile.FullName);
 0466                            localTrickplayInfo.Height = Math.Max(localTrickplayInfo.Height, (int)Math.Ceiling((double)im
 0467                            var bitrate = (int)Math.Ceiling((decimal)tile.Length * 8 / localTrickplayInfo.TileWidth / lo
 0468                            localTrickplayInfo.Bandwidth = Math.Max(localTrickplayInfo.Bandwidth, bitrate);
 469                        }
 470
 0471                        await SaveTrickplayInfo(localTrickplayInfo).ConfigureAwait(false);
 472
 0473                        _logger.LogDebug("Imported existing trickplay files for {ItemId}.", video.Id);
 0474                        return;
 475                    }
 0476                }
 477
 478                // Generate trickplay tiles
 0479                var mediaStream = mediaSource.VideoStream;
 0480                var container = mediaSource.Container;
 481
 482                // Checks for write permission before generating images
 0483                if (saveWithMedia)
 484                {
 0485                    _logger.LogDebug("Verifying write permission in media directory {OutputDir}", outputDir);
 0486                    Directory.CreateDirectory(outputDir.FullName);
 0487                    FileHelper.CreateEmpty(Path.Combine(outputDir.FullName, ".jellyfin-trickplay"));
 488                }
 489
 0490                _logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", actualWid
 0491                imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated(
 0492                    mediaPath,
 0493                    container,
 0494                    mediaSource,
 0495                    mediaStream,
 0496                    actualWidth,
 0497                    TimeSpan.FromMilliseconds(options.Interval),
 0498                    options.EnableHwAcceleration,
 0499                    options.EnableHwEncoding,
 0500                    options.ProcessThreads,
 0501                    options.Qscale,
 0502                    options.ProcessPriority,
 0503                    options.EnableKeyFrameOnlyExtraction,
 0504                    _encodingHelper,
 0505                    cancellationToken).ConfigureAwait(false);
 506
 0507                if (string.IsNullOrEmpty(imgTempDir) || !Directory.Exists(imgTempDir))
 508                {
 0509                    throw new InvalidOperationException("Null or invalid directory from media encoder.");
 510                }
 511
 0512                var images = _fileSystem.GetFiles(imgTempDir, _trickplayImgExtensions, false, false)
 0513                    .Select(i => i.FullName)
 0514                    .OrderBy(i => i)
 0515                    .ToList();
 516
 517                // Create tiles
 0518                var trickplayInfo = CreateTiles(images, actualWidth, options, outputDir.FullName);
 519
 520                // Save tiles info
 521                try
 522                {
 0523                    if (trickplayInfo is not null)
 524                    {
 0525                        trickplayInfo.ItemId = video.Id;
 0526                        await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false);
 527
 0528                        _logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath);
 529                    }
 530                    else
 531                    {
 0532                        throw new InvalidOperationException("Null trickplay tiles info from CreateTiles.");
 533                    }
 0534                }
 0535                catch (Exception ex)
 536                {
 0537                    _logger.LogError(ex, "Error while saving trickplay tiles info.");
 538
 539                    // Make sure no files stay in metadata folders on failure
 540                    // if tiles info wasn't saved.
 0541                    outputDir.Delete(true);
 0542                }
 0543            }
 0544            catch (Exception ex)
 545            {
 0546                _logger.LogError(ex, "Error creating trickplay images.");
 0547            }
 548            finally
 549            {
 0550                if (!string.IsNullOrEmpty(imgTempDir))
 551                {
 0552                    Directory.Delete(imgTempDir, true);
 553                }
 554            }
 0555        }
 0556    }
 557
 558    /// <inheritdoc />
 559    public TrickplayInfo CreateTiles(IReadOnlyList<string> images, int width, TrickplayOptions options, string outputDir
 560    {
 0561        if (images.Count == 0)
 562        {
 0563            throw new ArgumentException("Can't create trickplay from 0 images.");
 564        }
 565
 0566        var workDir = Path.Combine(_appPaths.TempDirectory, "trickplay_" + Guid.NewGuid().ToString("N"));
 0567        Directory.CreateDirectory(workDir);
 568
 569        try
 570        {
 0571            var trickplayInfo = new TrickplayInfo
 0572            {
 0573                Width = width,
 0574                Interval = options.Interval,
 0575                TileWidth = options.TileWidth,
 0576                TileHeight = options.TileHeight,
 0577                ThumbnailCount = images.Count,
 0578                // Set during image generation
 0579                Height = 0,
 0580                Bandwidth = 0
 0581            };
 582
 583            /*
 584             * Generate trickplay tiles from sets of thumbnails
 585             */
 0586            var imageOptions = new ImageCollageOptions
 0587            {
 0588                Width = trickplayInfo.TileWidth,
 0589                Height = trickplayInfo.TileHeight
 0590            };
 591
 0592            var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight;
 0593            var requiredTiles = (int)Math.Ceiling((double)images.Count / thumbnailsPerTile);
 594
 0595            for (int i = 0; i < requiredTiles; i++)
 596            {
 597                // Set output/input paths
 0598                var tilePath = Path.Combine(workDir, $"{i}.jpg");
 599
 0600                imageOptions.OutputPath = tilePath;
 0601                imageOptions.InputPaths = images.Skip(i * thumbnailsPerTile).Take(Math.Min(thumbnailsPerTile, images.Cou
 602
 603                // Generate image and use returned height for tiles info
 0604                var height = _imageEncoder.CreateTrickplayTile(imageOptions, options.JpegQuality, trickplayInfo.Width, t
 0605                if (trickplayInfo.Height == 0)
 606                {
 0607                    trickplayInfo.Height = height;
 608                }
 609
 610                // Update bitrate
 0611                var bitrate = (int)Math.Ceiling(new FileInfo(tilePath).Length * 8m / trickplayInfo.TileWidth / trickplay
 0612                trickplayInfo.Bandwidth = Math.Max(trickplayInfo.Bandwidth, bitrate);
 613            }
 614
 615            /*
 616             * Move trickplay tiles to output directory
 617             */
 0618            Directory.CreateDirectory(Directory.GetParent(outputDir)!.FullName);
 619
 620            // Replace existing tiles if they already exist
 0621            if (Directory.Exists(outputDir))
 622            {
 0623                Directory.Delete(outputDir, true);
 624            }
 625
 0626            _fileSystem.MoveDirectory(workDir, outputDir);
 627
 0628            return trickplayInfo;
 629        }
 0630        catch
 631        {
 0632            Directory.Delete(workDir, true);
 0633            throw;
 634        }
 0635    }
 636
 637    private bool CanGenerateTrickplay(Video video, int interval)
 638    {
 0639        var videoType = video.VideoType;
 0640        if (videoType == VideoType.Iso || videoType == VideoType.Dvd || videoType == VideoType.BluRay)
 641        {
 0642            return false;
 643        }
 644
 0645        if (video.IsPlaceHolder)
 646        {
 0647            return false;
 648        }
 649
 0650        if (video.IsShortcut)
 651        {
 0652            return false;
 653        }
 654
 0655        if (!video.IsCompleteMedia)
 656        {
 0657            return false;
 658        }
 659
 0660        if (!video.RunTimeTicks.HasValue || video.RunTimeTicks.Value < TimeSpan.FromMilliseconds(interval).Ticks)
 661        {
 0662            return false;
 663        }
 664
 665        // Can't extract images if there are no video streams
 0666        return video.GetMediaStreams().Count > 0;
 667    }
 668
 669    /// <inheritdoc />
 670    public async Task<Dictionary<int, TrickplayInfo>> GetTrickplayResolutions(Guid itemId)
 671    {
 0672        var trickplayResolutions = new Dictionary<int, TrickplayInfo>();
 673
 0674        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 0675        await using (dbContext.ConfigureAwait(false))
 676        {
 0677            var trickplayInfos = await dbContext.TrickplayInfos
 0678                .AsNoTracking()
 0679                .Where(i => i.ItemId.Equals(itemId))
 0680                .ToListAsync()
 0681                .ConfigureAwait(false);
 682
 0683            foreach (var info in trickplayInfos)
 684            {
 0685                trickplayResolutions[info.Width] = info;
 686            }
 687        }
 688
 0689        return trickplayResolutions;
 0690    }
 691
 692    /// <inheritdoc />
 693    public async Task<IReadOnlyList<TrickplayInfo>> GetTrickplayItemsAsync(int limit, int offset)
 694    {
 695        IReadOnlyList<TrickplayInfo> trickplayItems;
 696
 27697        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 27698        await using (dbContext.ConfigureAwait(false))
 699        {
 27700            trickplayItems = await dbContext.TrickplayInfos
 27701                .AsNoTracking()
 27702                .OrderBy(i => i.ItemId)
 27703                .Skip(offset)
 27704                .Take(limit)
 27705                .ToListAsync()
 27706                .ConfigureAwait(false);
 707        }
 708
 27709        return trickplayItems;
 27710    }
 711
 712    /// <inheritdoc />
 713    public async Task SaveTrickplayInfo(TrickplayInfo info)
 714    {
 0715        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 0716        await using (dbContext.ConfigureAwait(false))
 717        {
 0718            var oldInfo = await dbContext.TrickplayInfos.FindAsync(info.ItemId, info.Width).ConfigureAwait(false);
 0719            if (oldInfo is not null)
 720            {
 0721                dbContext.TrickplayInfos.Remove(oldInfo);
 722            }
 723
 0724            dbContext.Add(info);
 725
 0726            await dbContext.SaveChangesAsync().ConfigureAwait(false);
 727        }
 0728    }
 729
 730    /// <inheritdoc />
 731    public async Task DeleteTrickplayDataAsync(Guid itemId, CancellationToken cancellationToken)
 732    {
 1733        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 1734        await using (dbContext.ConfigureAwait(false))
 735        {
 1736            await dbContext.TrickplayInfos.Where(i => i.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).Con
 737        }
 1738    }
 739
 740    /// <inheritdoc />
 741    public async Task<Dictionary<string, Dictionary<int, TrickplayInfo>>> GetTrickplayManifest(BaseItem item)
 742    {
 0743        var trickplayManifest = new Dictionary<string, Dictionary<int, TrickplayInfo>>();
 0744        foreach (var mediaSource in item.GetMediaSources(false))
 745        {
 0746            if (mediaSource.IsRemote || !Guid.TryParse(mediaSource.Id, out var mediaSourceId))
 747            {
 748                continue;
 749            }
 750
 0751            var trickplayResolutions = await GetTrickplayResolutions(mediaSourceId).ConfigureAwait(false);
 752
 0753            if (trickplayResolutions.Count > 0)
 754            {
 0755                trickplayManifest[mediaSource.Id] = trickplayResolutions;
 756            }
 0757        }
 758
 0759        return trickplayManifest;
 0760    }
 761
 762    /// <inheritdoc />
 763    public async Task<string> GetTrickplayTilePathAsync(BaseItem item, int width, int index, bool saveWithMedia)
 764    {
 0765        var trickplayResolutions = await GetTrickplayResolutions(item.Id).ConfigureAwait(false);
 0766        if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo))
 767        {
 0768            return Path.Combine(GetTrickplayDirectory(item, trickplayInfo.TileWidth, trickplayInfo.TileHeight, width, sa
 769        }
 770
 0771        return string.Empty;
 0772    }
 773
 774    /// <inheritdoc />
 775    public async Task<string?> GetHlsPlaylist(Guid itemId, int width, string? apiKey)
 776    {
 0777        var trickplayResolutions = await GetTrickplayResolutions(itemId).ConfigureAwait(false);
 0778        if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo))
 779        {
 0780            var builder = new StringBuilder(128);
 781
 0782            if (trickplayInfo.ThumbnailCount > 0)
 783            {
 784                const string urlFormat = "{0}.jpg?MediaSourceId={1}&ApiKey={2}";
 785                const string decimalFormat = "{0:0.###}";
 786
 0787                var resolution = $"{trickplayInfo.Width}x{trickplayInfo.Height}";
 0788                var layout = $"{trickplayInfo.TileWidth}x{trickplayInfo.TileHeight}";
 0789                var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight;
 0790                var thumbnailDuration = trickplayInfo.Interval / 1000d;
 0791                var infDuration = thumbnailDuration * thumbnailsPerTile;
 0792                var tileCount = (int)Math.Ceiling((decimal)trickplayInfo.ThumbnailCount / thumbnailsPerTile);
 793
 0794                builder
 0795                    .AppendLine("#EXTM3U")
 0796                    .Append("#EXT-X-TARGETDURATION:")
 0797                    .AppendLine(tileCount.ToString(CultureInfo.InvariantCulture))
 0798                    .AppendLine("#EXT-X-VERSION:7")
 0799                    .AppendLine("#EXT-X-MEDIA-SEQUENCE:1")
 0800                    .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD")
 0801                    .AppendLine("#EXT-X-IMAGES-ONLY");
 802
 0803                for (int i = 0; i < tileCount; i++)
 804                {
 805                    // All tiles prior to the last must contain full amount of thumbnails (no black).
 0806                    if (i == tileCount - 1)
 807                    {
 0808                        thumbnailsPerTile = trickplayInfo.ThumbnailCount - (i * thumbnailsPerTile);
 0809                        infDuration = thumbnailDuration * thumbnailsPerTile;
 810                    }
 811
 812                    // EXTINF
 0813                    builder
 0814                        .Append("#EXTINF:")
 0815                        .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, infDuration)
 0816                        .AppendLine(",");
 817
 818                    // EXT-X-TILES
 0819                    builder
 0820                        .Append("#EXT-X-TILES:RESOLUTION=")
 0821                        .Append(resolution)
 0822                        .Append(",LAYOUT=")
 0823                        .Append(layout)
 0824                        .Append(",DURATION=")
 0825                        .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, thumbnailDuration)
 0826                        .AppendLine();
 827
 828                    // URL
 0829                    builder
 0830                        .AppendFormat(
 0831                            CultureInfo.InvariantCulture,
 0832                            urlFormat,
 0833                            i.ToString(CultureInfo.InvariantCulture),
 0834                            itemId.ToString("N"),
 0835                            apiKey)
 0836                        .AppendLine();
 837                }
 838
 0839                builder.AppendLine("#EXT-X-ENDLIST");
 0840                return builder.ToString();
 841            }
 842        }
 843
 0844        return null;
 0845    }
 846
 847    /// <inheritdoc />
 848    public string GetTrickplayDirectory(BaseItem item, int tileWidth, int tileHeight, int width, bool saveWithMedia = fa
 849    {
 0850        var path = _pathManager.GetTrickplayDirectory(item, saveWithMedia);
 0851        var subdirectory = string.Format(
 0852            CultureInfo.InvariantCulture,
 0853            "{0} - {1}x{2}",
 0854            width.ToString(CultureInfo.InvariantCulture),
 0855            tileWidth.ToString(CultureInfo.InvariantCulture),
 0856            tileHeight.ToString(CultureInfo.InvariantCulture));
 857
 0858        return Path.Combine(path, subdirectory);
 859    }
 860
 861    [GeneratedRegex(@"^(\d+) - (\d+)x(\d+)$")]
 862    private static partial Regex TrickplaySubdirRegex();
 863
 864    private static bool HasTrickplayTiles(string directory)
 865    {
 0866        if (!Directory.Exists(directory))
 867        {
 0868            return false;
 869        }
 870
 0871        return new DirectoryInfo(directory).EnumerateFiles("*.jpg").Any();
 872    }
 873
 874    private async Task<bool> HasTrickplayResolutionAsync(Guid itemId, int width)
 875    {
 0876        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 0877        await using (dbContext.ConfigureAwait(false))
 878        {
 0879            return await dbContext.TrickplayInfos
 0880                .AsNoTracking()
 0881                .Where(i => i.ItemId.Equals(itemId))
 0882                .AnyAsync(i => i.Width == width)
 0883                .ConfigureAwait(false);
 884        }
 0885    }
 886}