< 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
6%
Covered lines: 21
Uncovered lines: 327
Coverable lines: 348
Total lines: 703
Line coverage: 6%
Branch coverage
0%
Covered branches: 0
Total branches: 140
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 1/23/2026 - 12:11:06 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: 703 1/23/2026 - 12:11:06 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: 703

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.Threading;
 8using System.Threading.Tasks;
 9using AsyncKeyedLock;
 10using J2N.Collections.Generic.Extensions;
 11using Jellyfin.Database.Implementations;
 12using Jellyfin.Database.Implementations.Entities;
 13using MediaBrowser.Common.Configuration;
 14using MediaBrowser.Controller.Configuration;
 15using MediaBrowser.Controller.Drawing;
 16using MediaBrowser.Controller.Entities;
 17using MediaBrowser.Controller.IO;
 18using MediaBrowser.Controller.MediaEncoding;
 19using MediaBrowser.Controller.Trickplay;
 20using MediaBrowser.Model.Configuration;
 21using MediaBrowser.Model.Entities;
 22using MediaBrowser.Model.IO;
 23using Microsoft.EntityFrameworkCore;
 24using Microsoft.Extensions.Logging;
 25
 26namespace Jellyfin.Server.Implementations.Trickplay;
 27
 28/// <summary>
 29/// ITrickplayManager implementation.
 30/// </summary>
 31public class TrickplayManager : ITrickplayManager
 32{
 33    private readonly ILogger<TrickplayManager> _logger;
 34    private readonly IMediaEncoder _mediaEncoder;
 35    private readonly IFileSystem _fileSystem;
 36    private readonly EncodingHelper _encodingHelper;
 37    private readonly IServerConfigurationManager _config;
 38    private readonly IImageEncoder _imageEncoder;
 39    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 40    private readonly IApplicationPaths _appPaths;
 41    private readonly IPathManager _pathManager;
 42
 043    private static readonly AsyncNonKeyedLocker _resourcePool = new(1);
 044    private static readonly string[] _trickplayImgExtensions = [".jpg"];
 45
 46    /// <summary>
 47    /// Initializes a new instance of the <see cref="TrickplayManager"/> class.
 48    /// </summary>
 49    /// <param name="logger">The logger.</param>
 50    /// <param name="mediaEncoder">The media encoder.</param>
 51    /// <param name="fileSystem">The file system.</param>
 52    /// <param name="encodingHelper">The encoding helper.</param>
 53    /// <param name="config">The server configuration manager.</param>
 54    /// <param name="imageEncoder">The image encoder.</param>
 55    /// <param name="dbProvider">The database provider.</param>
 56    /// <param name="appPaths">The application paths.</param>
 57    /// <param name="pathManager">The path manager.</param>
 58    public TrickplayManager(
 59        ILogger<TrickplayManager> logger,
 60        IMediaEncoder mediaEncoder,
 61        IFileSystem fileSystem,
 62        EncodingHelper encodingHelper,
 63        IServerConfigurationManager config,
 64        IImageEncoder imageEncoder,
 65        IDbContextFactory<JellyfinDbContext> dbProvider,
 66        IApplicationPaths appPaths,
 67        IPathManager pathManager)
 68    {
 2169        _logger = logger;
 2170        _mediaEncoder = mediaEncoder;
 2171        _fileSystem = fileSystem;
 2172        _encodingHelper = encodingHelper;
 2173        _config = config;
 2174        _imageEncoder = imageEncoder;
 2175        _dbProvider = dbProvider;
 2176        _appPaths = appPaths;
 2177        _pathManager = pathManager;
 2178    }
 79
 80    /// <inheritdoc />
 81    public async Task MoveGeneratedTrickplayDataAsync(Video video, LibraryOptions libraryOptions, CancellationToken canc
 82    {
 083        var options = _config.Configuration.TrickplayOptions;
 084        if (libraryOptions is null || !libraryOptions.EnableTrickplayImageExtraction || !CanGenerateTrickplay(video, opt
 85        {
 086            return;
 87        }
 88
 089        var existingTrickplayResolutions = await GetTrickplayResolutions(video.Id).ConfigureAwait(false);
 090        foreach (var resolution in existingTrickplayResolutions)
 91        {
 092            cancellationToken.ThrowIfCancellationRequested();
 093            var existingResolution = resolution.Key;
 094            var tileWidth = resolution.Value.TileWidth;
 095            var tileHeight = resolution.Value.TileHeight;
 096            var shouldBeSavedWithMedia = libraryOptions is not null && libraryOptions.SaveTrickplayWithMedia;
 097            var localOutputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolutio
 098            var mediaOutputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolutio
 099            if (shouldBeSavedWithMedia && localOutputDir.Exists)
 100            {
 0101                var localDirFiles = localOutputDir.EnumerateFiles();
 0102                var mediaDirExists = mediaOutputDir.Exists;
 0103                if (localDirFiles.Any() && ((mediaDirExists && mediaOutputDir.EnumerateFiles().Any()) || !mediaDirExists
 104                {
 105                    // Move images from local dir to media dir
 0106                    MoveContent(localOutputDir.FullName, mediaOutputDir.FullName);
 0107                    _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, mediaOutpu
 108                }
 109            }
 0110            else if (!shouldBeSavedWithMedia && mediaOutputDir.Exists)
 111            {
 0112                var mediaDirFiles = mediaOutputDir.EnumerateFiles();
 0113                var localDirExists = localOutputDir.Exists;
 0114                if (mediaDirFiles.Any() && ((localDirExists && localOutputDir.EnumerateFiles().Any()) || !localDirExists
 115                {
 116                    // Move images from media dir to local dir
 0117                    MoveContent(mediaOutputDir.FullName, localOutputDir.FullName);
 0118                    _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, localOutpu
 119                }
 120            }
 121        }
 0122    }
 123
 124    private void MoveContent(string sourceFolder, string destinationFolder)
 125    {
 0126        _fileSystem.MoveDirectory(sourceFolder, destinationFolder);
 0127        var parent = Directory.GetParent(sourceFolder);
 0128        if (parent is not null)
 129        {
 0130            var parentContent = parent.EnumerateDirectories();
 0131            if (!parentContent.Any())
 132            {
 0133                parent.Delete();
 134            }
 135        }
 0136    }
 137
 138    /// <inheritdoc />
 139    public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationTo
 140    {
 0141        var options = _config.Configuration.TrickplayOptions;
 0142        if (!CanGenerateTrickplay(video, options.Interval) || libraryOptions is null)
 143        {
 0144            return;
 145        }
 146
 0147        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 0148        await using (dbContext.ConfigureAwait(false))
 149        {
 0150            var saveWithMedia = libraryOptions.SaveTrickplayWithMedia;
 0151            var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia);
 0152            if (!libraryOptions.EnableTrickplayImageExtraction || replace)
 153            {
 154                // Prune existing data
 0155                if (Directory.Exists(trickplayDirectory))
 156                {
 157                    try
 158                    {
 0159                        Directory.Delete(trickplayDirectory, true);
 0160                    }
 0161                    catch (Exception ex)
 162                    {
 0163                        _logger.LogWarning("Unable to clear trickplay directory: {Directory}: {Exception}", trickplayDir
 0164                    }
 165                }
 166
 0167                await dbContext.TrickplayInfos
 0168                        .Where(i => i.ItemId.Equals(video.Id))
 0169                        .ExecuteDeleteAsync(cancellationToken)
 0170                        .ConfigureAwait(false);
 171
 0172                if (!replace)
 173                {
 174                    return;
 175                }
 176            }
 177
 0178            _logger.LogDebug("Trickplay refresh for {ItemId} (replace existing: {Replace})", video.Id, replace);
 179
 0180            if (options.Interval < 1000)
 181            {
 0182                _logger.LogWarning("Trickplay image interval {Interval} is too small, reset to the minimum valid value o
 0183                options.Interval = 1000;
 184            }
 185
 0186            foreach (var width in options.WidthResolutions)
 187            {
 0188                cancellationToken.ThrowIfCancellationRequested();
 0189                await RefreshTrickplayDataInternal(
 0190                    video,
 0191                    replace,
 0192                    width,
 0193                    options,
 0194                    saveWithMedia,
 0195                    cancellationToken).ConfigureAwait(false);
 196            }
 197
 198            // Cleanup old trickplay files
 0199            if (Directory.Exists(trickplayDirectory))
 200            {
 0201                var existingFolders = Directory.GetDirectories(trickplayDirectory).ToList();
 0202                var trickplayInfos = await dbContext.TrickplayInfos
 0203                        .AsNoTracking()
 0204                        .Where(i => i.ItemId.Equals(video.Id))
 0205                        .ToListAsync(cancellationToken)
 0206                        .ConfigureAwait(false);
 0207                var expectedFolders = trickplayInfos.Select(i => GetTrickplayDirectory(video, i.TileWidth, i.TileHeight,
 0208                var foldersToRemove = existingFolders.Except(expectedFolders);
 0209                foreach (var folder in foldersToRemove)
 210                {
 211                    try
 212                    {
 0213                        _logger.LogWarning("Pruning trickplay files for {Item}", video.Path);
 0214                        Directory.Delete(folder, true);
 0215                    }
 0216                    catch (Exception ex)
 217                    {
 0218                        _logger.LogWarning("Unable to remove trickplay directory: {Directory}: {Exception}", folder, ex)
 0219                    }
 220                }
 0221            }
 0222        }
 0223    }
 224
 225    private async Task RefreshTrickplayDataInternal(
 226        Video video,
 227        bool replace,
 228        int width,
 229        TrickplayOptions options,
 230        bool saveWithMedia,
 231        CancellationToken cancellationToken)
 232    {
 0233        var imgTempDir = string.Empty;
 234
 0235        using (await _resourcePool.LockAsync(cancellationToken).ConfigureAwait(false))
 236        {
 237            try
 238            {
 239                // Extract images
 240                // Note: Media sources under parent items exist as their own video/item as well. Only use this video str
 0241                var mediaSource = video.GetMediaSources(false).FirstOrDefault(source => Guid.Parse(source.Id).Equals(vid
 242
 0243                if (mediaSource is null)
 244                {
 0245                    _logger.LogDebug("Found no matching media source for item {ItemId}", video.Id);
 0246                    return;
 247                }
 248
 0249                var mediaPath = mediaSource.Path;
 0250                if (!File.Exists(mediaPath))
 251                {
 0252                    _logger.LogWarning("Media not found at {Path} for item {ItemID}", mediaPath, video.Id);
 0253                    return;
 254                }
 255
 256                // We support video backdrops, but we should not generate trickplay images for them
 0257                var parentDirectory = Directory.GetParent(video.Path);
 0258                if (parentDirectory is not null && string.Equals(parentDirectory.Name, "backdrops", StringComparison.Ord
 259                {
 0260                    _logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", video.Path, video.Id);
 0261                    return;
 262                }
 263
 264                // The width has to be even, otherwise a lot of filters will not be able to sample it
 0265                var actualWidth = 2 * (width / 2);
 266
 267                // Force using the video width when the trickplay setting has a too large width
 0268                if (mediaSource.VideoStream.Width is not null && mediaSource.VideoStream.Width < width)
 269                {
 0270                    _logger.LogWarning("Video width {VideoWidth} is smaller than trickplay setting {TrickPlayWidth}, usi
 0271                    actualWidth = 2 * ((int)mediaSource.VideoStream.Width / 2);
 272                }
 273
 0274                var tileWidth = options.TileWidth;
 0275                var tileHeight = options.TileHeight;
 0276                var outputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, actualWidth, saveW
 277
 278                // Import existing trickplay tiles
 0279                if (!replace && outputDir.Exists)
 280                {
 0281                    var existingFiles = outputDir.GetFiles();
 0282                    if (existingFiles.Length > 0)
 283                    {
 0284                        var hasTrickplayResolution = await HasTrickplayResolutionAsync(video.Id, actualWidth).ConfigureA
 0285                        if (hasTrickplayResolution)
 286                        {
 0287                            _logger.LogDebug("Found existing trickplay files for {ItemId}.", video.Id);
 0288                            return;
 289                        }
 290
 291                        // Import tiles
 0292                        var localTrickplayInfo = new TrickplayInfo
 0293                        {
 0294                            ItemId = video.Id,
 0295                            Width = width,
 0296                            Interval = options.Interval,
 0297                            TileWidth = options.TileWidth,
 0298                            TileHeight = options.TileHeight,
 0299                            ThumbnailCount = existingFiles.Length,
 0300                            Height = 0,
 0301                            Bandwidth = 0
 0302                        };
 303
 0304                        foreach (var tile in existingFiles)
 305                        {
 0306                            var image = _imageEncoder.GetImageSize(tile.FullName);
 0307                            localTrickplayInfo.Height = Math.Max(localTrickplayInfo.Height, (int)Math.Ceiling((double)im
 0308                            var bitrate = (int)Math.Ceiling((decimal)tile.Length * 8 / localTrickplayInfo.TileWidth / lo
 0309                            localTrickplayInfo.Bandwidth = Math.Max(localTrickplayInfo.Bandwidth, bitrate);
 310                        }
 311
 0312                        await SaveTrickplayInfo(localTrickplayInfo).ConfigureAwait(false);
 313
 0314                        _logger.LogDebug("Imported existing trickplay files for {ItemId}.", video.Id);
 0315                        return;
 316                    }
 0317                }
 318
 319                // Generate trickplay tiles
 0320                var mediaStream = mediaSource.VideoStream;
 0321                var container = mediaSource.Container;
 322
 0323                _logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", actualWid
 0324                imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated(
 0325                    mediaPath,
 0326                    container,
 0327                    mediaSource,
 0328                    mediaStream,
 0329                    actualWidth,
 0330                    TimeSpan.FromMilliseconds(options.Interval),
 0331                    options.EnableHwAcceleration,
 0332                    options.EnableHwEncoding,
 0333                    options.ProcessThreads,
 0334                    options.Qscale,
 0335                    options.ProcessPriority,
 0336                    options.EnableKeyFrameOnlyExtraction,
 0337                    _encodingHelper,
 0338                    cancellationToken).ConfigureAwait(false);
 339
 0340                if (string.IsNullOrEmpty(imgTempDir) || !Directory.Exists(imgTempDir))
 341                {
 0342                    throw new InvalidOperationException("Null or invalid directory from media encoder.");
 343                }
 344
 0345                var images = _fileSystem.GetFiles(imgTempDir, _trickplayImgExtensions, false, false)
 0346                    .Select(i => i.FullName)
 0347                    .OrderBy(i => i)
 0348                    .ToList();
 349
 350                // Create tiles
 0351                var trickplayInfo = CreateTiles(images, actualWidth, options, outputDir.FullName);
 352
 353                // Save tiles info
 354                try
 355                {
 0356                    if (trickplayInfo is not null)
 357                    {
 0358                        trickplayInfo.ItemId = video.Id;
 0359                        await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false);
 360
 0361                        _logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath);
 362                    }
 363                    else
 364                    {
 0365                        throw new InvalidOperationException("Null trickplay tiles info from CreateTiles.");
 366                    }
 0367                }
 0368                catch (Exception ex)
 369                {
 0370                    _logger.LogError(ex, "Error while saving trickplay tiles info.");
 371
 372                    // Make sure no files stay in metadata folders on failure
 373                    // if tiles info wasn't saved.
 0374                    outputDir.Delete(true);
 0375                }
 0376            }
 0377            catch (Exception ex)
 378            {
 0379                _logger.LogError(ex, "Error creating trickplay images.");
 0380            }
 381            finally
 382            {
 0383                if (!string.IsNullOrEmpty(imgTempDir))
 384                {
 0385                    Directory.Delete(imgTempDir, true);
 386                }
 387            }
 0388        }
 0389    }
 390
 391    /// <inheritdoc />
 392    public TrickplayInfo CreateTiles(IReadOnlyList<string> images, int width, TrickplayOptions options, string outputDir
 393    {
 0394        if (images.Count == 0)
 395        {
 0396            throw new ArgumentException("Can't create trickplay from 0 images.");
 397        }
 398
 0399        var workDir = Path.Combine(_appPaths.TempDirectory, "trickplay_" + Guid.NewGuid().ToString("N"));
 0400        Directory.CreateDirectory(workDir);
 401
 402        try
 403        {
 0404            var trickplayInfo = new TrickplayInfo
 0405            {
 0406                Width = width,
 0407                Interval = options.Interval,
 0408                TileWidth = options.TileWidth,
 0409                TileHeight = options.TileHeight,
 0410                ThumbnailCount = images.Count,
 0411                // Set during image generation
 0412                Height = 0,
 0413                Bandwidth = 0
 0414            };
 415
 416            /*
 417             * Generate trickplay tiles from sets of thumbnails
 418             */
 0419            var imageOptions = new ImageCollageOptions
 0420            {
 0421                Width = trickplayInfo.TileWidth,
 0422                Height = trickplayInfo.TileHeight
 0423            };
 424
 0425            var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight;
 0426            var requiredTiles = (int)Math.Ceiling((double)images.Count / thumbnailsPerTile);
 427
 0428            for (int i = 0; i < requiredTiles; i++)
 429            {
 430                // Set output/input paths
 0431                var tilePath = Path.Combine(workDir, $"{i}.jpg");
 432
 0433                imageOptions.OutputPath = tilePath;
 0434                imageOptions.InputPaths = images.Skip(i * thumbnailsPerTile).Take(Math.Min(thumbnailsPerTile, images.Cou
 435
 436                // Generate image and use returned height for tiles info
 0437                var height = _imageEncoder.CreateTrickplayTile(imageOptions, options.JpegQuality, trickplayInfo.Width, t
 0438                if (trickplayInfo.Height == 0)
 439                {
 0440                    trickplayInfo.Height = height;
 441                }
 442
 443                // Update bitrate
 0444                var bitrate = (int)Math.Ceiling(new FileInfo(tilePath).Length * 8m / trickplayInfo.TileWidth / trickplay
 0445                trickplayInfo.Bandwidth = Math.Max(trickplayInfo.Bandwidth, bitrate);
 446            }
 447
 448            /*
 449             * Move trickplay tiles to output directory
 450             */
 0451            Directory.CreateDirectory(Directory.GetParent(outputDir)!.FullName);
 452
 453            // Replace existing tiles if they already exist
 0454            if (Directory.Exists(outputDir))
 455            {
 0456                Directory.Delete(outputDir, true);
 457            }
 458
 0459            _fileSystem.MoveDirectory(workDir, outputDir);
 460
 0461            return trickplayInfo;
 462        }
 0463        catch
 464        {
 0465            Directory.Delete(workDir, true);
 0466            throw;
 467        }
 0468    }
 469
 470    private bool CanGenerateTrickplay(Video video, int interval)
 471    {
 0472        var videoType = video.VideoType;
 0473        if (videoType == VideoType.Iso || videoType == VideoType.Dvd || videoType == VideoType.BluRay)
 474        {
 0475            return false;
 476        }
 477
 0478        if (video.IsPlaceHolder)
 479        {
 0480            return false;
 481        }
 482
 0483        if (video.IsShortcut)
 484        {
 0485            return false;
 486        }
 487
 0488        if (!video.IsCompleteMedia)
 489        {
 0490            return false;
 491        }
 492
 0493        if (!video.RunTimeTicks.HasValue || video.RunTimeTicks.Value < TimeSpan.FromMilliseconds(interval).Ticks)
 494        {
 0495            return false;
 496        }
 497
 498        // Can't extract images if there are no video streams
 0499        return video.GetMediaStreams().Count > 0;
 500    }
 501
 502    /// <inheritdoc />
 503    public async Task<Dictionary<int, TrickplayInfo>> GetTrickplayResolutions(Guid itemId)
 504    {
 0505        var trickplayResolutions = new Dictionary<int, TrickplayInfo>();
 506
 0507        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 0508        await using (dbContext.ConfigureAwait(false))
 509        {
 0510            var trickplayInfos = await dbContext.TrickplayInfos
 0511                .AsNoTracking()
 0512                .Where(i => i.ItemId.Equals(itemId))
 0513                .ToListAsync()
 0514                .ConfigureAwait(false);
 515
 0516            foreach (var info in trickplayInfos)
 517            {
 0518                trickplayResolutions[info.Width] = info;
 519            }
 520        }
 521
 0522        return trickplayResolutions;
 0523    }
 524
 525    /// <inheritdoc />
 526    public async Task<IReadOnlyList<TrickplayInfo>> GetTrickplayItemsAsync(int limit, int offset)
 527    {
 528        IReadOnlyList<TrickplayInfo> trickplayItems;
 529
 21530        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 21531        await using (dbContext.ConfigureAwait(false))
 532        {
 21533            trickplayItems = await dbContext.TrickplayInfos
 21534                .AsNoTracking()
 21535                .OrderBy(i => i.ItemId)
 21536                .Skip(offset)
 21537                .Take(limit)
 21538                .ToListAsync()
 21539                .ConfigureAwait(false);
 540        }
 541
 21542        return trickplayItems;
 21543    }
 544
 545    /// <inheritdoc />
 546    public async Task SaveTrickplayInfo(TrickplayInfo info)
 547    {
 0548        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 0549        await using (dbContext.ConfigureAwait(false))
 550        {
 0551            var oldInfo = await dbContext.TrickplayInfos.FindAsync(info.ItemId, info.Width).ConfigureAwait(false);
 0552            if (oldInfo is not null)
 553            {
 0554                dbContext.TrickplayInfos.Remove(oldInfo);
 555            }
 556
 0557            dbContext.Add(info);
 558
 0559            await dbContext.SaveChangesAsync().ConfigureAwait(false);
 560        }
 0561    }
 562
 563    /// <inheritdoc />
 564    public async Task DeleteTrickplayDataAsync(Guid itemId, CancellationToken cancellationToken)
 565    {
 0566        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 0567        await dbContext.TrickplayInfos.Where(i => i.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).Configu
 0568    }
 569
 570    /// <inheritdoc />
 571    public async Task<Dictionary<string, Dictionary<int, TrickplayInfo>>> GetTrickplayManifest(BaseItem item)
 572    {
 0573        var trickplayManifest = new Dictionary<string, Dictionary<int, TrickplayInfo>>();
 0574        foreach (var mediaSource in item.GetMediaSources(false))
 575        {
 0576            if (mediaSource.IsRemote || !Guid.TryParse(mediaSource.Id, out var mediaSourceId))
 577            {
 578                continue;
 579            }
 580
 0581            var trickplayResolutions = await GetTrickplayResolutions(mediaSourceId).ConfigureAwait(false);
 582
 0583            if (trickplayResolutions.Count > 0)
 584            {
 0585                trickplayManifest[mediaSource.Id] = trickplayResolutions;
 586            }
 0587        }
 588
 0589        return trickplayManifest;
 0590    }
 591
 592    /// <inheritdoc />
 593    public async Task<string> GetTrickplayTilePathAsync(BaseItem item, int width, int index, bool saveWithMedia)
 594    {
 0595        var trickplayResolutions = await GetTrickplayResolutions(item.Id).ConfigureAwait(false);
 0596        if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo))
 597        {
 0598            return Path.Combine(GetTrickplayDirectory(item, trickplayInfo.TileWidth, trickplayInfo.TileHeight, width, sa
 599        }
 600
 0601        return string.Empty;
 0602    }
 603
 604    /// <inheritdoc />
 605    public async Task<string?> GetHlsPlaylist(Guid itemId, int width, string? apiKey)
 606    {
 0607        var trickplayResolutions = await GetTrickplayResolutions(itemId).ConfigureAwait(false);
 0608        if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo))
 609        {
 0610            var builder = new StringBuilder(128);
 611
 0612            if (trickplayInfo.ThumbnailCount > 0)
 613            {
 614                const string urlFormat = "{0}.jpg?MediaSourceId={1}&ApiKey={2}";
 615                const string decimalFormat = "{0:0.###}";
 616
 0617                var resolution = $"{trickplayInfo.Width}x{trickplayInfo.Height}";
 0618                var layout = $"{trickplayInfo.TileWidth}x{trickplayInfo.TileHeight}";
 0619                var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight;
 0620                var thumbnailDuration = trickplayInfo.Interval / 1000d;
 0621                var infDuration = thumbnailDuration * thumbnailsPerTile;
 0622                var tileCount = (int)Math.Ceiling((decimal)trickplayInfo.ThumbnailCount / thumbnailsPerTile);
 623
 0624                builder
 0625                    .AppendLine("#EXTM3U")
 0626                    .Append("#EXT-X-TARGETDURATION:")
 0627                    .AppendLine(tileCount.ToString(CultureInfo.InvariantCulture))
 0628                    .AppendLine("#EXT-X-VERSION:7")
 0629                    .AppendLine("#EXT-X-MEDIA-SEQUENCE:1")
 0630                    .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD")
 0631                    .AppendLine("#EXT-X-IMAGES-ONLY");
 632
 0633                for (int i = 0; i < tileCount; i++)
 634                {
 635                    // All tiles prior to the last must contain full amount of thumbnails (no black).
 0636                    if (i == tileCount - 1)
 637                    {
 0638                        thumbnailsPerTile = trickplayInfo.ThumbnailCount - (i * thumbnailsPerTile);
 0639                        infDuration = thumbnailDuration * thumbnailsPerTile;
 640                    }
 641
 642                    // EXTINF
 0643                    builder
 0644                        .Append("#EXTINF:")
 0645                        .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, infDuration)
 0646                        .AppendLine(",");
 647
 648                    // EXT-X-TILES
 0649                    builder
 0650                        .Append("#EXT-X-TILES:RESOLUTION=")
 0651                        .Append(resolution)
 0652                        .Append(",LAYOUT=")
 0653                        .Append(layout)
 0654                        .Append(",DURATION=")
 0655                        .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, thumbnailDuration)
 0656                        .AppendLine();
 657
 658                    // URL
 0659                    builder
 0660                        .AppendFormat(
 0661                            CultureInfo.InvariantCulture,
 0662                            urlFormat,
 0663                            i.ToString(CultureInfo.InvariantCulture),
 0664                            itemId.ToString("N"),
 0665                            apiKey)
 0666                        .AppendLine();
 667                }
 668
 0669                builder.AppendLine("#EXT-X-ENDLIST");
 0670                return builder.ToString();
 671            }
 672        }
 673
 0674        return null;
 0675    }
 676
 677    /// <inheritdoc />
 678    public string GetTrickplayDirectory(BaseItem item, int tileWidth, int tileHeight, int width, bool saveWithMedia = fa
 679    {
 0680        var path = _pathManager.GetTrickplayDirectory(item, saveWithMedia);
 0681        var subdirectory = string.Format(
 0682            CultureInfo.InvariantCulture,
 0683            "{0} - {1}x{2}",
 0684            width.ToString(CultureInfo.InvariantCulture),
 0685            tileWidth.ToString(CultureInfo.InvariantCulture),
 0686            tileHeight.ToString(CultureInfo.InvariantCulture));
 687
 0688        return Path.Combine(path, subdirectory);
 689    }
 690
 691    private async Task<bool> HasTrickplayResolutionAsync(Guid itemId, int width)
 692    {
 0693        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 0694        await using (dbContext.ConfigureAwait(false))
 695        {
 0696            return await dbContext.TrickplayInfos
 0697                .AsNoTracking()
 0698                .Where(i => i.ItemId.Equals(itemId))
 0699                .AnyAsync(i => i.Width == width)
 0700                .ConfigureAwait(false);
 701        }
 0702    }
 703}