< Summary - Jellyfin

Information
Class: Emby.Server.Implementations.Chapters.ChapterManager
Assembly: Emby.Server.Implementations
File(s): /srv/git/jellyfin/Emby.Server.Implementations/Chapters/ChapterManager.cs
Line coverage
14%
Covered lines: 7
Uncovered lines: 41
Coverable lines: 48
Total lines: 300
Line coverage: 14.5%
Branch coverage
0%
Covered branches: 0
Total branches: 18
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 7/19/2025 - 12:11:39 AM Line coverage: 12.2% (7/57) Branch coverage: 0% (0/20) Total lines: 31310/14/2025 - 12:11:23 AM Line coverage: 14.8% (7/47) Branch coverage: 0% (0/18) Total lines: 29810/28/2025 - 12:11:27 AM Line coverage: 14.5% (7/48) Branch coverage: 0% (0/18) Total lines: 300 7/19/2025 - 12:11:39 AM Line coverage: 12.2% (7/57) Branch coverage: 0% (0/20) Total lines: 31310/14/2025 - 12:11:23 AM Line coverage: 14.8% (7/47) Branch coverage: 0% (0/18) Total lines: 29810/28/2025 - 12:11:27 AM Line coverage: 14.5% (7/48) Branch coverage: 0% (0/18) Total lines: 300

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%11100%
IsEligibleForChapterImageExtraction(...)0%110100%
GetAverageDurationBetweenChapters(...)0%2040%
SaveChapters(...)100%210%
GetChapter(...)100%210%
GetChapters(...)100%210%
GetSavedChapterImages(...)0%620%
DeleteDeadImages(...)0%620%

File(s)

/srv/git/jellyfin/Emby.Server.Implementations/Chapters/ChapterManager.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.IO;
 4using System.Linq;
 5using System.Threading;
 6using System.Threading.Tasks;
 7using Jellyfin.Extensions;
 8using MediaBrowser.Controller.Chapters;
 9using MediaBrowser.Controller.Entities;
 10using MediaBrowser.Controller.IO;
 11using MediaBrowser.Controller.Library;
 12using MediaBrowser.Controller.MediaEncoding;
 13using MediaBrowser.Controller.Persistence;
 14using MediaBrowser.Controller.Providers;
 15using MediaBrowser.Model.Configuration;
 16using MediaBrowser.Model.Dto;
 17using MediaBrowser.Model.Entities;
 18using MediaBrowser.Model.IO;
 19using MediaBrowser.Model.MediaInfo;
 20using Microsoft.Extensions.Logging;
 21
 22namespace Emby.Server.Implementations.Chapters;
 23
 24/// <summary>
 25/// The chapter manager.
 26/// </summary>
 27public class ChapterManager : IChapterManager
 28{
 29    private readonly IFileSystem _fileSystem;
 30    private readonly ILogger<ChapterManager> _logger;
 31    private readonly IMediaEncoder _encoder;
 32    private readonly IChapterRepository _chapterRepository;
 33    private readonly ILibraryManager _libraryManager;
 34    private readonly IPathManager _pathManager;
 35
 36    /// <summary>
 37    /// The first chapter ticks.
 38    /// </summary>
 039    private static readonly long _firstChapterTicks = TimeSpan.FromSeconds(15).Ticks;
 40
 41    /// <summary>
 42    /// Initializes a new instance of the <see cref="ChapterManager"/> class.
 43    /// </summary>
 44    /// <param name="logger">The <see cref="ILogger{ChapterManager}"/>.</param>
 45    /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
 46    /// <param name="encoder">The <see cref="IMediaEncoder"/>.</param>
 47    /// <param name="chapterRepository">The <see cref="IChapterRepository"/>.</param>
 48    /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
 49    /// <param name="pathManager">The <see cref="IPathManager"/>.</param>
 50    public ChapterManager(
 51        ILogger<ChapterManager> logger,
 52        IFileSystem fileSystem,
 53        IMediaEncoder encoder,
 54        IChapterRepository chapterRepository,
 55        ILibraryManager libraryManager,
 56        IPathManager pathManager)
 57    {
 2158        _logger = logger;
 2159        _fileSystem = fileSystem;
 2160        _encoder = encoder;
 2161        _chapterRepository = chapterRepository;
 2162        _libraryManager = libraryManager;
 2163        _pathManager = pathManager;
 2164    }
 65
 66    /// <summary>
 67    /// Determines whether [is eligible for chapter image extraction] [the specified video].
 68    /// </summary>
 69    /// <param name="video">The video.</param>
 70    /// <param name="libraryOptions">The library options for the video.</param>
 71    /// <returns><c>true</c> if [is eligible for chapter image extraction] [the specified video]; otherwise, <c>false</c
 72    private bool IsEligibleForChapterImageExtraction(Video video, LibraryOptions libraryOptions)
 73    {
 074        if (video.IsPlaceHolder)
 75        {
 076            return false;
 77        }
 78
 079        if (libraryOptions is null || !libraryOptions.EnableChapterImageExtraction)
 80        {
 081            return false;
 82        }
 83
 084        if (video.IsShortcut)
 85        {
 086            return false;
 87        }
 88
 089        if (!video.IsCompleteMedia)
 90        {
 091            return false;
 92        }
 93
 94        // Can't extract images if there are no video streams
 095        return video.DefaultVideoStreamIndex.HasValue;
 96    }
 97
 98    private long GetAverageDurationBetweenChapters(IReadOnlyList<ChapterInfo> chapters)
 99    {
 0100        if (chapters.Count < 2)
 101        {
 0102            return 0;
 103        }
 104
 0105        long sum = 0;
 0106        for (int i = 1; i < chapters.Count; i++)
 107        {
 0108            sum += chapters[i].StartPositionTicks - chapters[i - 1].StartPositionTicks;
 109        }
 110
 0111        return sum / chapters.Count;
 112    }
 113
 114    /// <inheritdoc />
 115    public async Task<bool> RefreshChapterImages(Video video, IDirectoryService directoryService, IReadOnlyList<ChapterI
 116    {
 117        if (chapters.Count == 0)
 118        {
 119            return true;
 120        }
 121
 122        var libraryOptions = _libraryManager.GetLibraryOptions(video);
 123
 124        if (!IsEligibleForChapterImageExtraction(video, libraryOptions))
 125        {
 126            extractImages = false;
 127        }
 128
 129        var averageChapterDuration = GetAverageDurationBetweenChapters(chapters);
 130        var threshold = TimeSpan.FromSeconds(1).Ticks;
 131        if (averageChapterDuration < threshold)
 132        {
 133            _logger.LogInformation("Skipping chapter image extraction for {Video} as the average chapter duration {Avera
 134            extractImages = false;
 135        }
 136
 137        var success = true;
 138        var changesMade = false;
 139
 140        var runtimeTicks = video.RunTimeTicks ?? 0;
 141
 142        var currentImages = GetSavedChapterImages(video, directoryService);
 143
 144        foreach (var chapter in chapters)
 145        {
 146            if (chapter.StartPositionTicks >= runtimeTicks)
 147            {
 148                _logger.LogInformation("Stopping chapter extraction for {0} because a chapter was found with a position 
 149                break;
 150            }
 151
 152            var path = _pathManager.GetChapterImagePath(video, chapter.StartPositionTicks);
 153
 154            if (!currentImages.Contains(path, StringComparison.OrdinalIgnoreCase))
 155            {
 156                if (extractImages)
 157                {
 158                    cancellationToken.ThrowIfCancellationRequested();
 159
 160                    try
 161                    {
 162                        // Add some time for the first chapter to make sure we don't end up with a black image
 163                        var time = chapter.StartPositionTicks == 0 ? TimeSpan.FromTicks(Math.Min(_firstChapterTicks, vid
 164
 165                        var inputPath = video.Path;
 166                        var directoryPath = Path.GetDirectoryName(path);
 167                        if (!string.IsNullOrEmpty(directoryPath))
 168                        {
 169                            Directory.CreateDirectory(directoryPath);
 170                        }
 171
 172                        var container = video.Container;
 173                        var mediaSource = new MediaSourceInfo
 174                        {
 175                            VideoType = video.VideoType,
 176                            IsoType = video.IsoType,
 177                            Protocol = video.PathProtocol ?? MediaProtocol.File,
 178                        };
 179
 180                        _logger.LogInformation("Extracting chapter image for {Name} at {Path}", video.Name, inputPath);
 181                        var tempFile = await _encoder.ExtractVideoImage(inputPath, container, mediaSource, video.GetDefa
 182                        File.Copy(tempFile, path, true);
 183
 184                        try
 185                        {
 186                            _fileSystem.DeleteFile(tempFile);
 187                        }
 188                        catch (IOException ex)
 189                        {
 190                            _logger.LogError(ex, "Error deleting temporary chapter image encoding file {Path}", tempFile
 191                        }
 192
 193                        chapter.ImagePath = path;
 194                        chapter.ImageDateModified = _fileSystem.GetLastWriteTimeUtc(path);
 195                        changesMade = true;
 196                    }
 197                    catch (Exception ex)
 198                    {
 199                        _logger.LogError(ex, "Error extracting chapter images for {0}", string.Join(',', video.Path));
 200                        success = false;
 201                        break;
 202                    }
 203                }
 204                else if (!string.IsNullOrEmpty(chapter.ImagePath))
 205                {
 206                    chapter.ImagePath = null;
 207                    changesMade = true;
 208                }
 209            }
 210            else if (!string.Equals(path, chapter.ImagePath, StringComparison.OrdinalIgnoreCase))
 211            {
 212                chapter.ImagePath = path;
 213                chapter.ImageDateModified = _fileSystem.GetLastWriteTimeUtc(path);
 214                changesMade = true;
 215            }
 216            else if (libraryOptions?.EnableChapterImageExtraction != true)
 217            {
 218                // We have an image for the current chapter but the user has disabled chapter image extraction -> delete
 219                chapter.ImagePath = null;
 220                changesMade = true;
 221            }
 222        }
 223
 224        if (saveChapters && changesMade)
 225        {
 226            SaveChapters(video, chapters);
 227        }
 228
 229        DeleteDeadImages(currentImages, chapters);
 230
 231        return success;
 232    }
 233
 234    /// <inheritdoc />
 235    public void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters)
 236    {
 237        // Remove any chapters that are outside of the runtime of the video
 0238        var validChapters = chapters.Where(c => c.StartPositionTicks < video.RunTimeTicks).ToList();
 0239        _chapterRepository.SaveChapters(video.Id, validChapters);
 0240    }
 241
 242    /// <inheritdoc />
 243    public ChapterInfo? GetChapter(Guid baseItemId, int index)
 244    {
 0245        return _chapterRepository.GetChapter(baseItemId, index);
 246    }
 247
 248    /// <inheritdoc />
 249    public IReadOnlyList<ChapterInfo> GetChapters(Guid baseItemId)
 250    {
 0251        return _chapterRepository.GetChapters(baseItemId);
 252    }
 253
 254    /// <inheritdoc />
 255    public async Task DeleteChapterDataAsync(Guid itemId, CancellationToken cancellationToken)
 256    {
 257        await _chapterRepository.DeleteChaptersAsync(itemId, cancellationToken).ConfigureAwait(false);
 258    }
 259
 260    private IReadOnlyList<string> GetSavedChapterImages(Video video, IDirectoryService directoryService)
 261    {
 0262        var path = _pathManager.GetChapterImageFolderPath(video);
 0263        if (!Directory.Exists(path))
 264        {
 0265            return [];
 266        }
 267
 268        try
 269        {
 0270            return directoryService.GetFilePaths(path);
 271        }
 0272        catch (IOException)
 273        {
 0274            return [];
 275        }
 0276    }
 277
 278    private void DeleteDeadImages(IEnumerable<string> images, IEnumerable<ChapterInfo> chapters)
 279    {
 0280        var existingImages = chapters.Select(i => i.ImagePath).Where(i => !string.IsNullOrEmpty(i));
 0281        var deadImages = images
 0282            .Except(existingImages, StringComparer.OrdinalIgnoreCase)
 0283            .Where(i => BaseItem.SupportedImageExtensions.Contains(Path.GetExtension(i.AsSpan()), StringComparison.Ordin
 0284            .ToList();
 285
 0286        foreach (var image in deadImages)
 287        {
 0288            _logger.LogDebug("Deleting dead chapter image {Path}", image);
 289
 290            try
 291            {
 0292                _fileSystem.DeleteFile(image!);
 0293            }
 0294            catch (IOException ex)
 295            {
 0296                _logger.LogError(ex, "Error deleting {Path}.", image);
 0297            }
 298        }
 0299    }
 300}