< Summary - Jellyfin

Information
Class: MediaBrowser.Providers.Subtitles.SubtitleManager
Assembly: MediaBrowser.Providers
File(s): /srv/git/jellyfin/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
Line coverage
15%
Covered lines: 12
Uncovered lines: 66
Coverable lines: 78
Total lines: 439
Line coverage: 15.3%
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 12/27/2025 - 12:11:51 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: 439 12/27/2025 - 12:11:51 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: 439

Coverage delta

Coverage delta 3 -3

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
DownloadSubtitles(...)100%210%
UploadSubtitle(...)0%2040%
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        {
 68            if (request.Language is not null)
 69            {
 70                var culture = _localization.FindLanguageInfo(request.Language);
 71
 72                if (culture is not null)
 73                {
 74                    request.TwoLetterISOLanguageName = culture.TwoLetterISOLanguageName;
 75                }
 76            }
 77
 78            var contentType = request.ContentType;
 79            var providers = _subtitleProviders
 80                .Where(i => i.SupportedMediaTypes.Contains(contentType) && !request.DisabledSubtitleFetchers.Contains(i.
 81                .OrderBy(i =>
 82                {
 83                    var index = request.SubtitleFetcherOrder.IndexOf(i.Name);
 84                    return index == -1 ? int.MaxValue : index;
 85                })
 86                .ToArray();
 87
 88            // If not searching all, search one at a time until something is found
 89            if (!request.SearchAllProviders)
 90            {
 91                foreach (var provider in providers)
 92                {
 93                    try
 94                    {
 95                        var searchResults = await provider.Search(request, cancellationToken).ConfigureAwait(false);
 96
 97                        var list = searchResults.ToArray();
 98
 99                        if (list.Length > 0)
 100                        {
 101                            Normalize(list);
 102                            return list;
 103                        }
 104                    }
 105                    catch (Exception ex)
 106                    {
 107                        _logger.LogError(ex, "Error downloading subtitles from {Provider}", provider.Name);
 108                    }
 109                }
 110
 111                return Array.Empty<RemoteSubtitleInfo>();
 112            }
 113
 114            var tasks = providers.Select(async i =>
 115            {
 116                try
 117                {
 118                    var searchResults = await i.Search(request, cancellationToken).ConfigureAwait(false);
 119
 120                    var list = searchResults.ToArray();
 121                    Normalize(list);
 122                    return list;
 123                }
 124                catch (Exception ex)
 125                {
 126                    _logger.LogError(ex, "Error downloading subtitles from {Name}", i.Name);
 127                    return Array.Empty<RemoteSubtitleInfo>();
 128                }
 129            });
 130
 131            var results = await Task.WhenAll(tasks).ConfigureAwait(false);
 132
 133            return results.SelectMany(i => i).ToArray();
 134        }
 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        {
 151            var parts = subtitleId.Split('_', 2);
 152            var provider = GetProvider(parts[0]);
 153
 154            try
 155            {
 156                var response = await GetRemoteSubtitles(subtitleId, cancellationToken).ConfigureAwait(false);
 157
 158                await TrySaveSubtitle(video, libraryOptions, response).ConfigureAwait(false);
 159            }
 160            catch (RateLimitExceededException)
 161            {
 162                throw;
 163            }
 164            catch (Exception ex)
 165            {
 166                SubtitleDownloadFailure?.Invoke(this, new SubtitleDownloadFailureEventArgs
 167                {
 168                    Item = video,
 169                    Exception = ex,
 170                    Provider = provider.Name
 171                });
 172
 173                throw;
 174            }
 175        }
 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        {
 195            var saveInMediaFolder = libraryOptions.SaveSubtitlesWithMedia;
 196
 197            var memoryStream = new MemoryStream();
 198            await using (memoryStream.ConfigureAwait(false))
 199            {
 200                var stream = response.Stream;
 201                await using (stream.ConfigureAwait(false))
 202                {
 203                    await stream.CopyToAsync(memoryStream).ConfigureAwait(false);
 204                    memoryStream.Position = 0;
 205                }
 206
 207                var savePaths = new List<string>();
 208                var language = response.Language.ToLowerInvariant();
 209                if (language.AsSpan().IndexOfAny(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) >= 0)
 210                {
 211                    throw new ArgumentException("Language contains invalid characters.");
 212                }
 213
 214                var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + language;
 215
 216                if (response.IsForced)
 217                {
 218                    saveFileName += ".forced";
 219                }
 220
 221                if (response.IsHearingImpaired)
 222                {
 223                    saveFileName += ".sdh";
 224                }
 225
 226                if (saveInMediaFolder)
 227                {
 228                    var mediaFolderPath = Path.GetFullPath(Path.Combine(video.ContainingFolderPath, saveFileName));
 229                    savePaths.Add(mediaFolderPath);
 230                }
 231
 232                var internalPath = Path.GetFullPath(Path.Combine(video.GetInternalMetadataPath(), saveFileName));
 233
 234                savePaths.Add(internalPath);
 235
 236                await TrySaveToFiles(memoryStream, savePaths, video, response.Format.ToLowerInvariant()).ConfigureAwait(
 237            }
 238        }
 239
 240        private async Task TrySaveToFiles(Stream stream, List<string> savePaths, Video video, string extension)
 241        {
 242            if (!_allowedSubtitleFormats.Contains(extension, StringComparison.OrdinalIgnoreCase))
 243            {
 244                throw new ArgumentException($"Invalid subtitle format: {extension}");
 245            }
 246
 247            List<Exception>? exs = null;
 248
 249            foreach (var savePath in savePaths)
 250            {
 251                var path = Path.GetFullPath(savePath + "." + extension);
 252                try
 253                {
 254                    var containingFolder = video.ContainingFolderPath + Path.DirectorySeparatorChar;
 255                    var metadataFolder = video.GetInternalMetadataPath() + Path.DirectorySeparatorChar;
 256                    if (path.StartsWith(containingFolder, StringComparison.Ordinal)
 257                            || path.StartsWith(metadataFolder, StringComparison.Ordinal))
 258                    {
 259                        var fileExists = File.Exists(path);
 260                        var counter = 0;
 261
 262                        while (fileExists)
 263                        {
 264                            path = string.Format(CultureInfo.InvariantCulture, "{0}.{1}.{2}", savePath, counter, extensi
 265                            fileExists = File.Exists(path);
 266                            counter++;
 267                        }
 268
 269                        _logger.LogInformation("Saving subtitles to {SavePath}", path);
 270                        _monitor.ReportFileSystemChangeBeginning(path);
 271
 272                        Directory.CreateDirectory(Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Pa
 273
 274                        var fileOptions = AsyncFile.WriteOptions;
 275                        fileOptions.Mode = FileMode.CreateNew;
 276                        fileOptions.PreallocationSize = stream.Length;
 277                        var fs = new FileStream(path, fileOptions);
 278                        await using (fs.ConfigureAwait(false))
 279                        {
 280                            await stream.CopyToAsync(fs).ConfigureAwait(false);
 281                        }
 282
 283                        return;
 284                    }
 285                    else
 286                    {
 287                        // TODO: Add some error handling to the API user: return BadRequest("Could not save subtitle, ba
 288                        _logger.LogError("An uploaded subtitle could not be saved because the resulting path was invalid
 289                    }
 290                }
 291                catch (Exception ex)
 292                {
 293                    (exs ??= []).Add(ex);
 294                }
 295                finally
 296                {
 297                    _monitor.ReportFileSystemChangeComplete(path, false);
 298                }
 299
 300                stream.Position = 0;
 301            }
 302
 303            if (exs is not null)
 304            {
 305                throw new AggregateException(exs);
 306            }
 307        }
 308
 309        /// <inheritdoc />
 310        public Task<RemoteSubtitleInfo[]> SearchSubtitles(Video video, string language, bool? isPerfectMatch, bool isAut
 311        {
 0312            if (video.VideoType != VideoType.VideoFile)
 313            {
 0314                return Task.FromResult(Array.Empty<RemoteSubtitleInfo>());
 315            }
 316
 317            VideoContentType mediaType;
 318
 0319            if (video is Episode)
 320            {
 0321                mediaType = VideoContentType.Episode;
 322            }
 0323            else if (video is Movie)
 324            {
 0325                mediaType = VideoContentType.Movie;
 326            }
 327            else
 328            {
 329                // These are the only supported types
 0330                return Task.FromResult(Array.Empty<RemoteSubtitleInfo>());
 331            }
 332
 0333            var request = new SubtitleSearchRequest
 0334            {
 0335                ContentType = mediaType,
 0336                IndexNumber = video.IndexNumber,
 0337                Language = language,
 0338                MediaPath = video.Path,
 0339                Name = video.Name,
 0340                ParentIndexNumber = video.ParentIndexNumber,
 0341                ProductionYear = video.ProductionYear,
 0342                ProviderIds = video.ProviderIds,
 0343                RuntimeTicks = video.RunTimeTicks,
 0344                IsPerfectMatch = isPerfectMatch ?? false,
 0345                IsAutomated = isAutomated
 0346            };
 347
 0348            if (video is Episode episode)
 349            {
 0350                request.IndexNumberEnd = episode.IndexNumberEnd;
 0351                request.SeriesName = episode.SeriesName;
 352            }
 353
 0354            return SearchSubtitles(request, cancellationToken);
 355        }
 356
 357        private void Normalize(IEnumerable<RemoteSubtitleInfo> subtitles)
 358        {
 0359            foreach (var sub in subtitles)
 360            {
 0361                sub.Id = GetProviderId(sub.ProviderName) + "_" + sub.Id;
 362            }
 0363        }
 364
 365        private string GetProviderId(string name)
 366        {
 0367            return name.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture);
 368        }
 369
 370        private ISubtitleProvider GetProvider(string id)
 371        {
 0372            return _subtitleProviders.First(i => string.Equals(id, GetProviderId(i.Name), StringComparison.Ordinal));
 373        }
 374
 375        /// <inheritdoc />
 376        public Task DeleteSubtitles(BaseItem item, int index)
 377        {
 0378            var stream = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery
 0379            {
 0380                Index = index,
 0381                ItemId = item.Id,
 0382                Type = MediaStreamType.Subtitle
 0383            })[0];
 384
 0385            var path = stream.Path;
 0386            _monitor.ReportFileSystemChangeBeginning(path);
 387
 388            try
 389            {
 0390                _fileSystem.DeleteFile(path);
 0391            }
 392            finally
 393            {
 0394                _monitor.ReportFileSystemChangeComplete(path, false);
 0395            }
 396
 0397            return item.RefreshMetadata(CancellationToken.None);
 398        }
 399
 400        /// <inheritdoc />
 401        public Task<SubtitleResponse> GetRemoteSubtitles(string id, CancellationToken cancellationToken)
 402        {
 0403            var parts = id.Split('_', 2);
 404
 0405            var provider = GetProvider(parts[0]);
 0406            id = parts[^1];
 407
 0408            return provider.GetSubtitles(id, cancellationToken);
 409        }
 410
 411        /// <inheritdoc />
 412        public SubtitleProviderInfo[] GetSupportedProviders(BaseItem item)
 413        {
 414            VideoContentType mediaType;
 415
 0416            if (item is Episode)
 417            {
 0418                mediaType = VideoContentType.Episode;
 419            }
 0420            else if (item is Movie)
 421            {
 0422                mediaType = VideoContentType.Movie;
 423            }
 424            else
 425            {
 426                // These are the only supported types
 0427                return Array.Empty<SubtitleProviderInfo>();
 428            }
 429
 0430            return _subtitleProviders
 0431                .Where(i => i.SupportedMediaTypes.Contains(mediaType))
 0432                .Select(i => new SubtitleProviderInfo
 0433                {
 0434                    Name = i.Name,
 0435                    Id = GetProviderId(i.Name)
 0436                }).ToArray();
 437        }
 438    }
 439}