< 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
12%
Covered lines: 10
Uncovered lines: 67
Coverable lines: 77
Total lines: 643
Line coverage: 12.9%
Branch coverage
0%
Covered branches: 0
Total branches: 34
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%420200%
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 Jellyfin.Database.Implementations;
 11using Jellyfin.Database.Implementations.Entities;
 12using MediaBrowser.Common.Configuration;
 13using MediaBrowser.Controller.Configuration;
 14using MediaBrowser.Controller.Drawing;
 15using MediaBrowser.Controller.Entities;
 16using MediaBrowser.Controller.IO;
 17using MediaBrowser.Controller.MediaEncoding;
 18using MediaBrowser.Controller.Trickplay;
 19using MediaBrowser.Model.Configuration;
 20using MediaBrowser.Model.Entities;
 21using MediaBrowser.Model.IO;
 22using Microsoft.EntityFrameworkCore;
 23using Microsoft.Extensions.Logging;
 24
 25namespace Jellyfin.Server.Implementations.Trickplay;
 26
 27/// <summary>
 28/// ITrickplayManager implementation.
 29/// </summary>
 30public class TrickplayManager : ITrickplayManager
 31{
 32    private readonly ILogger<TrickplayManager> _logger;
 33    private readonly IMediaEncoder _mediaEncoder;
 34    private readonly IFileSystem _fileSystem;
 35    private readonly EncodingHelper _encodingHelper;
 36    private readonly IServerConfigurationManager _config;
 37    private readonly IImageEncoder _imageEncoder;
 38    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 39    private readonly IApplicationPaths _appPaths;
 40    private readonly IPathManager _pathManager;
 41
 042    private static readonly AsyncNonKeyedLocker _resourcePool = new(1);
 043    private static readonly string[] _trickplayImgExtensions = [".jpg"];
 44
 45    /// <summary>
 46    /// Initializes a new instance of the <see cref="TrickplayManager"/> class.
 47    /// </summary>
 48    /// <param name="logger">The logger.</param>
 49    /// <param name="mediaEncoder">The media encoder.</param>
 50    /// <param name="fileSystem">The file system.</param>
 51    /// <param name="encodingHelper">The encoding helper.</param>
 52    /// <param name="config">The server configuration manager.</param>
 53    /// <param name="imageEncoder">The image encoder.</param>
 54    /// <param name="dbProvider">The database provider.</param>
 55    /// <param name="appPaths">The application paths.</param>
 56    /// <param name="pathManager">The path manager.</param>
 57    public TrickplayManager(
 58        ILogger<TrickplayManager> logger,
 59        IMediaEncoder mediaEncoder,
 60        IFileSystem fileSystem,
 61        EncodingHelper encodingHelper,
 62        IServerConfigurationManager config,
 63        IImageEncoder imageEncoder,
 64        IDbContextFactory<JellyfinDbContext> dbProvider,
 65        IApplicationPaths appPaths,
 66        IPathManager pathManager)
 67    {
 2168        _logger = logger;
 2169        _mediaEncoder = mediaEncoder;
 2170        _fileSystem = fileSystem;
 2171        _encodingHelper = encodingHelper;
 2172        _config = config;
 2173        _imageEncoder = imageEncoder;
 2174        _dbProvider = dbProvider;
 2175        _appPaths = appPaths;
 2176        _pathManager = pathManager;
 2177    }
 78
 79    /// <inheritdoc />
 80    public async Task MoveGeneratedTrickplayDataAsync(Video video, LibraryOptions libraryOptions, CancellationToken canc
 81    {
 82        var options = _config.Configuration.TrickplayOptions;
 83        if (!CanGenerateTrickplay(video, options.Interval, libraryOptions))
 84        {
 85            return;
 86        }
 87
 88        var existingTrickplayResolutions = await GetTrickplayResolutions(video.Id).ConfigureAwait(false);
 89        foreach (var resolution in existingTrickplayResolutions)
 90        {
 91            cancellationToken.ThrowIfCancellationRequested();
 92            var existingResolution = resolution.Key;
 93            var tileWidth = resolution.Value.TileWidth;
 94            var tileHeight = resolution.Value.TileHeight;
 95            var shouldBeSavedWithMedia = libraryOptions is not null && libraryOptions.SaveTrickplayWithMedia;
 96            var localOutputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolutio
 97            var mediaOutputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolutio
 98            if (shouldBeSavedWithMedia && localOutputDir.Exists)
 99            {
 100                var localDirFiles = localOutputDir.EnumerateFiles();
 101                var mediaDirExists = mediaOutputDir.Exists;
 102                if (localDirFiles.Any() && ((mediaDirExists && mediaOutputDir.EnumerateFiles().Any()) || !mediaDirExists
 103                {
 104                    // Move images from local dir to media dir
 105                    MoveContent(localOutputDir.FullName, mediaOutputDir.FullName);
 106                    _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, mediaOutpu
 107                }
 108            }
 109            else if (!shouldBeSavedWithMedia && mediaOutputDir.Exists)
 110            {
 111                var mediaDirFiles = mediaOutputDir.EnumerateFiles();
 112                var localDirExists = localOutputDir.Exists;
 113                if (mediaDirFiles.Any() && ((localDirExists && localOutputDir.EnumerateFiles().Any()) || !localDirExists
 114                {
 115                    // Move images from media dir to local dir
 116                    MoveContent(mediaOutputDir.FullName, localOutputDir.FullName);
 117                    _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, localOutpu
 118                }
 119            }
 120        }
 121    }
 122
 123    private void MoveContent(string sourceFolder, string destinationFolder)
 124    {
 0125        _fileSystem.MoveDirectory(sourceFolder, destinationFolder);
 0126        var parent = Directory.GetParent(sourceFolder);
 0127        if (parent is not null)
 128        {
 0129            var parentContent = parent.EnumerateDirectories();
 0130            if (!parentContent.Any())
 131            {
 0132                parent.Delete();
 133            }
 134        }
 0135    }
 136
 137    /// <inheritdoc />
 138    public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationTo
 139    {
 140        _logger.LogDebug("Trickplay refresh for {ItemId} (replace existing: {Replace})", video.Id, replace);
 141
 142        var options = _config.Configuration.TrickplayOptions;
 143        if (options.Interval < 1000)
 144        {
 145            _logger.LogWarning("Trickplay image interval {Interval} is too small, reset to the minimum valid value of 10
 146            options.Interval = 1000;
 147        }
 148
 149        foreach (var width in options.WidthResolutions)
 150        {
 151            cancellationToken.ThrowIfCancellationRequested();
 152            await RefreshTrickplayDataInternal(
 153                video,
 154                replace,
 155                width,
 156                options,
 157                libraryOptions,
 158                cancellationToken).ConfigureAwait(false);
 159        }
 160    }
 161
 162    private async Task RefreshTrickplayDataInternal(
 163        Video video,
 164        bool replace,
 165        int width,
 166        TrickplayOptions options,
 167        LibraryOptions libraryOptions,
 168        CancellationToken cancellationToken)
 169    {
 170        if (!CanGenerateTrickplay(video, options.Interval, libraryOptions))
 171        {
 172            return;
 173        }
 174
 175        var imgTempDir = string.Empty;
 176
 177        using (await _resourcePool.LockAsync(cancellationToken).ConfigureAwait(false))
 178        {
 179            try
 180            {
 181                // Extract images
 182                // Note: Media sources under parent items exist as their own video/item as well. Only use this video str
 183                var mediaSource = video.GetMediaSources(false).FirstOrDefault(source => Guid.Parse(source.Id).Equals(vid
 184
 185                if (mediaSource is null)
 186                {
 187                    _logger.LogDebug("Found no matching media source for item {ItemId}", video.Id);
 188                    return;
 189                }
 190
 191                var mediaPath = mediaSource.Path;
 192                if (!File.Exists(mediaPath))
 193                {
 194                    _logger.LogWarning("Media not found at {Path} for item {ItemID}", mediaPath, video.Id);
 195                    return;
 196                }
 197
 198                // We support video backdrops, but we should not generate trickplay images for them
 199                var parentDirectory = Directory.GetParent(mediaPath);
 200                if (parentDirectory is not null && string.Equals(parentDirectory.Name, "backdrops", StringComparison.Ord
 201                {
 202                    _logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", mediaPath, video.Id);
 203                    return;
 204                }
 205
 206                // The width has to be even, otherwise a lot of filters will not be able to sample it
 207                var actualWidth = 2 * (width / 2);
 208
 209                // Force using the video width when the trickplay setting has a too large width
 210                if (mediaSource.VideoStream.Width is not null && mediaSource.VideoStream.Width < width)
 211                {
 212                    _logger.LogWarning("Video width {VideoWidth} is smaller than trickplay setting {TrickPlayWidth}, usi
 213                    actualWidth = 2 * ((int)mediaSource.VideoStream.Width / 2);
 214                }
 215
 216                var tileWidth = options.TileWidth;
 217                var tileHeight = options.TileHeight;
 218                var saveWithMedia = libraryOptions is not null && libraryOptions.SaveTrickplayWithMedia;
 219                var outputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, actualWidth, saveW
 220
 221                // Import existing trickplay tiles
 222                if (!replace && outputDir.Exists)
 223                {
 224                    var existingFiles = outputDir.GetFiles();
 225                    if (existingFiles.Length > 0)
 226                    {
 227                        var hasTrickplayResolution = await HasTrickplayResolutionAsync(video.Id, actualWidth).ConfigureA
 228                        if (hasTrickplayResolution)
 229                        {
 230                            _logger.LogDebug("Found existing trickplay files for {ItemId}.", video.Id);
 231                            return;
 232                        }
 233
 234                        // Import tiles
 235                        var localTrickplayInfo = new TrickplayInfo
 236                        {
 237                            ItemId = video.Id,
 238                            Width = width,
 239                            Interval = options.Interval,
 240                            TileWidth = options.TileWidth,
 241                            TileHeight = options.TileHeight,
 242                            ThumbnailCount = existingFiles.Length,
 243                            Height = 0,
 244                            Bandwidth = 0
 245                        };
 246
 247                        foreach (var tile in existingFiles)
 248                        {
 249                            var image = _imageEncoder.GetImageSize(tile.FullName);
 250                            localTrickplayInfo.Height = Math.Max(localTrickplayInfo.Height, (int)Math.Ceiling((double)im
 251                            var bitrate = (int)Math.Ceiling((decimal)tile.Length * 8 / localTrickplayInfo.TileWidth / lo
 252                            localTrickplayInfo.Bandwidth = Math.Max(localTrickplayInfo.Bandwidth, bitrate);
 253                        }
 254
 255                        await SaveTrickplayInfo(localTrickplayInfo).ConfigureAwait(false);
 256
 257                        _logger.LogDebug("Imported existing trickplay files for {ItemId}.", video.Id);
 258                        return;
 259                    }
 260                }
 261
 262                // Generate trickplay tiles
 263                var mediaStream = mediaSource.VideoStream;
 264                var container = mediaSource.Container;
 265
 266                _logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", actualWid
 267                imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated(
 268                    mediaPath,
 269                    container,
 270                    mediaSource,
 271                    mediaStream,
 272                    actualWidth,
 273                    TimeSpan.FromMilliseconds(options.Interval),
 274                    options.EnableHwAcceleration,
 275                    options.EnableHwEncoding,
 276                    options.ProcessThreads,
 277                    options.Qscale,
 278                    options.ProcessPriority,
 279                    options.EnableKeyFrameOnlyExtraction,
 280                    _encodingHelper,
 281                    cancellationToken).ConfigureAwait(false);
 282
 283                if (string.IsNullOrEmpty(imgTempDir) || !Directory.Exists(imgTempDir))
 284                {
 285                    throw new InvalidOperationException("Null or invalid directory from media encoder.");
 286                }
 287
 288                var images = _fileSystem.GetFiles(imgTempDir, _trickplayImgExtensions, false, false)
 289                    .Select(i => i.FullName)
 290                    .OrderBy(i => i)
 291                    .ToList();
 292
 293                // Create tiles
 294                var trickplayInfo = CreateTiles(images, actualWidth, options, outputDir.FullName);
 295
 296                // Save tiles info
 297                try
 298                {
 299                    if (trickplayInfo is not null)
 300                    {
 301                        trickplayInfo.ItemId = video.Id;
 302                        await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false);
 303
 304                        _logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath);
 305                    }
 306                    else
 307                    {
 308                        throw new InvalidOperationException("Null trickplay tiles info from CreateTiles.");
 309                    }
 310                }
 311                catch (Exception ex)
 312                {
 313                    _logger.LogError(ex, "Error while saving trickplay tiles info.");
 314
 315                    // Make sure no files stay in metadata folders on failure
 316                    // if tiles info wasn't saved.
 317                    outputDir.Delete(true);
 318                }
 319            }
 320            catch (Exception ex)
 321            {
 322                _logger.LogError(ex, "Error creating trickplay images.");
 323            }
 324            finally
 325            {
 326                if (!string.IsNullOrEmpty(imgTempDir))
 327                {
 328                    Directory.Delete(imgTempDir, true);
 329                }
 330            }
 331        }
 332    }
 333
 334    /// <inheritdoc />
 335    public TrickplayInfo CreateTiles(IReadOnlyList<string> images, int width, TrickplayOptions options, string outputDir
 336    {
 0337        if (images.Count == 0)
 338        {
 0339            throw new ArgumentException("Can't create trickplay from 0 images.");
 340        }
 341
 0342        var workDir = Path.Combine(_appPaths.TempDirectory, "trickplay_" + Guid.NewGuid().ToString("N"));
 0343        Directory.CreateDirectory(workDir);
 344
 0345        var trickplayInfo = new TrickplayInfo
 0346        {
 0347            Width = width,
 0348            Interval = options.Interval,
 0349            TileWidth = options.TileWidth,
 0350            TileHeight = options.TileHeight,
 0351            ThumbnailCount = images.Count,
 0352            // Set during image generation
 0353            Height = 0,
 0354            Bandwidth = 0
 0355        };
 356
 357        /*
 358         * Generate trickplay tiles from sets of thumbnails
 359         */
 0360        var imageOptions = new ImageCollageOptions
 0361        {
 0362            Width = trickplayInfo.TileWidth,
 0363            Height = trickplayInfo.TileHeight
 0364        };
 365
 0366        var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight;
 0367        var requiredTiles = (int)Math.Ceiling((double)images.Count / thumbnailsPerTile);
 368
 0369        for (int i = 0; i < requiredTiles; i++)
 370        {
 371            // Set output/input paths
 0372            var tilePath = Path.Combine(workDir, $"{i}.jpg");
 373
 0374            imageOptions.OutputPath = tilePath;
 0375            imageOptions.InputPaths = images.Skip(i * thumbnailsPerTile).Take(Math.Min(thumbnailsPerTile, images.Count -
 376
 377            // Generate image and use returned height for tiles info
 0378            var height = _imageEncoder.CreateTrickplayTile(imageOptions, options.JpegQuality, trickplayInfo.Width, trick
 0379            if (trickplayInfo.Height == 0)
 380            {
 0381                trickplayInfo.Height = height;
 382            }
 383
 384            // Update bitrate
 0385            var bitrate = (int)Math.Ceiling(new FileInfo(tilePath).Length * 8m / trickplayInfo.TileWidth / trickplayInfo
 0386            trickplayInfo.Bandwidth = Math.Max(trickplayInfo.Bandwidth, bitrate);
 387        }
 388
 389        /*
 390         * Move trickplay tiles to output directory
 391         */
 0392        Directory.CreateDirectory(Directory.GetParent(outputDir)!.FullName);
 393
 394        // Replace existing tiles if they already exist
 0395        if (Directory.Exists(outputDir))
 396        {
 0397            Directory.Delete(outputDir, true);
 398        }
 399
 0400        _fileSystem.MoveDirectory(workDir, outputDir);
 401
 0402        return trickplayInfo;
 403    }
 404
 405    private bool CanGenerateTrickplay(Video video, int interval, LibraryOptions libraryOptions)
 406    {
 0407        var videoType = video.VideoType;
 0408        if (videoType == VideoType.Iso || videoType == VideoType.Dvd || videoType == VideoType.BluRay)
 409        {
 0410            return false;
 411        }
 412
 0413        if (video.IsPlaceHolder)
 414        {
 0415            return false;
 416        }
 417
 0418        if (video.IsShortcut)
 419        {
 0420            return false;
 421        }
 422
 0423        if (!video.IsCompleteMedia)
 424        {
 0425            return false;
 426        }
 427
 0428        if (!video.RunTimeTicks.HasValue || video.RunTimeTicks.Value < TimeSpan.FromMilliseconds(interval).Ticks)
 429        {
 0430            return false;
 431        }
 432
 0433        if (libraryOptions is null || !libraryOptions.EnableTrickplayImageExtraction)
 434        {
 0435            return false;
 436        }
 437
 438        // Can't extract images if there are no video streams
 0439        return video.GetMediaStreams().Count > 0;
 440    }
 441
 442    /// <inheritdoc />
 443    public async Task<Dictionary<int, TrickplayInfo>> GetTrickplayResolutions(Guid itemId)
 444    {
 445        var trickplayResolutions = new Dictionary<int, TrickplayInfo>();
 446
 447        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 448        await using (dbContext.ConfigureAwait(false))
 449        {
 450            var trickplayInfos = await dbContext.TrickplayInfos
 451                .AsNoTracking()
 452                .Where(i => i.ItemId.Equals(itemId))
 453                .ToListAsync()
 454                .ConfigureAwait(false);
 455
 456            foreach (var info in trickplayInfos)
 457            {
 458                trickplayResolutions[info.Width] = info;
 459            }
 460        }
 461
 462        return trickplayResolutions;
 463    }
 464
 465    /// <inheritdoc />
 466    public async Task<IReadOnlyList<TrickplayInfo>> GetTrickplayItemsAsync(int limit, int offset)
 467    {
 468        IReadOnlyList<TrickplayInfo> trickplayItems;
 469
 470        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 471        await using (dbContext.ConfigureAwait(false))
 472        {
 473            trickplayItems = await dbContext.TrickplayInfos
 474                .AsNoTracking()
 475                .OrderBy(i => i.ItemId)
 476                .Skip(offset)
 477                .Take(limit)
 478                .ToListAsync()
 479                .ConfigureAwait(false);
 480        }
 481
 482        return trickplayItems;
 483    }
 484
 485    /// <inheritdoc />
 486    public async Task SaveTrickplayInfo(TrickplayInfo info)
 487    {
 488        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 489        await using (dbContext.ConfigureAwait(false))
 490        {
 491            var oldInfo = await dbContext.TrickplayInfos.FindAsync(info.ItemId, info.Width).ConfigureAwait(false);
 492            if (oldInfo is not null)
 493            {
 494                dbContext.TrickplayInfos.Remove(oldInfo);
 495            }
 496
 497            dbContext.Add(info);
 498
 499            await dbContext.SaveChangesAsync().ConfigureAwait(false);
 500        }
 501    }
 502
 503    /// <inheritdoc />
 504    public async Task DeleteTrickplayDataAsync(Guid itemId, CancellationToken cancellationToken)
 505    {
 506        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 507        await dbContext.TrickplayInfos.Where(i => i.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).Configu
 508    }
 509
 510    /// <inheritdoc />
 511    public async Task<Dictionary<string, Dictionary<int, TrickplayInfo>>> GetTrickplayManifest(BaseItem item)
 512    {
 513        var trickplayManifest = new Dictionary<string, Dictionary<int, TrickplayInfo>>();
 514        foreach (var mediaSource in item.GetMediaSources(false))
 515        {
 516            if (mediaSource.IsRemote || !Guid.TryParse(mediaSource.Id, out var mediaSourceId))
 517            {
 518                continue;
 519            }
 520
 521            var trickplayResolutions = await GetTrickplayResolutions(mediaSourceId).ConfigureAwait(false);
 522
 523            if (trickplayResolutions.Count > 0)
 524            {
 525                trickplayManifest[mediaSource.Id] = trickplayResolutions;
 526            }
 527        }
 528
 529        return trickplayManifest;
 530    }
 531
 532    /// <inheritdoc />
 533    public async Task<string> GetTrickplayTilePathAsync(BaseItem item, int width, int index, bool saveWithMedia)
 534    {
 535        var trickplayResolutions = await GetTrickplayResolutions(item.Id).ConfigureAwait(false);
 536        if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo))
 537        {
 538            return Path.Combine(GetTrickplayDirectory(item, trickplayInfo.TileWidth, trickplayInfo.TileHeight, width, sa
 539        }
 540
 541        return string.Empty;
 542    }
 543
 544    /// <inheritdoc />
 545    public async Task<string?> GetHlsPlaylist(Guid itemId, int width, string? apiKey)
 546    {
 547        var trickplayResolutions = await GetTrickplayResolutions(itemId).ConfigureAwait(false);
 548        if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo))
 549        {
 550            var builder = new StringBuilder(128);
 551
 552            if (trickplayInfo.ThumbnailCount > 0)
 553            {
 554                const string urlFormat = "{0}.jpg?MediaSourceId={1}&ApiKey={2}";
 555                const string decimalFormat = "{0:0.###}";
 556
 557                var resolution = $"{trickplayInfo.Width}x{trickplayInfo.Height}";
 558                var layout = $"{trickplayInfo.TileWidth}x{trickplayInfo.TileHeight}";
 559                var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight;
 560                var thumbnailDuration = trickplayInfo.Interval / 1000d;
 561                var infDuration = thumbnailDuration * thumbnailsPerTile;
 562                var tileCount = (int)Math.Ceiling((decimal)trickplayInfo.ThumbnailCount / thumbnailsPerTile);
 563
 564                builder
 565                    .AppendLine("#EXTM3U")
 566                    .Append("#EXT-X-TARGETDURATION:")
 567                    .AppendLine(tileCount.ToString(CultureInfo.InvariantCulture))
 568                    .AppendLine("#EXT-X-VERSION:7")
 569                    .AppendLine("#EXT-X-MEDIA-SEQUENCE:1")
 570                    .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD")
 571                    .AppendLine("#EXT-X-IMAGES-ONLY");
 572
 573                for (int i = 0; i < tileCount; i++)
 574                {
 575                    // All tiles prior to the last must contain full amount of thumbnails (no black).
 576                    if (i == tileCount - 1)
 577                    {
 578                        thumbnailsPerTile = trickplayInfo.ThumbnailCount - (i * thumbnailsPerTile);
 579                        infDuration = thumbnailDuration * thumbnailsPerTile;
 580                    }
 581
 582                    // EXTINF
 583                    builder
 584                        .Append("#EXTINF:")
 585                        .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, infDuration)
 586                        .AppendLine(",");
 587
 588                    // EXT-X-TILES
 589                    builder
 590                        .Append("#EXT-X-TILES:RESOLUTION=")
 591                        .Append(resolution)
 592                        .Append(",LAYOUT=")
 593                        .Append(layout)
 594                        .Append(",DURATION=")
 595                        .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, thumbnailDuration)
 596                        .AppendLine();
 597
 598                    // URL
 599                    builder
 600                        .AppendFormat(
 601                            CultureInfo.InvariantCulture,
 602                            urlFormat,
 603                            i.ToString(CultureInfo.InvariantCulture),
 604                            itemId.ToString("N"),
 605                            apiKey)
 606                        .AppendLine();
 607                }
 608
 609                builder.AppendLine("#EXT-X-ENDLIST");
 610                return builder.ToString();
 611            }
 612        }
 613
 614        return null;
 615    }
 616
 617    /// <inheritdoc />
 618    public string GetTrickplayDirectory(BaseItem item, int tileWidth, int tileHeight, int width, bool saveWithMedia = fa
 619    {
 0620        var path = _pathManager.GetTrickplayDirectory(item, saveWithMedia);
 0621        var subdirectory = string.Format(
 0622            CultureInfo.InvariantCulture,
 0623            "{0} - {1}x{2}",
 0624            width.ToString(CultureInfo.InvariantCulture),
 0625            tileWidth.ToString(CultureInfo.InvariantCulture),
 0626            tileHeight.ToString(CultureInfo.InvariantCulture));
 627
 0628        return Path.Combine(path, subdirectory);
 629    }
 630
 631    private async Task<bool> HasTrickplayResolutionAsync(Guid itemId, int width)
 632    {
 633        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 634        await using (dbContext.ConfigureAwait(false))
 635        {
 636            return await dbContext.TrickplayInfos
 637                .AsNoTracking()
 638                .Where(i => i.ItemId.Equals(itemId))
 639                .AnyAsync(i => i.Width == width)
 640                .ConfigureAwait(false);
 641        }
 642    }
 643}