< 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: 12
Uncovered lines: 186
Coverable lines: 198
Total lines: 440
Line coverage: 6%
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 2/15/2026 - 12:13:43 AM Line coverage: 12.5% (9/72) Branch coverage: 0% (0/14) Total lines: 4144/7/2026 - 12:14:03 AM Line coverage: 15.3% (12/78) Branch coverage: 0% (0/18) Total lines: 4394/19/2026 - 12:14:27 AM Line coverage: 6% (12/198) Branch coverage: 0% (0/54) Total lines: 4395/7/2026 - 12:15:44 AM Line coverage: 6% (12/198) Branch coverage: 0% (0/54) Total lines: 440 2/15/2026 - 12:13:43 AM Line coverage: 12.5% (9/72) Branch coverage: 0% (0/14) Total lines: 4144/7/2026 - 12:14:03 AM Line coverage: 15.3% (12/78) Branch coverage: 0% (0/18) Total lines: 4394/19/2026 - 12:14:27 AM Line coverage: 6% (12/198) Branch coverage: 0% (0/54) Total lines: 4395/7/2026 - 12:15:44 AM Line coverage: 6% (12/198) Branch coverage: 0% (0/54) Total lines: 440

Coverage delta

Coverage delta 10 -10

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 HashSet<string> _allowedSubtitleFormats;
 37
 38        private readonly ISubtitleProvider[] _subtitleProviders;
 39
 40        public SubtitleManager(
 41            ILogger<SubtitleManager> logger,
 42            IFileSystem fileSystem,
 43            ILibraryMonitor monitor,
 44            IMediaSourceManager mediaSourceManager,
 45            ILocalizationManager localizationManager,
 46            IEnumerable<ISubtitleProvider> subtitleProviders,
 47            NamingOptions namingOptions)
 48        {
 2149            _logger = logger;
 2150            _fileSystem = fileSystem;
 2151            _monitor = monitor;
 2152            _mediaSourceManager = mediaSourceManager;
 2153            _localization = localizationManager;
 2154            _subtitleProviders = subtitleProviders
 2155                .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
 2156                .ToArray();
 2157            _allowedSubtitleFormats = new HashSet<string>(
 2158                namingOptions.SubtitleFileExtensions.Select(e => e.TrimStart('.')),
 2159                StringComparer.OrdinalIgnoreCase);
 2160        }
 61
 62        /// <inheritdoc />
 63        public event EventHandler<SubtitleDownloadFailureEventArgs>? SubtitleDownloadFailure;
 64
 65        /// <inheritdoc />
 66        public async Task<RemoteSubtitleInfo[]> SearchSubtitles(SubtitleSearchRequest request, CancellationToken cancell
 67        {
 068            if (request.Language is not null)
 69            {
 070                var culture = _localization.FindLanguageInfo(request.Language);
 71
 072                if (culture is not null)
 73                {
 074                    request.TwoLetterISOLanguageName = culture.TwoLetterISOLanguageName;
 75                }
 76            }
 77
 078            var contentType = request.ContentType;
 079            var providers = _subtitleProviders
 080                .Where(i => i.SupportedMediaTypes.Contains(contentType) && !request.DisabledSubtitleFetchers.Contains(i.
 081                .OrderBy(i =>
 082                {
 083                    var index = request.SubtitleFetcherOrder.IndexOf(i.Name);
 084                    return index == -1 ? int.MaxValue : index;
 085                })
 086                .ToArray();
 87
 88            // If not searching all, search one at a time until something is found
 089            if (!request.SearchAllProviders)
 90            {
 091                foreach (var provider in providers)
 92                {
 93                    try
 94                    {
 095                        var searchResults = await provider.Search(request, cancellationToken).ConfigureAwait(false);
 96
 097                        var list = searchResults.ToArray();
 98
 099                        if (list.Length > 0)
 100                        {
 0101                            Normalize(list);
 0102                            return list;
 103                        }
 0104                    }
 0105                    catch (Exception ex)
 106                    {
 0107                        _logger.LogError(ex, "Error downloading subtitles from {Provider}", provider.Name);
 0108                    }
 0109                }
 110
 0111                return Array.Empty<RemoteSubtitleInfo>();
 112            }
 113
 0114            var tasks = providers.Select(async i =>
 0115            {
 0116                try
 0117                {
 0118                    var searchResults = await i.Search(request, cancellationToken).ConfigureAwait(false);
 0119
 0120                    var list = searchResults.ToArray();
 0121                    Normalize(list);
 0122                    return list;
 0123                }
 0124                catch (Exception ex)
 0125                {
 0126                    _logger.LogError(ex, "Error downloading subtitles from {Name}", i.Name);
 0127                    return Array.Empty<RemoteSubtitleInfo>();
 0128                }
 0129            });
 130
 0131            var results = await Task.WhenAll(tasks).ConfigureAwait(false);
 132
 0133            return results.SelectMany(i => i).ToArray();
 0134        }
 135
 136        /// <inheritdoc />
 137        public Task DownloadSubtitles(Video video, string subtitleId, CancellationToken cancellationToken)
 138        {
 0139            var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(video);
 140
 0141            return DownloadSubtitles(video, libraryOptions, subtitleId, cancellationToken);
 142        }
 143
 144        /// <inheritdoc />
 145        public async Task DownloadSubtitles(
 146            Video video,
 147            LibraryOptions libraryOptions,
 148            string subtitleId,
 149            CancellationToken cancellationToken)
 150        {
 0151            var parts = subtitleId.Split('_', 2);
 0152            var provider = GetProvider(parts[0]);
 153
 154            try
 155            {
 0156                var response = await GetRemoteSubtitles(subtitleId, cancellationToken).ConfigureAwait(false);
 157
 0158                await TrySaveSubtitle(video, libraryOptions, response).ConfigureAwait(false);
 0159            }
 0160            catch (RateLimitExceededException)
 161            {
 0162                throw;
 163            }
 0164            catch (Exception ex)
 165            {
 0166                SubtitleDownloadFailure?.Invoke(this, new SubtitleDownloadFailureEventArgs
 0167                {
 0168                    Item = video,
 0169                    Exception = ex,
 0170                    Provider = provider.Name
 0171                });
 172
 0173                throw;
 174            }
 0175        }
 176
 177        /// <inheritdoc />
 178        public Task UploadSubtitle(Video video, SubtitleResponse response)
 179        {
 0180            var format = response.Format;
 0181            if (string.IsNullOrEmpty(format) || !_allowedSubtitleFormats.Contains(format))
 182            {
 0183                throw new ArgumentException($"Unsupported subtitle format: '{format}'");
 184            }
 185
 0186            var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(video);
 0187            return TrySaveSubtitle(video, libraryOptions, response);
 188        }
 189
 190        private async Task TrySaveSubtitle(
 191            Video video,
 192            LibraryOptions libraryOptions,
 193            SubtitleResponse response)
 194        {
 0195            var saveInMediaFolder = libraryOptions.SaveSubtitlesWithMedia;
 196
 0197            var memoryStream = new MemoryStream();
 0198            await using (memoryStream.ConfigureAwait(false))
 199            {
 0200                var stream = response.Stream;
 0201                await using (stream.ConfigureAwait(false))
 202                {
 0203                    await stream.CopyToAsync(memoryStream).ConfigureAwait(false);
 0204                    memoryStream.Position = 0;
 205                }
 206
 0207                var savePaths = new List<string>();
 0208                var language = response.Language.ToLowerInvariant();
 0209                if (language.AsSpan().IndexOfAny(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) >= 0)
 210                {
 0211                    throw new ArgumentException("Language contains invalid characters.");
 212                }
 213
 0214                var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + language;
 215
 0216                if (response.IsForced)
 217                {
 0218                    saveFileName += ".forced";
 219                }
 220
 0221                if (response.IsHearingImpaired)
 222                {
 0223                    saveFileName += ".sdh";
 224                }
 225
 0226                if (saveInMediaFolder)
 227                {
 0228                    var mediaFolderPath = Path.GetFullPath(Path.Combine(video.ContainingFolderPath, saveFileName));
 0229                    savePaths.Add(mediaFolderPath);
 230                }
 231                else
 232                {
 0233                    var internalPath = Path.GetFullPath(Path.Combine(video.GetInternalMetadataPath(), saveFileName));
 0234                    savePaths.Add(internalPath);
 235                }
 236
 0237                await TrySaveToFiles(memoryStream, savePaths, video, response.Format.ToLowerInvariant()).ConfigureAwait(
 238            }
 0239        }
 240
 241        private async Task TrySaveToFiles(Stream stream, List<string> savePaths, Video video, string extension)
 242        {
 0243            if (!_allowedSubtitleFormats.Contains(extension, StringComparison.OrdinalIgnoreCase))
 244            {
 0245                throw new ArgumentException($"Invalid subtitle format: {extension}");
 246            }
 247
 0248            List<Exception>? exs = null;
 249
 0250            foreach (var savePath in savePaths)
 251            {
 0252                var path = Path.GetFullPath(savePath + "." + extension);
 253                try
 254                {
 0255                    var containingFolder = video.ContainingFolderPath + Path.DirectorySeparatorChar;
 0256                    var metadataFolder = video.GetInternalMetadataPath() + Path.DirectorySeparatorChar;
 0257                    if (path.StartsWith(containingFolder, StringComparison.Ordinal)
 0258                            || path.StartsWith(metadataFolder, StringComparison.Ordinal))
 259                    {
 0260                        var fileExists = File.Exists(path);
 0261                        var counter = 0;
 262
 0263                        while (fileExists)
 264                        {
 0265                            path = string.Format(CultureInfo.InvariantCulture, "{0}.{1}.{2}", savePath, counter, extensi
 0266                            fileExists = File.Exists(path);
 0267                            counter++;
 268                        }
 269
 0270                        _logger.LogInformation("Saving subtitles to {SavePath}", path);
 0271                        _monitor.ReportFileSystemChangeBeginning(path);
 272
 0273                        Directory.CreateDirectory(Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Pa
 274
 0275                        var fileOptions = AsyncFile.WriteOptions;
 0276                        fileOptions.Mode = FileMode.CreateNew;
 0277                        fileOptions.PreallocationSize = stream.Length;
 0278                        var fs = new FileStream(path, fileOptions);
 0279                        await using (fs.ConfigureAwait(false))
 280                        {
 0281                            await stream.CopyToAsync(fs).ConfigureAwait(false);
 282                        }
 283
 0284                        return;
 285                    }
 286                    else
 287                    {
 288                        // TODO: Add some error handling to the API user: return BadRequest("Could not save subtitle, ba
 0289                        _logger.LogError("An uploaded subtitle could not be saved because the resulting path was invalid
 290                    }
 0291                }
 0292                catch (Exception ex)
 293                {
 0294                    (exs ??= []).Add(ex);
 0295                }
 296                finally
 297                {
 0298                    _monitor.ReportFileSystemChangeComplete(path, false);
 299                }
 300
 0301                stream.Position = 0;
 0302            }
 303
 0304            if (exs is not null)
 305            {
 0306                throw new AggregateException(exs);
 307            }
 0308        }
 309
 310        /// <inheritdoc />
 311        public Task<RemoteSubtitleInfo[]> SearchSubtitles(Video video, string language, bool? isPerfectMatch, bool isAut
 312        {
 0313            if (video.VideoType != VideoType.VideoFile)
 314            {
 0315                return Task.FromResult(Array.Empty<RemoteSubtitleInfo>());
 316            }
 317
 318            VideoContentType mediaType;
 319
 0320            if (video is Episode)
 321            {
 0322                mediaType = VideoContentType.Episode;
 323            }
 0324            else if (video is Movie)
 325            {
 0326                mediaType = VideoContentType.Movie;
 327            }
 328            else
 329            {
 330                // These are the only supported types
 0331                return Task.FromResult(Array.Empty<RemoteSubtitleInfo>());
 332            }
 333
 0334            var request = new SubtitleSearchRequest
 0335            {
 0336                ContentType = mediaType,
 0337                IndexNumber = video.IndexNumber,
 0338                Language = language,
 0339                MediaPath = video.Path,
 0340                Name = video.Name,
 0341                ParentIndexNumber = video.ParentIndexNumber,
 0342                ProductionYear = video.ProductionYear,
 0343                ProviderIds = video.ProviderIds,
 0344                RuntimeTicks = video.RunTimeTicks,
 0345                IsPerfectMatch = isPerfectMatch ?? false,
 0346                IsAutomated = isAutomated
 0347            };
 348
 0349            if (video is Episode episode)
 350            {
 0351                request.IndexNumberEnd = episode.IndexNumberEnd;
 0352                request.SeriesName = episode.SeriesName;
 353            }
 354
 0355            return SearchSubtitles(request, cancellationToken);
 356        }
 357
 358        private void Normalize(IEnumerable<RemoteSubtitleInfo> subtitles)
 359        {
 0360            foreach (var sub in subtitles)
 361            {
 0362                sub.Id = GetProviderId(sub.ProviderName) + "_" + sub.Id;
 363            }
 0364        }
 365
 366        private string GetProviderId(string name)
 367        {
 0368            return name.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture);
 369        }
 370
 371        private ISubtitleProvider GetProvider(string id)
 372        {
 0373            return _subtitleProviders.First(i => string.Equals(id, GetProviderId(i.Name), StringComparison.Ordinal));
 374        }
 375
 376        /// <inheritdoc />
 377        public Task DeleteSubtitles(BaseItem item, int index)
 378        {
 0379            var stream = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery
 0380            {
 0381                Index = index,
 0382                ItemId = item.Id,
 0383                Type = MediaStreamType.Subtitle
 0384            })[0];
 385
 0386            var path = stream.Path;
 0387            _monitor.ReportFileSystemChangeBeginning(path);
 388
 389            try
 390            {
 0391                _fileSystem.DeleteFile(path);
 0392            }
 393            finally
 394            {
 0395                _monitor.ReportFileSystemChangeComplete(path, false);
 0396            }
 397
 0398            return item.RefreshMetadata(CancellationToken.None);
 399        }
 400
 401        /// <inheritdoc />
 402        public Task<SubtitleResponse> GetRemoteSubtitles(string id, CancellationToken cancellationToken)
 403        {
 0404            var parts = id.Split('_', 2);
 405
 0406            var provider = GetProvider(parts[0]);
 0407            id = parts[^1];
 408
 0409            return provider.GetSubtitles(id, cancellationToken);
 410        }
 411
 412        /// <inheritdoc />
 413        public SubtitleProviderInfo[] GetSupportedProviders(BaseItem item)
 414        {
 415            VideoContentType mediaType;
 416
 0417            if (item is Episode)
 418            {
 0419                mediaType = VideoContentType.Episode;
 420            }
 0421            else if (item is Movie)
 422            {
 0423                mediaType = VideoContentType.Movie;
 424            }
 425            else
 426            {
 427                // These are the only supported types
 0428                return Array.Empty<SubtitleProviderInfo>();
 429            }
 430
 0431            return _subtitleProviders
 0432                .Where(i => i.SupportedMediaTypes.Contains(mediaType))
 0433                .Select(i => new SubtitleProviderInfo
 0434                {
 0435                    Name = i.Name,
 0436                    Id = GetProviderId(i.Name)
 0437                }).ToArray();
 438        }
 439    }
 440}