< 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: 70
Coverable lines: 80
Total lines: 625
Line coverage: 12.5%
Branch coverage
0%
Covered branches: 0
Total branches: 36
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(...)0%620%

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.Data.Entities;
 11using MediaBrowser.Common.Configuration;
 12using MediaBrowser.Controller.Configuration;
 13using MediaBrowser.Controller.Drawing;
 14using MediaBrowser.Controller.Entities;
 15using MediaBrowser.Controller.Library;
 16using MediaBrowser.Controller.MediaEncoding;
 17using MediaBrowser.Controller.Trickplay;
 18using MediaBrowser.Model.Configuration;
 19using MediaBrowser.Model.Entities;
 20using MediaBrowser.Model.IO;
 21using Microsoft.EntityFrameworkCore;
 22using Microsoft.Extensions.Logging;
 23
 24namespace Jellyfin.Server.Implementations.Trickplay;
 25
 26/// <summary>
 27/// ITrickplayManager implementation.
 28/// </summary>
 29public class TrickplayManager : ITrickplayManager
 30{
 31    private readonly ILogger<TrickplayManager> _logger;
 32    private readonly IMediaEncoder _mediaEncoder;
 33    private readonly IFileSystem _fileSystem;
 34    private readonly EncodingHelper _encodingHelper;
 35    private readonly ILibraryManager _libraryManager;
 36    private readonly IServerConfigurationManager _config;
 37    private readonly IImageEncoder _imageEncoder;
 38    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 39    private readonly IApplicationPaths _appPaths;
 40
 041    private static readonly AsyncNonKeyedLocker _resourcePool = new(1);
 042    private static readonly string[] _trickplayImgExtensions = { ".jpg" };
 43
 44    /// <summary>
 45    /// Initializes a new instance of the <see cref="TrickplayManager"/> class.
 46    /// </summary>
 47    /// <param name="logger">The logger.</param>
 48    /// <param name="mediaEncoder">The media encoder.</param>
 49    /// <param name="fileSystem">The file systen.</param>
 50    /// <param name="encodingHelper">The encoding helper.</param>
 51    /// <param name="libraryManager">The library manager.</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    public TrickplayManager(
 57        ILogger<TrickplayManager> logger,
 58        IMediaEncoder mediaEncoder,
 59        IFileSystem fileSystem,
 60        EncodingHelper encodingHelper,
 61        ILibraryManager libraryManager,
 62        IServerConfigurationManager config,
 63        IImageEncoder imageEncoder,
 64        IDbContextFactory<JellyfinDbContext> dbProvider,
 65        IApplicationPaths appPaths)
 66    {
 2267        _logger = logger;
 2268        _mediaEncoder = mediaEncoder;
 2269        _fileSystem = fileSystem;
 2270        _encodingHelper = encodingHelper;
 2271        _libraryManager = libraryManager;
 2272        _config = config;
 2273        _imageEncoder = imageEncoder;
 2274        _dbProvider = dbProvider;
 2275        _appPaths = appPaths;
 2276    }
 77
 78    /// <inheritdoc />
 79    public async Task MoveGeneratedTrickplayDataAsync(Video video, LibraryOptions? libraryOptions, CancellationToken can
 80    {
 81        var options = _config.Configuration.TrickplayOptions;
 82        if (!CanGenerateTrickplay(video, options.Interval))
 83        {
 84            return;
 85        }
 86
 87        var existingTrickplayResolutions = await GetTrickplayResolutions(video.Id).ConfigureAwait(false);
 88        foreach (var resolution in existingTrickplayResolutions)
 89        {
 90            cancellationToken.ThrowIfCancellationRequested();
 91            var existingResolution = resolution.Key;
 92            var tileWidth = resolution.Value.TileWidth;
 93            var tileHeight = resolution.Value.TileHeight;
 94            var shouldBeSavedWithMedia = libraryOptions is null ? false : libraryOptions.SaveTrickplayWithMedia;
 95            var localOutputDir = GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolution, false);
 96            var mediaOutputDir = GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolution, true);
 97            if (shouldBeSavedWithMedia && Directory.Exists(localOutputDir))
 98            {
 99                var localDirFiles = Directory.GetFiles(localOutputDir);
 100                var mediaDirExists = Directory.Exists(mediaOutputDir);
 101                if (localDirFiles.Length > 0 && ((mediaDirExists && Directory.GetFiles(mediaOutputDir).Length == 0) || !
 102                {
 103                    // Move images from local dir to media dir
 104                    MoveContent(localOutputDir, mediaOutputDir);
 105                    _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, mediaOutpu
 106                }
 107            }
 108            else if (Directory.Exists(mediaOutputDir))
 109            {
 110                var mediaDirFiles = Directory.GetFiles(mediaOutputDir);
 111                var localDirExists = Directory.Exists(localOutputDir);
 112                if (mediaDirFiles.Length > 0 && ((localDirExists && Directory.GetFiles(localOutputDir).Length == 0) || !
 113                {
 114                    // Move images from media dir to local dir
 115                    MoveContent(mediaOutputDir, localOutputDir);
 116                    _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, localOutpu
 117                }
 118            }
 119        }
 120    }
 121
 122    private void MoveContent(string sourceFolder, string destinationFolder)
 123    {
 0124        _fileSystem.MoveDirectory(sourceFolder, destinationFolder);
 0125        var parent = Directory.GetParent(sourceFolder);
 0126        if (parent is not null)
 127        {
 0128            var parentContent = Directory.GetDirectories(parent.FullName);
 0129            if (parentContent.Length == 0)
 130            {
 0131                Directory.Delete(parent.FullName);
 132            }
 133        }
 0134    }
 135
 136    /// <inheritdoc />
 137    public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions? libraryOptions, CancellationT
 138    {
 139        _logger.LogDebug("Trickplay refresh for {ItemId} (replace existing: {Replace})", video.Id, replace);
 140
 141        var options = _config.Configuration.TrickplayOptions;
 142        if (options.Interval < 1000)
 143        {
 144            _logger.LogWarning("Trickplay image interval {Interval} is too small, reset to the minimum valid value of 10
 145            options.Interval = 1000;
 146        }
 147
 148        foreach (var width in options.WidthResolutions)
 149        {
 150            cancellationToken.ThrowIfCancellationRequested();
 151            await RefreshTrickplayDataInternal(
 152                video,
 153                replace,
 154                width,
 155                options,
 156                libraryOptions,
 157                cancellationToken).ConfigureAwait(false);
 158        }
 159    }
 160
 161    private async Task RefreshTrickplayDataInternal(
 162        Video video,
 163        bool replace,
 164        int width,
 165        TrickplayOptions options,
 166        LibraryOptions? libraryOptions,
 167        CancellationToken cancellationToken)
 168    {
 169        if (!CanGenerateTrickplay(video, options.Interval))
 170        {
 171            return;
 172        }
 173
 174        var imgTempDir = string.Empty;
 175
 176        using (await _resourcePool.LockAsync(cancellationToken).ConfigureAwait(false))
 177        {
 178            try
 179            {
 180                // Extract images
 181                // Note: Media sources under parent items exist as their own video/item as well. Only use this video str
 182                var mediaSource = video.GetMediaSources(false).Find(source => Guid.Parse(source.Id).Equals(video.Id));
 183
 184                if (mediaSource is null)
 185                {
 186                    _logger.LogDebug("Found no matching media source for item {ItemId}", video.Id);
 187                    return;
 188                }
 189
 190                var mediaPath = mediaSource.Path;
 191                if (!File.Exists(mediaPath))
 192                {
 193                    _logger.LogWarning("Media not found at {Path} for item {ItemID}", mediaPath, video.Id);
 194                    return;
 195                }
 196
 197                // The width has to be even, otherwise a lot of filters will not be able to sample it
 198                var actualWidth = 2 * (width / 2);
 199
 200                // Force using the video width when the trickplay setting has a too large width
 201                if (mediaSource.VideoStream.Width is not null && mediaSource.VideoStream.Width < width)
 202                {
 203                    _logger.LogWarning("Video width {VideoWidth} is smaller than trickplay setting {TrickPlayWidth}, usi
 204                    actualWidth = 2 * ((int)mediaSource.VideoStream.Width / 2);
 205                }
 206
 207                var tileWidth = options.TileWidth;
 208                var tileHeight = options.TileHeight;
 209                var saveWithMedia = libraryOptions is null ? false : libraryOptions.SaveTrickplayWithMedia;
 210                var outputDir = GetTrickplayDirectory(video, tileWidth, tileHeight, actualWidth, saveWithMedia);
 211
 212                // Import existing trickplay tiles
 213                if (!replace && Directory.Exists(outputDir))
 214                {
 215                    var existingFiles = Directory.GetFiles(outputDir);
 216                    if (existingFiles.Length > 0)
 217                    {
 218                        var hasTrickplayResolution = await HasTrickplayResolutionAsync(video.Id, actualWidth).ConfigureA
 219                        if (hasTrickplayResolution)
 220                        {
 221                            _logger.LogDebug("Found existing trickplay files for {ItemId}.", video.Id);
 222                            return;
 223                        }
 224
 225                        // Import tiles
 226                        var localTrickplayInfo = new TrickplayInfo
 227                        {
 228                            ItemId = video.Id,
 229                            Width = width,
 230                            Interval = options.Interval,
 231                            TileWidth = options.TileWidth,
 232                            TileHeight = options.TileHeight,
 233                            ThumbnailCount = existingFiles.Length,
 234                            Height = 0,
 235                            Bandwidth = 0
 236                        };
 237
 238                        foreach (var tile in existingFiles)
 239                        {
 240                            var image = _imageEncoder.GetImageSize(tile);
 241                            localTrickplayInfo.Height = Math.Max(localTrickplayInfo.Height, image.Height);
 242                            var bitrate = (int)Math.Ceiling((decimal)new FileInfo(tile).Length * 8 / localTrickplayInfo.
 243                            localTrickplayInfo.Bandwidth = Math.Max(localTrickplayInfo.Bandwidth, bitrate);
 244                        }
 245
 246                        await SaveTrickplayInfo(localTrickplayInfo).ConfigureAwait(false);
 247
 248                        _logger.LogDebug("Imported existing trickplay files for {ItemId}.", video.Id);
 249                        return;
 250                    }
 251                }
 252
 253                // Generate trickplay tiles
 254                var mediaStream = mediaSource.VideoStream;
 255                var container = mediaSource.Container;
 256
 257                _logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", actualWid
 258                imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated(
 259                    mediaPath,
 260                    container,
 261                    mediaSource,
 262                    mediaStream,
 263                    actualWidth,
 264                    TimeSpan.FromMilliseconds(options.Interval),
 265                    options.EnableHwAcceleration,
 266                    options.EnableHwEncoding,
 267                    options.ProcessThreads,
 268                    options.Qscale,
 269                    options.ProcessPriority,
 270                    options.EnableKeyFrameOnlyExtraction,
 271                    _encodingHelper,
 272                    cancellationToken).ConfigureAwait(false);
 273
 274                if (string.IsNullOrEmpty(imgTempDir) || !Directory.Exists(imgTempDir))
 275                {
 276                    throw new InvalidOperationException("Null or invalid directory from media encoder.");
 277                }
 278
 279                var images = _fileSystem.GetFiles(imgTempDir, _trickplayImgExtensions, false, false)
 280                    .Select(i => i.FullName)
 281                    .OrderBy(i => i)
 282                    .ToList();
 283
 284                // Create tiles
 285                var trickplayInfo = CreateTiles(images, actualWidth, options, outputDir);
 286
 287                // Save tiles info
 288                try
 289                {
 290                    if (trickplayInfo is not null)
 291                    {
 292                        trickplayInfo.ItemId = video.Id;
 293                        await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false);
 294
 295                        _logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath);
 296                    }
 297                    else
 298                    {
 299                        throw new InvalidOperationException("Null trickplay tiles info from CreateTiles.");
 300                    }
 301                }
 302                catch (Exception ex)
 303                {
 304                    _logger.LogError(ex, "Error while saving trickplay tiles info.");
 305
 306                    // Make sure no files stay in metadata folders on failure
 307                    // if tiles info wasn't saved.
 308                    Directory.Delete(outputDir, true);
 309                }
 310            }
 311            catch (Exception ex)
 312            {
 313                _logger.LogError(ex, "Error creating trickplay images.");
 314            }
 315            finally
 316            {
 317                if (!string.IsNullOrEmpty(imgTempDir))
 318                {
 319                    Directory.Delete(imgTempDir, true);
 320                }
 321            }
 322        }
 323    }
 324
 325    /// <inheritdoc />
 326    public TrickplayInfo CreateTiles(IReadOnlyList<string> images, int width, TrickplayOptions options, string outputDir
 327    {
 0328        if (images.Count == 0)
 329        {
 0330            throw new ArgumentException("Can't create trickplay from 0 images.");
 331        }
 332
 0333        var workDir = Path.Combine(_appPaths.TempDirectory, "trickplay_" + Guid.NewGuid().ToString("N"));
 0334        Directory.CreateDirectory(workDir);
 335
 0336        var trickplayInfo = new TrickplayInfo
 0337        {
 0338            Width = width,
 0339            Interval = options.Interval,
 0340            TileWidth = options.TileWidth,
 0341            TileHeight = options.TileHeight,
 0342            ThumbnailCount = images.Count,
 0343            // Set during image generation
 0344            Height = 0,
 0345            Bandwidth = 0
 0346        };
 347
 348        /*
 349         * Generate trickplay tiles from sets of thumbnails
 350         */
 0351        var imageOptions = new ImageCollageOptions
 0352        {
 0353            Width = trickplayInfo.TileWidth,
 0354            Height = trickplayInfo.TileHeight
 0355        };
 356
 0357        var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight;
 0358        var requiredTiles = (int)Math.Ceiling((double)images.Count / thumbnailsPerTile);
 359
 0360        for (int i = 0; i < requiredTiles; i++)
 361        {
 362            // Set output/input paths
 0363            var tilePath = Path.Combine(workDir, $"{i}.jpg");
 364
 0365            imageOptions.OutputPath = tilePath;
 0366            imageOptions.InputPaths = images.Skip(i * thumbnailsPerTile).Take(Math.Min(thumbnailsPerTile, images.Count -
 367
 368            // Generate image and use returned height for tiles info
 0369            var height = _imageEncoder.CreateTrickplayTile(imageOptions, options.JpegQuality, trickplayInfo.Width, trick
 0370            if (trickplayInfo.Height == 0)
 371            {
 0372                trickplayInfo.Height = height;
 373            }
 374
 375            // Update bitrate
 0376            var bitrate = (int)Math.Ceiling(new FileInfo(tilePath).Length * 8m / trickplayInfo.TileWidth / trickplayInfo
 0377            trickplayInfo.Bandwidth = Math.Max(trickplayInfo.Bandwidth, bitrate);
 378        }
 379
 380        /*
 381         * Move trickplay tiles to output directory
 382         */
 0383        Directory.CreateDirectory(Directory.GetParent(outputDir)!.FullName);
 384
 385        // Replace existing tiles if they already exist
 0386        if (Directory.Exists(outputDir))
 387        {
 0388            Directory.Delete(outputDir, true);
 389        }
 390
 0391        _fileSystem.MoveDirectory(workDir, outputDir);
 392
 0393        return trickplayInfo;
 394    }
 395
 396    private bool CanGenerateTrickplay(Video video, int interval)
 397    {
 0398        var videoType = video.VideoType;
 0399        if (videoType == VideoType.Iso || videoType == VideoType.Dvd || videoType == VideoType.BluRay)
 400        {
 0401            return false;
 402        }
 403
 0404        if (video.IsPlaceHolder)
 405        {
 0406            return false;
 407        }
 408
 0409        if (video.IsShortcut)
 410        {
 0411            return false;
 412        }
 413
 0414        if (!video.IsCompleteMedia)
 415        {
 0416            return false;
 417        }
 418
 0419        if (!video.RunTimeTicks.HasValue || video.RunTimeTicks.Value < TimeSpan.FromMilliseconds(interval).Ticks)
 420        {
 0421            return false;
 422        }
 423
 0424        var libraryOptions = _libraryManager.GetLibraryOptions(video);
 0425        if (libraryOptions is null || !libraryOptions.EnableTrickplayImageExtraction)
 426        {
 0427            return false;
 428        }
 429
 430        // Can't extract images if there are no video streams
 0431        return video.GetMediaStreams().Count > 0;
 432    }
 433
 434    /// <inheritdoc />
 435    public async Task<Dictionary<int, TrickplayInfo>> GetTrickplayResolutions(Guid itemId)
 436    {
 437        var trickplayResolutions = new Dictionary<int, TrickplayInfo>();
 438
 439        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 440        await using (dbContext.ConfigureAwait(false))
 441        {
 442            var trickplayInfos = await dbContext.TrickplayInfos
 443                .AsNoTracking()
 444                .Where(i => i.ItemId.Equals(itemId))
 445                .ToListAsync()
 446                .ConfigureAwait(false);
 447
 448            foreach (var info in trickplayInfos)
 449            {
 450                trickplayResolutions[info.Width] = info;
 451            }
 452        }
 453
 454        return trickplayResolutions;
 455    }
 456
 457    /// <inheritdoc />
 458    public async Task<IReadOnlyList<Guid>> GetTrickplayItemsAsync()
 459    {
 460        List<Guid> trickplayItems;
 461
 462        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 463        await using (dbContext.ConfigureAwait(false))
 464        {
 465            trickplayItems = await dbContext.TrickplayInfos
 466                .AsNoTracking()
 467                .Select(i => i.ItemId)
 468                .ToListAsync()
 469                .ConfigureAwait(false);
 470        }
 471
 472        return trickplayItems;
 473    }
 474
 475    /// <inheritdoc />
 476    public async Task SaveTrickplayInfo(TrickplayInfo info)
 477    {
 478        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 479        await using (dbContext.ConfigureAwait(false))
 480        {
 481            var oldInfo = await dbContext.TrickplayInfos.FindAsync(info.ItemId, info.Width).ConfigureAwait(false);
 482            if (oldInfo is not null)
 483            {
 484                dbContext.TrickplayInfos.Remove(oldInfo);
 485            }
 486
 487            dbContext.Add(info);
 488
 489            await dbContext.SaveChangesAsync().ConfigureAwait(false);
 490        }
 491    }
 492
 493    /// <inheritdoc />
 494    public async Task<Dictionary<string, Dictionary<int, TrickplayInfo>>> GetTrickplayManifest(BaseItem item)
 495    {
 496        var trickplayManifest = new Dictionary<string, Dictionary<int, TrickplayInfo>>();
 497        foreach (var mediaSource in item.GetMediaSources(false))
 498        {
 499            var mediaSourceId = Guid.Parse(mediaSource.Id);
 500            var trickplayResolutions = await GetTrickplayResolutions(mediaSourceId).ConfigureAwait(false);
 501
 502            if (trickplayResolutions.Count > 0)
 503            {
 504                trickplayManifest[mediaSource.Id] = trickplayResolutions;
 505            }
 506        }
 507
 508        return trickplayManifest;
 509    }
 510
 511    /// <inheritdoc />
 512    public async Task<string> GetTrickplayTilePathAsync(BaseItem item, int width, int index, bool saveWithMedia)
 513    {
 514        var trickplayResolutions = await GetTrickplayResolutions(item.Id).ConfigureAwait(false);
 515        if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo))
 516        {
 517            return Path.Combine(GetTrickplayDirectory(item, trickplayInfo.TileWidth, trickplayInfo.TileHeight, width, sa
 518        }
 519
 520        return string.Empty;
 521    }
 522
 523    /// <inheritdoc />
 524    public async Task<string?> GetHlsPlaylist(Guid itemId, int width, string? apiKey)
 525    {
 526        var trickplayResolutions = await GetTrickplayResolutions(itemId).ConfigureAwait(false);
 527        if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo))
 528        {
 529            var builder = new StringBuilder(128);
 530
 531            if (trickplayInfo.ThumbnailCount > 0)
 532            {
 533                const string urlFormat = "{0}.jpg?MediaSourceId={1}&api_key={2}";
 534                const string decimalFormat = "{0:0.###}";
 535
 536                var resolution = $"{trickplayInfo.Width}x{trickplayInfo.Height}";
 537                var layout = $"{trickplayInfo.TileWidth}x{trickplayInfo.TileHeight}";
 538                var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight;
 539                var thumbnailDuration = trickplayInfo.Interval / 1000d;
 540                var infDuration = thumbnailDuration * thumbnailsPerTile;
 541                var tileCount = (int)Math.Ceiling((decimal)trickplayInfo.ThumbnailCount / thumbnailsPerTile);
 542
 543                builder
 544                    .AppendLine("#EXTM3U")
 545                    .Append("#EXT-X-TARGETDURATION:")
 546                    .AppendLine(tileCount.ToString(CultureInfo.InvariantCulture))
 547                    .AppendLine("#EXT-X-VERSION:7")
 548                    .AppendLine("#EXT-X-MEDIA-SEQUENCE:1")
 549                    .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD")
 550                    .AppendLine("#EXT-X-IMAGES-ONLY");
 551
 552                for (int i = 0; i < tileCount; i++)
 553                {
 554                    // All tiles prior to the last must contain full amount of thumbnails (no black).
 555                    if (i == tileCount - 1)
 556                    {
 557                        thumbnailsPerTile = trickplayInfo.ThumbnailCount - (i * thumbnailsPerTile);
 558                        infDuration = thumbnailDuration * thumbnailsPerTile;
 559                    }
 560
 561                    // EXTINF
 562                    builder
 563                        .Append("#EXTINF:")
 564                        .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, infDuration)
 565                        .AppendLine(",");
 566
 567                    // EXT-X-TILES
 568                    builder
 569                        .Append("#EXT-X-TILES:RESOLUTION=")
 570                        .Append(resolution)
 571                        .Append(",LAYOUT=")
 572                        .Append(layout)
 573                        .Append(",DURATION=")
 574                        .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, thumbnailDuration)
 575                        .AppendLine();
 576
 577                    // URL
 578                    builder
 579                        .AppendFormat(
 580                            CultureInfo.InvariantCulture,
 581                            urlFormat,
 582                            i.ToString(CultureInfo.InvariantCulture),
 583                            itemId.ToString("N"),
 584                            apiKey)
 585                        .AppendLine();
 586                }
 587
 588                builder.AppendLine("#EXT-X-ENDLIST");
 589                return builder.ToString();
 590            }
 591        }
 592
 593        return null;
 594    }
 595
 596    /// <inheritdoc />
 597    public string GetTrickplayDirectory(BaseItem item, int tileWidth, int tileHeight, int width, bool saveWithMedia = fa
 598    {
 0599        var path = saveWithMedia
 0600            ? Path.Combine(item.ContainingFolderPath, Path.ChangeExtension(item.Path, ".trickplay"))
 0601            : Path.Combine(item.GetInternalMetadataPath(), "trickplay");
 602
 0603        var subdirectory = string.Format(
 0604            CultureInfo.InvariantCulture,
 0605            "{0} - {1}x{2}",
 0606            width.ToString(CultureInfo.InvariantCulture),
 0607            tileWidth.ToString(CultureInfo.InvariantCulture),
 0608            tileHeight.ToString(CultureInfo.InvariantCulture));
 609
 0610        return Path.Combine(path, subdirectory);
 611    }
 612
 613    private async Task<bool> HasTrickplayResolutionAsync(Guid itemId, int width)
 614    {
 615        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 616        await using (dbContext.ConfigureAwait(false))
 617        {
 618            return await dbContext.TrickplayInfos
 619                .AsNoTracking()
 620                .Where(i => i.ItemId.Equals(itemId))
 621                .AnyAsync(i => i.Width == width)
 622                .ConfigureAwait(false);
 623        }
 624    }
 625}