< Summary - Jellyfin

Information
Class: MediaBrowser.Providers.Subtitles.SubtitleManager
Assembly: MediaBrowser.Providers
File(s): /srv/git/jellyfin/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
Line coverage
6%
Covered lines: 13
Uncovered lines: 188
Coverable lines: 201
Total lines: 447
Line coverage: 6.4%
Branch coverage
0%
Covered branches: 0
Total branches: 54
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/12/2026 - 12:15:57 AM Line coverage: 6% (12/198) Branch coverage: 0% (0/54) Total lines: 44010/29/2026 - 12:17:11 AM Line coverage: 6.4% (13/201) Branch coverage: 0% (0/54) Total lines: 447 5/12/2026 - 12:15:57 AM Line coverage: 6% (12/198) Branch coverage: 0% (0/54) Total lines: 44010/29/2026 - 12:17:11 AM Line coverage: 6.4% (13/201) Branch coverage: 0% (0/54) Total lines: 447

Coverage delta

Coverage delta 1 -1

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
SearchSubtitles()0%110100%
DownloadSubtitles(...)100%210%
DownloadSubtitles()0%620%
UploadSubtitle(...)0%2040%
TrySaveSubtitle()0%7280%
TrySaveToFiles()0%272160%
SearchSubtitles(...)0%7280%
Normalize(...)0%620%
GetProviderId(...)100%210%
GetProvider(...)100%210%
DeleteSubtitles(...)100%210%
GetRemoteSubtitles(...)100%210%
GetSupportedProviders(...)0%2040%

File(s)

/srv/git/jellyfin/MediaBrowser.Providers/Subtitles/SubtitleManager.cs

#LineLine coverage
 1#pragma warning disable CS1591
 2
 3using System;
 4using System.Collections.Generic;
 5using System.Globalization;
 6using System.IO;
 7using System.Linq;
 8using System.Threading;
 9using System.Threading.Tasks;
 10using Emby.Naming.Common;
 11using Jellyfin.Extensions;
 12using MediaBrowser.Common.Extensions;
 13using MediaBrowser.Controller.Entities;
 14using MediaBrowser.Controller.Entities.Movies;
 15using MediaBrowser.Controller.Entities.TV;
 16using MediaBrowser.Controller.Library;
 17using MediaBrowser.Controller.Persistence;
 18using MediaBrowser.Controller.Providers;
 19using MediaBrowser.Controller.Subtitles;
 20using MediaBrowser.Model.Configuration;
 21using MediaBrowser.Model.Entities;
 22using MediaBrowser.Model.Globalization;
 23using MediaBrowser.Model.IO;
 24using MediaBrowser.Model.Providers;
 25using Microsoft.Extensions.Logging;
 26
 27namespace MediaBrowser.Providers.Subtitles
 28{
 29    public class SubtitleManager : ISubtitleManager
 30    {
 31        private readonly ILogger<SubtitleManager> _logger;
 32        private readonly IFileSystem _fileSystem;
 33        private readonly ILibraryMonitor _monitor;
 34        private readonly IMediaSourceManager _mediaSourceManager;
 35        private readonly ILocalizationManager _localization;
 36        private readonly IDirectoryService _directoryService;
 37        private readonly HashSet<string> _allowedSubtitleFormats;
 38
 39        private readonly ISubtitleProvider[] _subtitleProviders;
 40
 41        public SubtitleManager(
 42            ILogger<SubtitleManager> logger,
 43            IFileSystem fileSystem,
 44            ILibraryMonitor monitor,
 45            IMediaSourceManager mediaSourceManager,
 46            ILocalizationManager localizationManager,
 47            IDirectoryService directoryService,
 48            IEnumerable<ISubtitleProvider> subtitleProviders,
 49            NamingOptions namingOptions)
 50        {
 2751            _logger = logger;
 2752            _fileSystem = fileSystem;
 2753            _monitor = monitor;
 2754            _mediaSourceManager = mediaSourceManager;
 2755            _localization = localizationManager;
 2756            _directoryService = directoryService;
 2757            _subtitleProviders = subtitleProviders
 2758                .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
 2759                .ToArray();
 2760            _allowedSubtitleFormats = new HashSet<string>(
 2761                namingOptions.SubtitleFileExtensions.Select(e => e.TrimStart('.')),
 2762                StringComparer.OrdinalIgnoreCase);
 2763        }
 64
 65        /// <inheritdoc />
 66        public event EventHandler<SubtitleDownloadFailureEventArgs>? SubtitleDownloadFailure;
 67
 68        /// <inheritdoc />
 69        public async Task<RemoteSubtitleInfo[]> SearchSubtitles(SubtitleSearchRequest request, CancellationToken cancell
 70        {
 071            if (request.Language is not null)
 72            {
 073                var culture = _localization.FindLanguageInfo(request.Language);
 74
 075                if (culture is not null)
 76                {
 077                    request.TwoLetterISOLanguageName = culture.TwoLetterISOLanguageName;
 78                }
 79            }
 80
 081            var contentType = request.ContentType;
 082            var providers = _subtitleProviders
 083                .Where(i => i.SupportedMediaTypes.Contains(contentType) && !request.DisabledSubtitleFetchers.Contains(i.
 084                .OrderBy(i =>
 085                {
 086                    var index = request.SubtitleFetcherOrder.IndexOf(i.Name);
 087                    return index == -1 ? int.MaxValue : index;
 088                })
 089                .ToArray();
 90
 91            // If not searching all, search one at a time until something is found
 092            if (!request.SearchAllProviders)
 93            {
 094                foreach (var provider in providers)
 95                {
 96                    try
 97                    {
 098                        var searchResults = await provider.Search(request, cancellationToken).ConfigureAwait(false);
 99
 0100                        var list = searchResults.ToArray();
 101
 0102                        if (list.Length > 0)
 103                        {
 0104                            Normalize(list);
 0105                            return list;
 106                        }
 0107                    }
 0108                    catch (Exception ex)
 109                    {
 0110                        _logger.LogError(ex, "Error downloading subtitles from {Provider}", provider.Name);
 0111                    }
 0112                }
 113
 0114                return Array.Empty<RemoteSubtitleInfo>();
 115            }
 116
 0117            var tasks = providers.Select(async i =>
 0118            {
 0119                try
 0120                {
 0121                    var searchResults = await i.Search(request, cancellationToken).ConfigureAwait(false);
 0122
 0123                    var list = searchResults.ToArray();
 0124                    Normalize(list);
 0125                    return list;
 0126                }
 0127                catch (Exception ex)
 0128                {
 0129                    _logger.LogError(ex, "Error downloading subtitles from {Name}", i.Name);
 0130                    return Array.Empty<RemoteSubtitleInfo>();
 0131                }
 0132            });
 133
 0134            var results = await Task.WhenAll(tasks).ConfigureAwait(false);
 135
 0136            return results.SelectMany(i => i).ToArray();
 0137        }
 138
 139        /// <inheritdoc />
 140        public Task DownloadSubtitles(Video video, string subtitleId, CancellationToken cancellationToken)
 141        {
 0142            var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(video);
 143
 0144            return DownloadSubtitles(video, libraryOptions, subtitleId, cancellationToken);
 145        }
 146
 147        /// <inheritdoc />
 148        public async Task DownloadSubtitles(
 149            Video video,
 150            LibraryOptions libraryOptions,
 151            string subtitleId,
 152            CancellationToken cancellationToken)
 153        {
 0154            var parts = subtitleId.Split('_', 2);
 0155            var provider = GetProvider(parts[0]);
 156
 157            try
 158            {
 0159                var response = await GetRemoteSubtitles(subtitleId, cancellationToken).ConfigureAwait(false);
 160
 0161                await TrySaveSubtitle(video, libraryOptions, response).ConfigureAwait(false);
 0162            }
 0163            catch (RateLimitExceededException)
 164            {
 0165                throw;
 166            }
 0167            catch (Exception ex)
 168            {
 0169                SubtitleDownloadFailure?.Invoke(this, new SubtitleDownloadFailureEventArgs
 0170                {
 0171                    Item = video,
 0172                    Exception = ex,
 0173                    Provider = provider.Name
 0174                });
 175
 0176                throw;
 177            }
 0178        }
 179
 180        /// <inheritdoc />
 181        public Task UploadSubtitle(Video video, SubtitleResponse response)
 182        {
 0183            var format = response.Format;
 0184            if (string.IsNullOrEmpty(format) || !_allowedSubtitleFormats.Contains(format))
 185            {
 0186                throw new ArgumentException($"Unsupported subtitle format: '{format}'");
 187            }
 188
 0189            var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(video);
 0190            return TrySaveSubtitle(video, libraryOptions, response);
 191        }
 192
 193        private async Task TrySaveSubtitle(
 194            Video video,
 195            LibraryOptions libraryOptions,
 196            SubtitleResponse response)
 197        {
 0198            var saveInMediaFolder = libraryOptions.SaveSubtitlesWithMedia;
 199
 0200            var memoryStream = new MemoryStream();
 0201            await using (memoryStream.ConfigureAwait(false))
 202            {
 0203                var stream = response.Stream;
 0204                await using (stream.ConfigureAwait(false))
 205                {
 0206                    await stream.CopyToAsync(memoryStream).ConfigureAwait(false);
 0207                    memoryStream.Position = 0;
 208                }
 209
 0210                var savePaths = new List<string>();
 0211                var language = response.Language.ToLowerInvariant();
 0212                if (language.AsSpan().IndexOfAny(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) >= 0)
 213                {
 0214                    throw new ArgumentException("Language contains invalid characters.");
 215                }
 216
 0217                var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + language;
 218
 0219                if (response.IsForced)
 220                {
 0221                    saveFileName += ".forced";
 222                }
 223
 0224                if (response.IsHearingImpaired)
 225                {
 0226                    saveFileName += ".sdh";
 227                }
 228
 0229                if (saveInMediaFolder)
 230                {
 0231                    var mediaFolderPath = Path.GetFullPath(Path.Combine(video.ContainingFolderPath, saveFileName));
 0232                    savePaths.Add(mediaFolderPath);
 233                }
 234                else
 235                {
 0236                    var internalPath = Path.GetFullPath(Path.Combine(video.GetInternalMetadataPath(), saveFileName));
 0237                    savePaths.Add(internalPath);
 238                }
 239
 0240                await TrySaveToFiles(memoryStream, savePaths, video, response.Format.ToLowerInvariant()).ConfigureAwait(
 241            }
 0242        }
 243
 244        private async Task TrySaveToFiles(Stream stream, List<string> savePaths, Video video, string extension)
 245        {
 0246            if (!_allowedSubtitleFormats.Contains(extension, StringComparison.OrdinalIgnoreCase))
 247            {
 0248                throw new ArgumentException($"Invalid subtitle format: {extension}");
 249            }
 250
 0251            List<Exception>? exs = null;
 252
 0253            foreach (var savePath in savePaths)
 254            {
 0255                var path = Path.GetFullPath(savePath + "." + extension);
 256                try
 257                {
 0258                    var containingFolder = video.ContainingFolderPath + Path.DirectorySeparatorChar;
 0259                    var metadataFolder = video.GetInternalMetadataPath() + Path.DirectorySeparatorChar;
 0260                    if (path.StartsWith(containingFolder, StringComparison.Ordinal)
 0261                            || path.StartsWith(metadataFolder, StringComparison.Ordinal))
 262                    {
 0263                        var fileExists = File.Exists(path);
 0264                        var counter = 0;
 265
 0266                        while (fileExists)
 267                        {
 0268                            path = string.Format(CultureInfo.InvariantCulture, "{0}.{1}.{2}", savePath, counter, extensi
 0269                            fileExists = File.Exists(path);
 0270                            counter++;
 271                        }
 272
 0273                        _logger.LogInformation("Saving subtitles to {SavePath}", path);
 0274                        _monitor.ReportFileSystemChangeBeginning(path);
 275
 0276                        Directory.CreateDirectory(Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Pa
 277
 0278                        var fileOptions = AsyncFile.WriteOptions;
 0279                        fileOptions.Mode = FileMode.CreateNew;
 0280                        fileOptions.PreallocationSize = stream.Length;
 0281                        var fs = new FileStream(path, fileOptions);
 0282                        await using (fs.ConfigureAwait(false))
 283                        {
 0284                            await stream.CopyToAsync(fs).ConfigureAwait(false);
 285                        }
 286
 0287                        _directoryService.Invalidate(path);
 288
 0289                        return;
 290                    }
 291                    else
 292                    {
 293                        // TODO: Add some error handling to the API user: return BadRequest("Could not save subtitle, ba
 0294                        _logger.LogError("An uploaded subtitle could not be saved because the resulting path was invalid
 295                    }
 0296                }
 0297                catch (Exception ex)
 298                {
 0299                    (exs ??= []).Add(ex);
 0300                }
 301                finally
 302                {
 0303                    _monitor.ReportFileSystemChangeComplete(path, false);
 304                }
 305
 0306                stream.Position = 0;
 0307            }
 308
 0309            if (exs is not null)
 310            {
 0311                throw new AggregateException(exs);
 312            }
 0313        }
 314
 315        /// <inheritdoc />
 316        public Task<RemoteSubtitleInfo[]> SearchSubtitles(Video video, string language, bool? isPerfectMatch, bool isAut
 317        {
 0318            if (video.VideoType != VideoType.VideoFile)
 319            {
 0320                return Task.FromResult(Array.Empty<RemoteSubtitleInfo>());
 321            }
 322
 323            VideoContentType mediaType;
 324
 0325            if (video is Episode)
 326            {
 0327                mediaType = VideoContentType.Episode;
 328            }
 0329            else if (video is Movie)
 330            {
 0331                mediaType = VideoContentType.Movie;
 332            }
 333            else
 334            {
 335                // These are the only supported types
 0336                return Task.FromResult(Array.Empty<RemoteSubtitleInfo>());
 337            }
 338
 0339            var request = new SubtitleSearchRequest
 0340            {
 0341                ContentType = mediaType,
 0342                IndexNumber = video.IndexNumber,
 0343                Language = language,
 0344                MediaPath = video.Path,
 0345                Name = video.Name,
 0346                ParentIndexNumber = video.ParentIndexNumber,
 0347                ProductionYear = video.ProductionYear,
 0348                ProviderIds = video.ProviderIds,
 0349                RuntimeTicks = video.RunTimeTicks,
 0350                IsPerfectMatch = isPerfectMatch ?? false,
 0351                IsAutomated = isAutomated
 0352            };
 353
 0354            if (video is Episode episode)
 355            {
 0356                request.IndexNumberEnd = episode.IndexNumberEnd;
 0357                request.SeriesName = episode.SeriesName;
 358            }
 359
 0360            return SearchSubtitles(request, cancellationToken);
 361        }
 362
 363        private void Normalize(IEnumerable<RemoteSubtitleInfo> subtitles)
 364        {
 0365            foreach (var sub in subtitles)
 366            {
 0367                sub.Id = GetProviderId(sub.ProviderName) + "_" + sub.Id;
 368            }
 0369        }
 370
 371        private string GetProviderId(string name)
 372        {
 0373            return name.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture);
 374        }
 375
 376        private ISubtitleProvider GetProvider(string id)
 377        {
 0378            return _subtitleProviders.First(i => string.Equals(id, GetProviderId(i.Name), StringComparison.Ordinal));
 379        }
 380
 381        /// <inheritdoc />
 382        public Task DeleteSubtitles(BaseItem item, int index)
 383        {
 0384            var stream = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery
 0385            {
 0386                Index = index,
 0387                ItemId = item.Id,
 0388                Type = MediaStreamType.Subtitle
 0389            })[0];
 390
 0391            var path = stream.Path;
 0392            _monitor.ReportFileSystemChangeBeginning(path);
 393
 394            try
 395            {
 0396                _fileSystem.DeleteFile(path);
 0397            }
 398            finally
 399            {
 0400                _monitor.ReportFileSystemChangeComplete(path, false);
 0401            }
 402
 0403            _directoryService.Invalidate(path);
 404
 0405            return item.RefreshMetadata(CancellationToken.None);
 406        }
 407
 408        /// <inheritdoc />
 409        public Task<SubtitleResponse> GetRemoteSubtitles(string id, CancellationToken cancellationToken)
 410        {
 0411            var parts = id.Split('_', 2);
 412
 0413            var provider = GetProvider(parts[0]);
 0414            id = parts[^1];
 415
 0416            return provider.GetSubtitles(id, cancellationToken);
 417        }
 418
 419        /// <inheritdoc />
 420        public SubtitleProviderInfo[] GetSupportedProviders(BaseItem item)
 421        {
 422            VideoContentType mediaType;
 423
 0424            if (item is Episode)
 425            {
 0426                mediaType = VideoContentType.Episode;
 427            }
 0428            else if (item is Movie)
 429            {
 0430                mediaType = VideoContentType.Movie;
 431            }
 432            else
 433            {
 434                // These are the only supported types
 0435                return Array.Empty<SubtitleProviderInfo>();
 436            }
 437
 0438            return _subtitleProviders
 0439                .Where(i => i.SupportedMediaTypes.Contains(mediaType))
 0440                .Select(i => new SubtitleProviderInfo
 0441                {
 0442                    Name = i.Name,
 0443                    Id = GetProviderId(i.Name)
 0444                }).ToArray();
 445        }
 446    }
 447}