< 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
13%
Covered lines: 10
Uncovered lines: 65
Coverable lines: 75
Total lines: 695
Line coverage: 13.3%
Branch coverage
0%
Covered branches: 0
Total branches: 30
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%11100%
MoveContent(...)0%2040%
CreateTiles(...)0%110100%
CanGenerateTrickplay(...)0%272160%
GetTrickplayDirectory(...)100%210%

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    {
 83        var options = _config.Configuration.TrickplayOptions;
 84        if (libraryOptions is null || !libraryOptions.EnableTrickplayImageExtraction || !CanGenerateTrickplay(video, opt
 85        {
 86            return;
 87        }
 88
 89        var existingTrickplayResolutions = await GetTrickplayResolutions(video.Id).ConfigureAwait(false);
 90        foreach (var resolution in existingTrickplayResolutions)
 91        {
 92            cancellationToken.ThrowIfCancellationRequested();
 93            var existingResolution = resolution.Key;
 94            var tileWidth = resolution.Value.TileWidth;
 95            var tileHeight = resolution.Value.TileHeight;
 96            var shouldBeSavedWithMedia = libraryOptions is not null && libraryOptions.SaveTrickplayWithMedia;
 97            var localOutputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolutio
 98            var mediaOutputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolutio
 99            if (shouldBeSavedWithMedia && localOutputDir.Exists)
 100            {
 101                var localDirFiles = localOutputDir.EnumerateFiles();
 102                var mediaDirExists = mediaOutputDir.Exists;
 103                if (localDirFiles.Any() && ((mediaDirExists && mediaOutputDir.EnumerateFiles().Any()) || !mediaDirExists
 104                {
 105                    // Move images from local dir to media dir
 106                    MoveContent(localOutputDir.FullName, mediaOutputDir.FullName);
 107                    _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, mediaOutpu
 108                }
 109            }
 110            else if (!shouldBeSavedWithMedia && mediaOutputDir.Exists)
 111            {
 112                var mediaDirFiles = mediaOutputDir.EnumerateFiles();
 113                var localDirExists = localOutputDir.Exists;
 114                if (mediaDirFiles.Any() && ((localDirExists && localOutputDir.EnumerateFiles().Any()) || !localDirExists
 115                {
 116                    // Move images from media dir to local dir
 117                    MoveContent(mediaOutputDir.FullName, localOutputDir.FullName);
 118                    _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, localOutpu
 119                }
 120            }
 121        }
 122    }
 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    {
 141        var options = _config.Configuration.TrickplayOptions;
 142        if (!CanGenerateTrickplay(video, options.Interval) || libraryOptions is null)
 143        {
 144            return;
 145        }
 146
 147        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 148        await using (dbContext.ConfigureAwait(false))
 149        {
 150            var saveWithMedia = libraryOptions.SaveTrickplayWithMedia;
 151            var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia);
 152            if (!libraryOptions.EnableTrickplayImageExtraction || replace)
 153            {
 154                // Prune existing data
 155                if (Directory.Exists(trickplayDirectory))
 156                {
 157                    try
 158                    {
 159                        Directory.Delete(trickplayDirectory, true);
 160                    }
 161                    catch (Exception ex)
 162                    {
 163                        _logger.LogWarning("Unable to clear trickplay directory: {Directory}: {Exception}", trickplayDir
 164                    }
 165                }
 166
 167                await dbContext.TrickplayInfos
 168                        .Where(i => i.ItemId.Equals(video.Id))
 169                        .ExecuteDeleteAsync(cancellationToken)
 170                        .ConfigureAwait(false);
 171
 172                if (!replace)
 173                {
 174                    return;
 175                }
 176            }
 177
 178            _logger.LogDebug("Trickplay refresh for {ItemId} (replace existing: {Replace})", video.Id, replace);
 179
 180            if (options.Interval < 1000)
 181            {
 182                _logger.LogWarning("Trickplay image interval {Interval} is too small, reset to the minimum valid value o
 183                options.Interval = 1000;
 184            }
 185
 186            foreach (var width in options.WidthResolutions)
 187            {
 188                cancellationToken.ThrowIfCancellationRequested();
 189                await RefreshTrickplayDataInternal(
 190                    video,
 191                    replace,
 192                    width,
 193                    options,
 194                    saveWithMedia,
 195                    cancellationToken).ConfigureAwait(false);
 196            }
 197
 198            // Cleanup old trickplay files
 199            if (Directory.Exists(trickplayDirectory))
 200            {
 201                var existingFolders = Directory.GetDirectories(trickplayDirectory).ToList();
 202                var trickplayInfos = await dbContext.TrickplayInfos
 203                        .AsNoTracking()
 204                        .Where(i => i.ItemId.Equals(video.Id))
 205                        .ToListAsync(cancellationToken)
 206                        .ConfigureAwait(false);
 207                var expectedFolders = trickplayInfos.Select(i => GetTrickplayDirectory(video, i.TileWidth, i.TileHeight,
 208                var foldersToRemove = existingFolders.Except(expectedFolders);
 209                foreach (var folder in foldersToRemove)
 210                {
 211                    try
 212                    {
 213                        _logger.LogWarning("Pruning trickplay files for {Item}", video.Path);
 214                        Directory.Delete(folder, true);
 215                    }
 216                    catch (Exception ex)
 217                    {
 218                        _logger.LogWarning("Unable to remove trickplay directory: {Directory}: {Exception}", folder, ex)
 219                    }
 220                }
 221            }
 222        }
 223    }
 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    {
 233        var imgTempDir = string.Empty;
 234
 235        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
 241                var mediaSource = video.GetMediaSources(false).FirstOrDefault(source => Guid.Parse(source.Id).Equals(vid
 242
 243                if (mediaSource is null)
 244                {
 245                    _logger.LogDebug("Found no matching media source for item {ItemId}", video.Id);
 246                    return;
 247                }
 248
 249                var mediaPath = mediaSource.Path;
 250                if (!File.Exists(mediaPath))
 251                {
 252                    _logger.LogWarning("Media not found at {Path} for item {ItemID}", mediaPath, video.Id);
 253                    return;
 254                }
 255
 256                // We support video backdrops, but we should not generate trickplay images for them
 257                var parentDirectory = Directory.GetParent(mediaPath);
 258                if (parentDirectory is not null && string.Equals(parentDirectory.Name, "backdrops", StringComparison.Ord
 259                {
 260                    _logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", mediaPath, video.Id);
 261                    return;
 262                }
 263
 264                // The width has to be even, otherwise a lot of filters will not be able to sample it
 265                var actualWidth = 2 * (width / 2);
 266
 267                // Force using the video width when the trickplay setting has a too large width
 268                if (mediaSource.VideoStream.Width is not null && mediaSource.VideoStream.Width < width)
 269                {
 270                    _logger.LogWarning("Video width {VideoWidth} is smaller than trickplay setting {TrickPlayWidth}, usi
 271                    actualWidth = 2 * ((int)mediaSource.VideoStream.Width / 2);
 272                }
 273
 274                var tileWidth = options.TileWidth;
 275                var tileHeight = options.TileHeight;
 276                var outputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, actualWidth, saveW
 277
 278                // Import existing trickplay tiles
 279                if (!replace && outputDir.Exists)
 280                {
 281                    var existingFiles = outputDir.GetFiles();
 282                    if (existingFiles.Length > 0)
 283                    {
 284                        var hasTrickplayResolution = await HasTrickplayResolutionAsync(video.Id, actualWidth).ConfigureA
 285                        if (hasTrickplayResolution)
 286                        {
 287                            _logger.LogDebug("Found existing trickplay files for {ItemId}.", video.Id);
 288                            return;
 289                        }
 290
 291                        // Import tiles
 292                        var localTrickplayInfo = new TrickplayInfo
 293                        {
 294                            ItemId = video.Id,
 295                            Width = width,
 296                            Interval = options.Interval,
 297                            TileWidth = options.TileWidth,
 298                            TileHeight = options.TileHeight,
 299                            ThumbnailCount = existingFiles.Length,
 300                            Height = 0,
 301                            Bandwidth = 0
 302                        };
 303
 304                        foreach (var tile in existingFiles)
 305                        {
 306                            var image = _imageEncoder.GetImageSize(tile.FullName);
 307                            localTrickplayInfo.Height = Math.Max(localTrickplayInfo.Height, (int)Math.Ceiling((double)im
 308                            var bitrate = (int)Math.Ceiling((decimal)tile.Length * 8 / localTrickplayInfo.TileWidth / lo
 309                            localTrickplayInfo.Bandwidth = Math.Max(localTrickplayInfo.Bandwidth, bitrate);
 310                        }
 311
 312                        await SaveTrickplayInfo(localTrickplayInfo).ConfigureAwait(false);
 313
 314                        _logger.LogDebug("Imported existing trickplay files for {ItemId}.", video.Id);
 315                        return;
 316                    }
 317                }
 318
 319                // Generate trickplay tiles
 320                var mediaStream = mediaSource.VideoStream;
 321                var container = mediaSource.Container;
 322
 323                _logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", actualWid
 324                imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated(
 325                    mediaPath,
 326                    container,
 327                    mediaSource,
 328                    mediaStream,
 329                    actualWidth,
 330                    TimeSpan.FromMilliseconds(options.Interval),
 331                    options.EnableHwAcceleration,
 332                    options.EnableHwEncoding,
 333                    options.ProcessThreads,
 334                    options.Qscale,
 335                    options.ProcessPriority,
 336                    options.EnableKeyFrameOnlyExtraction,
 337                    _encodingHelper,
 338                    cancellationToken).ConfigureAwait(false);
 339
 340                if (string.IsNullOrEmpty(imgTempDir) || !Directory.Exists(imgTempDir))
 341                {
 342                    throw new InvalidOperationException("Null or invalid directory from media encoder.");
 343                }
 344
 345                var images = _fileSystem.GetFiles(imgTempDir, _trickplayImgExtensions, false, false)
 346                    .Select(i => i.FullName)
 347                    .OrderBy(i => i)
 348                    .ToList();
 349
 350                // Create tiles
 351                var trickplayInfo = CreateTiles(images, actualWidth, options, outputDir.FullName);
 352
 353                // Save tiles info
 354                try
 355                {
 356                    if (trickplayInfo is not null)
 357                    {
 358                        trickplayInfo.ItemId = video.Id;
 359                        await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false);
 360
 361                        _logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath);
 362                    }
 363                    else
 364                    {
 365                        throw new InvalidOperationException("Null trickplay tiles info from CreateTiles.");
 366                    }
 367                }
 368                catch (Exception ex)
 369                {
 370                    _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.
 374                    outputDir.Delete(true);
 375                }
 376            }
 377            catch (Exception ex)
 378            {
 379                _logger.LogError(ex, "Error creating trickplay images.");
 380            }
 381            finally
 382            {
 383                if (!string.IsNullOrEmpty(imgTempDir))
 384                {
 385                    Directory.Delete(imgTempDir, true);
 386                }
 387            }
 388        }
 389    }
 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
 0402        var trickplayInfo = new TrickplayInfo
 0403        {
 0404            Width = width,
 0405            Interval = options.Interval,
 0406            TileWidth = options.TileWidth,
 0407            TileHeight = options.TileHeight,
 0408            ThumbnailCount = images.Count,
 0409            // Set during image generation
 0410            Height = 0,
 0411            Bandwidth = 0
 0412        };
 413
 414        /*
 415         * Generate trickplay tiles from sets of thumbnails
 416         */
 0417        var imageOptions = new ImageCollageOptions
 0418        {
 0419            Width = trickplayInfo.TileWidth,
 0420            Height = trickplayInfo.TileHeight
 0421        };
 422
 0423        var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight;
 0424        var requiredTiles = (int)Math.Ceiling((double)images.Count / thumbnailsPerTile);
 425
 0426        for (int i = 0; i < requiredTiles; i++)
 427        {
 428            // Set output/input paths
 0429            var tilePath = Path.Combine(workDir, $"{i}.jpg");
 430
 0431            imageOptions.OutputPath = tilePath;
 0432            imageOptions.InputPaths = images.Skip(i * thumbnailsPerTile).Take(Math.Min(thumbnailsPerTile, images.Count -
 433
 434            // Generate image and use returned height for tiles info
 0435            var height = _imageEncoder.CreateTrickplayTile(imageOptions, options.JpegQuality, trickplayInfo.Width, trick
 0436            if (trickplayInfo.Height == 0)
 437            {
 0438                trickplayInfo.Height = height;
 439            }
 440
 441            // Update bitrate
 0442            var bitrate = (int)Math.Ceiling(new FileInfo(tilePath).Length * 8m / trickplayInfo.TileWidth / trickplayInfo
 0443            trickplayInfo.Bandwidth = Math.Max(trickplayInfo.Bandwidth, bitrate);
 444        }
 445
 446        /*
 447         * Move trickplay tiles to output directory
 448         */
 0449        Directory.CreateDirectory(Directory.GetParent(outputDir)!.FullName);
 450
 451        // Replace existing tiles if they already exist
 0452        if (Directory.Exists(outputDir))
 453        {
 0454            Directory.Delete(outputDir, true);
 455        }
 456
 0457        _fileSystem.MoveDirectory(workDir, outputDir);
 458
 0459        return trickplayInfo;
 460    }
 461
 462    private bool CanGenerateTrickplay(Video video, int interval)
 463    {
 0464        var videoType = video.VideoType;
 0465        if (videoType == VideoType.Iso || videoType == VideoType.Dvd || videoType == VideoType.BluRay)
 466        {
 0467            return false;
 468        }
 469
 0470        if (video.IsPlaceHolder)
 471        {
 0472            return false;
 473        }
 474
 0475        if (video.IsShortcut)
 476        {
 0477            return false;
 478        }
 479
 0480        if (!video.IsCompleteMedia)
 481        {
 0482            return false;
 483        }
 484
 0485        if (!video.RunTimeTicks.HasValue || video.RunTimeTicks.Value < TimeSpan.FromMilliseconds(interval).Ticks)
 486        {
 0487            return false;
 488        }
 489
 490        // Can't extract images if there are no video streams
 0491        return video.GetMediaStreams().Count > 0;
 492    }
 493
 494    /// <inheritdoc />
 495    public async Task<Dictionary<int, TrickplayInfo>> GetTrickplayResolutions(Guid itemId)
 496    {
 497        var trickplayResolutions = new Dictionary<int, TrickplayInfo>();
 498
 499        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 500        await using (dbContext.ConfigureAwait(false))
 501        {
 502            var trickplayInfos = await dbContext.TrickplayInfos
 503                .AsNoTracking()
 504                .Where(i => i.ItemId.Equals(itemId))
 505                .ToListAsync()
 506                .ConfigureAwait(false);
 507
 508            foreach (var info in trickplayInfos)
 509            {
 510                trickplayResolutions[info.Width] = info;
 511            }
 512        }
 513
 514        return trickplayResolutions;
 515    }
 516
 517    /// <inheritdoc />
 518    public async Task<IReadOnlyList<TrickplayInfo>> GetTrickplayItemsAsync(int limit, int offset)
 519    {
 520        IReadOnlyList<TrickplayInfo> trickplayItems;
 521
 522        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 523        await using (dbContext.ConfigureAwait(false))
 524        {
 525            trickplayItems = await dbContext.TrickplayInfos
 526                .AsNoTracking()
 527                .OrderBy(i => i.ItemId)
 528                .Skip(offset)
 529                .Take(limit)
 530                .ToListAsync()
 531                .ConfigureAwait(false);
 532        }
 533
 534        return trickplayItems;
 535    }
 536
 537    /// <inheritdoc />
 538    public async Task SaveTrickplayInfo(TrickplayInfo info)
 539    {
 540        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 541        await using (dbContext.ConfigureAwait(false))
 542        {
 543            var oldInfo = await dbContext.TrickplayInfos.FindAsync(info.ItemId, info.Width).ConfigureAwait(false);
 544            if (oldInfo is not null)
 545            {
 546                dbContext.TrickplayInfos.Remove(oldInfo);
 547            }
 548
 549            dbContext.Add(info);
 550
 551            await dbContext.SaveChangesAsync().ConfigureAwait(false);
 552        }
 553    }
 554
 555    /// <inheritdoc />
 556    public async Task DeleteTrickplayDataAsync(Guid itemId, CancellationToken cancellationToken)
 557    {
 558        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 559        await dbContext.TrickplayInfos.Where(i => i.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).Configu
 560    }
 561
 562    /// <inheritdoc />
 563    public async Task<Dictionary<string, Dictionary<int, TrickplayInfo>>> GetTrickplayManifest(BaseItem item)
 564    {
 565        var trickplayManifest = new Dictionary<string, Dictionary<int, TrickplayInfo>>();
 566        foreach (var mediaSource in item.GetMediaSources(false))
 567        {
 568            if (mediaSource.IsRemote || !Guid.TryParse(mediaSource.Id, out var mediaSourceId))
 569            {
 570                continue;
 571            }
 572
 573            var trickplayResolutions = await GetTrickplayResolutions(mediaSourceId).ConfigureAwait(false);
 574
 575            if (trickplayResolutions.Count > 0)
 576            {
 577                trickplayManifest[mediaSource.Id] = trickplayResolutions;
 578            }
 579        }
 580
 581        return trickplayManifest;
 582    }
 583
 584    /// <inheritdoc />
 585    public async Task<string> GetTrickplayTilePathAsync(BaseItem item, int width, int index, bool saveWithMedia)
 586    {
 587        var trickplayResolutions = await GetTrickplayResolutions(item.Id).ConfigureAwait(false);
 588        if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo))
 589        {
 590            return Path.Combine(GetTrickplayDirectory(item, trickplayInfo.TileWidth, trickplayInfo.TileHeight, width, sa
 591        }
 592
 593        return string.Empty;
 594    }
 595
 596    /// <inheritdoc />
 597    public async Task<string?> GetHlsPlaylist(Guid itemId, int width, string? apiKey)
 598    {
 599        var trickplayResolutions = await GetTrickplayResolutions(itemId).ConfigureAwait(false);
 600        if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo))
 601        {
 602            var builder = new StringBuilder(128);
 603
 604            if (trickplayInfo.ThumbnailCount > 0)
 605            {
 606                const string urlFormat = "{0}.jpg?MediaSourceId={1}&ApiKey={2}";
 607                const string decimalFormat = "{0:0.###}";
 608
 609                var resolution = $"{trickplayInfo.Width}x{trickplayInfo.Height}";
 610                var layout = $"{trickplayInfo.TileWidth}x{trickplayInfo.TileHeight}";
 611                var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight;
 612                var thumbnailDuration = trickplayInfo.Interval / 1000d;
 613                var infDuration = thumbnailDuration * thumbnailsPerTile;
 614                var tileCount = (int)Math.Ceiling((decimal)trickplayInfo.ThumbnailCount / thumbnailsPerTile);
 615
 616                builder
 617                    .AppendLine("#EXTM3U")
 618                    .Append("#EXT-X-TARGETDURATION:")
 619                    .AppendLine(tileCount.ToString(CultureInfo.InvariantCulture))
 620                    .AppendLine("#EXT-X-VERSION:7")
 621                    .AppendLine("#EXT-X-MEDIA-SEQUENCE:1")
 622                    .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD")
 623                    .AppendLine("#EXT-X-IMAGES-ONLY");
 624
 625                for (int i = 0; i < tileCount; i++)
 626                {
 627                    // All tiles prior to the last must contain full amount of thumbnails (no black).
 628                    if (i == tileCount - 1)
 629                    {
 630                        thumbnailsPerTile = trickplayInfo.ThumbnailCount - (i * thumbnailsPerTile);
 631                        infDuration = thumbnailDuration * thumbnailsPerTile;
 632                    }
 633
 634                    // EXTINF
 635                    builder
 636                        .Append("#EXTINF:")
 637                        .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, infDuration)
 638                        .AppendLine(",");
 639
 640                    // EXT-X-TILES
 641                    builder
 642                        .Append("#EXT-X-TILES:RESOLUTION=")
 643                        .Append(resolution)
 644                        .Append(",LAYOUT=")
 645                        .Append(layout)
 646                        .Append(",DURATION=")
 647                        .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, thumbnailDuration)
 648                        .AppendLine();
 649
 650                    // URL
 651                    builder
 652                        .AppendFormat(
 653                            CultureInfo.InvariantCulture,
 654                            urlFormat,
 655                            i.ToString(CultureInfo.InvariantCulture),
 656                            itemId.ToString("N"),
 657                            apiKey)
 658                        .AppendLine();
 659                }
 660
 661                builder.AppendLine("#EXT-X-ENDLIST");
 662                return builder.ToString();
 663            }
 664        }
 665
 666        return null;
 667    }
 668
 669    /// <inheritdoc />
 670    public string GetTrickplayDirectory(BaseItem item, int tileWidth, int tileHeight, int width, bool saveWithMedia = fa
 671    {
 0672        var path = _pathManager.GetTrickplayDirectory(item, saveWithMedia);
 0673        var subdirectory = string.Format(
 0674            CultureInfo.InvariantCulture,
 0675            "{0} - {1}x{2}",
 0676            width.ToString(CultureInfo.InvariantCulture),
 0677            tileWidth.ToString(CultureInfo.InvariantCulture),
 0678            tileHeight.ToString(CultureInfo.InvariantCulture));
 679
 0680        return Path.Combine(path, subdirectory);
 681    }
 682
 683    private async Task<bool> HasTrickplayResolutionAsync(Guid itemId, int width)
 684    {
 685        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 686        await using (dbContext.ConfigureAwait(false))
 687        {
 688            return await dbContext.TrickplayInfos
 689                .AsNoTracking()
 690                .Where(i => i.ItemId.Equals(itemId))
 691                .AnyAsync(i => i.Width == width)
 692                .ConfigureAwait(false);
 693        }
 694    }
 695}