< Summary - Jellyfin

Information
Class: MediaBrowser.Providers.MediaInfo.AudioFileProber
Assembly: MediaBrowser.Providers
File(s): /srv/git/jellyfin/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
Line coverage
36%
Covered lines: 12
Uncovered lines: 21
Coverable lines: 33
Total lines: 494
Line coverage: 36.3%
Branch coverage
0%
Covered branches: 0
Total branches: 12
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
.ctor(...)100%11100%
AddExternalLyrics(...)0%2040%
SplitWithCustomDelimiter(...)0%4260%
GetFirstMusicBrainzId(...)0%620%

File(s)

/srv/git/jellyfin/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Globalization;
 4using System.Linq;
 5using System.Threading;
 6using System.Threading.Tasks;
 7using ATL;
 8using Jellyfin.Data.Enums;
 9using Jellyfin.Extensions;
 10using MediaBrowser.Controller.Entities;
 11using MediaBrowser.Controller.Entities.Audio;
 12using MediaBrowser.Controller.Library;
 13using MediaBrowser.Controller.Lyrics;
 14using MediaBrowser.Controller.MediaEncoding;
 15using MediaBrowser.Controller.Persistence;
 16using MediaBrowser.Controller.Providers;
 17using MediaBrowser.Model.Dlna;
 18using MediaBrowser.Model.Dto;
 19using MediaBrowser.Model.Entities;
 20using MediaBrowser.Model.Extensions;
 21using MediaBrowser.Model.MediaInfo;
 22using Microsoft.Extensions.Logging;
 23using static Jellyfin.Extensions.StringExtensions;
 24
 25namespace MediaBrowser.Providers.MediaInfo
 26{
 27    /// <summary>
 28    /// Probes audio files for metadata.
 29    /// </summary>
 30    public class AudioFileProber
 31    {
 32        private const char InternalValueSeparator = '\u001F';
 33
 34        private readonly IMediaEncoder _mediaEncoder;
 35        private readonly IItemRepository _itemRepo;
 36        private readonly ILibraryManager _libraryManager;
 37        private readonly ILogger<AudioFileProber> _logger;
 38        private readonly IMediaSourceManager _mediaSourceManager;
 39        private readonly LyricResolver _lyricResolver;
 40        private readonly ILyricManager _lyricManager;
 41        private readonly IMediaStreamRepository _mediaStreamRepository;
 42
 43        /// <summary>
 44        /// Initializes a new instance of the <see cref="AudioFileProber"/> class.
 45        /// </summary>
 46        /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
 47        /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
 48        /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
 49        /// <param name="itemRepo">Instance of the <see cref="IItemRepository"/> interface.</param>
 50        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
 51        /// <param name="lyricResolver">Instance of the <see cref="LyricResolver"/> interface.</param>
 52        /// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param>
 53        /// <param name="mediaStreamRepository">Instance of the <see cref="IMediaStreamRepository"/>.</param>
 54        public AudioFileProber(
 55            ILogger<AudioFileProber> logger,
 56            IMediaSourceManager mediaSourceManager,
 57            IMediaEncoder mediaEncoder,
 58            IItemRepository itemRepo,
 59            ILibraryManager libraryManager,
 60            LyricResolver lyricResolver,
 61            ILyricManager lyricManager,
 62            IMediaStreamRepository mediaStreamRepository)
 63        {
 2164            _mediaEncoder = mediaEncoder;
 2165            _itemRepo = itemRepo;
 2166            _libraryManager = libraryManager;
 2167            _logger = logger;
 2168            _mediaSourceManager = mediaSourceManager;
 2169            _lyricResolver = lyricResolver;
 2170            _lyricManager = lyricManager;
 2171            _mediaStreamRepository = mediaStreamRepository;
 2172            ATL.Settings.DisplayValueSeparator = InternalValueSeparator;
 2173            ATL.Settings.UseFileNameWhenNoTitle = false;
 2174            ATL.Settings.ID3v2_separatev2v3Values = false;
 2175        }
 76
 77        /// <summary>
 78        /// Probes the specified item for metadata.
 79        /// </summary>
 80        /// <param name="item">The item to probe.</param>
 81        /// <param name="options">The <see cref="MetadataRefreshOptions"/>.</param>
 82        /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
 83        /// <typeparam name="T">The type of item to resolve.</typeparam>
 84        /// <returns>A <see cref="Task"/> probing the item for metadata.</returns>
 85        public async Task<ItemUpdateType> Probe<T>(
 86            T item,
 87            MetadataRefreshOptions options,
 88            CancellationToken cancellationToken)
 89            where T : Audio
 90        {
 91            var path = item.Path;
 92            var protocol = item.PathProtocol ?? MediaProtocol.File;
 93
 94            if (!item.IsShortcut || options.EnableRemoteContentProbe)
 95            {
 96                if (item.IsShortcut)
 97                {
 98                    path = item.ShortcutPath;
 99                    protocol = _mediaSourceManager.GetPathProtocol(path);
 100                }
 101
 102                var result = await _mediaEncoder.GetMediaInfo(
 103                    new MediaInfoRequest
 104                    {
 105                        MediaType = DlnaProfileType.Audio,
 106                        MediaSource = new MediaSourceInfo
 107                        {
 108                            Path = path,
 109                            Protocol = protocol
 110                        }
 111                    },
 112                    cancellationToken).ConfigureAwait(false);
 113
 114                cancellationToken.ThrowIfCancellationRequested();
 115
 116                await FetchAsync(item, result, options, cancellationToken).ConfigureAwait(false);
 117            }
 118
 119            return ItemUpdateType.MetadataImport;
 120        }
 121
 122        /// <summary>
 123        /// Fetches the specified audio.
 124        /// </summary>
 125        /// <param name="audio">The <see cref="Audio"/>.</param>
 126        /// <param name="mediaInfo">The <see cref="Model.MediaInfo.MediaInfo"/>.</param>
 127        /// <param name="options">The <see cref="MetadataRefreshOptions"/>.</param>
 128        /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
 129        /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
 130        private async Task FetchAsync(
 131            Audio audio,
 132            Model.MediaInfo.MediaInfo mediaInfo,
 133            MetadataRefreshOptions options,
 134            CancellationToken cancellationToken)
 135        {
 136            audio.Container = mediaInfo.Container;
 137            audio.TotalBitrate = mediaInfo.Bitrate;
 138
 139            audio.RunTimeTicks = mediaInfo.RunTimeTicks;
 140            audio.Size = mediaInfo.Size;
 141
 142            // Add external lyrics first to prevent the lrc file get overwritten on first scan
 143            var mediaStreams = new List<MediaStream>(mediaInfo.MediaStreams);
 144            AddExternalLyrics(audio, mediaStreams, options);
 145            var tryExtractEmbeddedLyrics = mediaStreams.All(s => s.Type != MediaStreamType.Lyric);
 146
 147            if (!audio.IsLocked)
 148            {
 149                await FetchDataFromTags(audio, mediaInfo, options, tryExtractEmbeddedLyrics).ConfigureAwait(false);
 150                if (tryExtractEmbeddedLyrics)
 151                {
 152                    AddExternalLyrics(audio, mediaStreams, options);
 153                }
 154            }
 155
 156            audio.HasLyrics = mediaStreams.Any(s => s.Type == MediaStreamType.Lyric);
 157
 158            _mediaStreamRepository.SaveMediaStreams(audio.Id, mediaStreams, cancellationToken);
 159        }
 160
 161        /// <summary>
 162        /// Fetches data from the tags.
 163        /// </summary>
 164        /// <param name="audio">The <see cref="Audio"/>.</param>
 165        /// <param name="mediaInfo">The <see cref="Model.MediaInfo.MediaInfo"/>.</param>
 166        /// <param name="options">The <see cref="MetadataRefreshOptions"/>.</param>
 167        /// <param name="tryExtractEmbeddedLyrics">Whether to extract embedded lyrics to lrc file. </param>
 168        private async Task FetchDataFromTags(Audio audio, Model.MediaInfo.MediaInfo mediaInfo, MetadataRefreshOptions op
 169        {
 170            var libraryOptions = _libraryManager.GetLibraryOptions(audio);
 171            Track track = new Track(audio.Path);
 172
 173            if (track.MetadataFormats
 174                .All(mf => string.Equals(mf.ShortName, "ID3v1", StringComparison.OrdinalIgnoreCase)))
 175            {
 176                _logger.LogWarning("File {File} only has ID3v1 tags, some fields may be truncated", audio.Path);
 177            }
 178
 179            // We should never use the property setter of the ATL.Track class.
 180            // That setter is meant for its own tag parser and external editor usage and will have unwanted side effects
 181            // For example, setting the Year property will also set the Date property, which is not what we want here.
 182            // To properly handle fallback values, we make a clone of those fields when valid.
 183            var trackTitle = (string.IsNullOrEmpty(track.Title) ? mediaInfo.Name : track.Title).Trim();
 184            var trackAlbum = (string.IsNullOrEmpty(track.Album) ? mediaInfo.Album : track.Album).Trim();
 185            var trackYear = track.Year is null or 0 ? mediaInfo.ProductionYear : track.Year;
 186            var trackTrackNumber = track.TrackNumber is null or 0 ? mediaInfo.IndexNumber : track.TrackNumber;
 187            var trackDiscNumber = track.DiscNumber is null or 0 ? mediaInfo.ParentIndexNumber : track.DiscNumber;
 188
 189            if (audio.SupportsPeople && !audio.LockedFields.Contains(MetadataField.Cast))
 190            {
 191                var people = new List<PersonInfo>();
 192                var albumArtists = string.IsNullOrEmpty(track.AlbumArtist) ? [] : track.AlbumArtist.Split(InternalValueS
 193
 194                if (libraryOptions.UseCustomTagDelimiters)
 195                {
 196                    albumArtists = albumArtists.SelectMany(a => SplitWithCustomDelimiter(a, libraryOptions.GetCustomTagD
 197                }
 198
 199                foreach (var albumArtist in albumArtists)
 200                {
 201                    if (!string.IsNullOrWhiteSpace(albumArtist))
 202                    {
 203                        PeopleHelper.AddPerson(people, new PersonInfo
 204                        {
 205                            Name = albumArtist.Trim(),
 206                            Type = PersonKind.AlbumArtist
 207                        });
 208                    }
 209                }
 210
 211                string[]? performers = null;
 212                if (libraryOptions.PreferNonstandardArtistsTag)
 213                {
 214                    track.AdditionalFields.TryGetValue("ARTISTS", out var artistsTagString);
 215                    if (artistsTagString is not null)
 216                    {
 217                        performers = artistsTagString.Split(InternalValueSeparator);
 218                    }
 219                }
 220
 221                if (performers is null || performers.Length == 0)
 222                {
 223                    performers = string.IsNullOrEmpty(track.Artist) ? [] : track.Artist.Split(InternalValueSeparator);
 224                }
 225
 226                if (libraryOptions.UseCustomTagDelimiters)
 227                {
 228                    performers = performers.SelectMany(p => SplitWithCustomDelimiter(p, libraryOptions.GetCustomTagDelim
 229                }
 230
 231                foreach (var performer in performers)
 232                {
 233                    if (!string.IsNullOrWhiteSpace(performer))
 234                    {
 235                        PeopleHelper.AddPerson(people, new PersonInfo
 236                        {
 237                            Name = performer.Trim(),
 238                            Type = PersonKind.Artist
 239                        });
 240                    }
 241                }
 242
 243                foreach (var composer in track.Composer.Split(InternalValueSeparator))
 244                {
 245                    if (!string.IsNullOrWhiteSpace(composer))
 246                    {
 247                        PeopleHelper.AddPerson(people, new PersonInfo
 248                        {
 249                            Name = composer.Trim(),
 250                            Type = PersonKind.Composer
 251                        });
 252                    }
 253                }
 254
 255                _libraryManager.UpdatePeople(audio, people);
 256
 257                if (options.ReplaceAllMetadata && performers.Length != 0)
 258                {
 259                    audio.Artists = performers;
 260                }
 261                else if (!options.ReplaceAllMetadata
 262                         && (audio.Artists is null || audio.Artists.Count == 0))
 263                {
 264                    audio.Artists = performers;
 265                }
 266
 267                if (albumArtists.Length == 0)
 268                {
 269                    // Album artists not provided, fall back to performers (artists).
 270                    albumArtists = performers;
 271                }
 272
 273                if (options.ReplaceAllMetadata && albumArtists.Length != 0)
 274                {
 275                    audio.AlbumArtists = albumArtists;
 276                }
 277                else if (!options.ReplaceAllMetadata
 278                         && (audio.AlbumArtists is null || audio.AlbumArtists.Count == 0))
 279                {
 280                    audio.AlbumArtists = albumArtists;
 281                }
 282            }
 283
 284            if (!audio.LockedFields.Contains(MetadataField.Name) && !string.IsNullOrEmpty(trackTitle))
 285            {
 286                audio.Name = trackTitle;
 287            }
 288
 289            if (options.ReplaceAllMetadata)
 290            {
 291                audio.Album = trackAlbum;
 292                audio.IndexNumber = trackTrackNumber;
 293                audio.ParentIndexNumber = trackDiscNumber;
 294            }
 295            else
 296            {
 297                audio.Album ??= trackAlbum;
 298                audio.IndexNumber ??= trackTrackNumber;
 299                audio.ParentIndexNumber ??= trackDiscNumber;
 300            }
 301
 302            if (track.Date.HasValue)
 303            {
 304                audio.PremiereDate = track.Date;
 305            }
 306
 307            if (trackYear.HasValue)
 308            {
 309                var year = trackYear.Value;
 310                audio.ProductionYear = year;
 311
 312                // ATL library handles such fallback this with its own internal logic, but we also need to handle it her
 313                if (!audio.PremiereDate.HasValue)
 314                {
 315                    try
 316                    {
 317                        audio.PremiereDate = new DateTime(year, 01, 01);
 318                    }
 319                    catch (ArgumentOutOfRangeException ex)
 320                    {
 321                        _logger.LogError(ex, "Error parsing YEAR tag in {File}. '{TagValue}' is an invalid year", audio.
 322                    }
 323                }
 324            }
 325
 326            if (!audio.LockedFields.Contains(MetadataField.Genres))
 327            {
 328                var genres = string.IsNullOrEmpty(track.Genre) ? [] : track.Genre.Split(InternalValueSeparator).Distinct
 329
 330                if (libraryOptions.UseCustomTagDelimiters)
 331                {
 332                    genres = genres.SelectMany(g => SplitWithCustomDelimiter(g, libraryOptions.GetCustomTagDelimiters(),
 333                }
 334
 335                genres = genres.Trimmed().Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
 336
 337                audio.Genres = options.ReplaceAllMetadata || audio.Genres is null || audio.Genres.Length == 0
 338                    ? genres
 339                    : audio.Genres;
 340            }
 341
 342            track.AdditionalFields.TryGetValue("REPLAYGAIN_TRACK_GAIN", out var trackGainTag);
 343
 344            if (trackGainTag is not null)
 345            {
 346                if (trackGainTag.EndsWith("db", StringComparison.OrdinalIgnoreCase))
 347                {
 348                    trackGainTag = trackGainTag[..^2].Trim();
 349                }
 350
 351                if (float.TryParse(trackGainTag, NumberStyles.Float, CultureInfo.InvariantCulture, out var value))
 352                {
 353                    audio.NormalizationGain = value;
 354                }
 355            }
 356
 357            if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _))
 358            {
 359                if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_ARTISTID", out var musicBrainzArtistTag)
 360                     || track.AdditionalFields.TryGetValue("MusicBrainz Artist Id", out musicBrainzArtistTag))
 361                    && !string.IsNullOrEmpty(musicBrainzArtistTag))
 362                {
 363                    var id = GetFirstMusicBrainzId(musicBrainzArtistTag, libraryOptions.UseCustomTagDelimiters, libraryO
 364                    audio.TrySetProviderId(MetadataProvider.MusicBrainzArtist, id);
 365                }
 366            }
 367
 368            if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out _))
 369            {
 370                if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_ALBUMARTISTID", out var musicBrainzReleaseArtistIdT
 371                     || track.AdditionalFields.TryGetValue("MusicBrainz Album Artist Id", out musicBrainzReleaseArtistId
 372                    && !string.IsNullOrEmpty(musicBrainzReleaseArtistIdTag))
 373                {
 374                    var id = GetFirstMusicBrainzId(musicBrainzReleaseArtistIdTag, libraryOptions.UseCustomTagDelimiters,
 375                    audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbumArtist, id);
 376                }
 377            }
 378
 379            if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out _))
 380            {
 381                if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_ALBUMID", out var musicBrainzReleaseIdTag)
 382                     || track.AdditionalFields.TryGetValue("MusicBrainz Album Id", out musicBrainzReleaseIdTag))
 383                    && !string.IsNullOrEmpty(musicBrainzReleaseIdTag))
 384                {
 385                    var id = GetFirstMusicBrainzId(musicBrainzReleaseIdTag, libraryOptions.UseCustomTagDelimiters, libra
 386                    audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbum, id);
 387                }
 388            }
 389
 390            if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out _))
 391            {
 392                if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_RELEASEGROUPID", out var musicBrainzReleaseGroupIdT
 393                     || track.AdditionalFields.TryGetValue("MusicBrainz Release Group Id", out musicBrainzReleaseGroupId
 394                    && !string.IsNullOrEmpty(musicBrainzReleaseGroupIdTag))
 395                {
 396                    var id = GetFirstMusicBrainzId(musicBrainzReleaseGroupIdTag, libraryOptions.UseCustomTagDelimiters, 
 397                    audio.TrySetProviderId(MetadataProvider.MusicBrainzReleaseGroup, id);
 398                }
 399            }
 400
 401            if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out _))
 402            {
 403                if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_RELEASETRACKID", out var trackMbId)
 404                     || track.AdditionalFields.TryGetValue("MusicBrainz Release Track Id", out trackMbId))
 405                    && !string.IsNullOrEmpty(trackMbId))
 406                {
 407                    var id = GetFirstMusicBrainzId(trackMbId, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetC
 408                    audio.TrySetProviderId(MetadataProvider.MusicBrainzTrack, id);
 409                }
 410            }
 411
 412            if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzRecording, out _))
 413            {
 414                if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_TRACKID", out var recordingMbId)
 415                     || track.AdditionalFields.TryGetValue("MusicBrainz Track Id", out recordingMbId))
 416                    && !string.IsNullOrEmpty(recordingMbId))
 417                {
 418                    audio.TrySetProviderId(MetadataProvider.MusicBrainzRecording, recordingMbId);
 419                }
 420                else if (track.AdditionalFields.TryGetValue("UFID", out var ufIdValue) && !string.IsNullOrEmpty(ufIdValu
 421                {
 422                    // If tagged with MB Picard, the format is 'http://musicbrainz.org\0<recording MBID>'
 423                    if (ufIdValue.Contains("musicbrainz.org", StringComparison.OrdinalIgnoreCase))
 424                    {
 425                        audio.TrySetProviderId(MetadataProvider.MusicBrainzRecording, ufIdValue.AsSpan().RightPart('\0')
 426                    }
 427                }
 428            }
 429
 430            // Save extracted lyrics if they exist,
 431            // and if the audio doesn't yet have lyrics.
 432            var lyrics = track.Lyrics.SynchronizedLyrics.Count > 0 ? track.Lyrics.FormatSynchToLRC() : track.Lyrics.Unsy
 433            if (!string.IsNullOrWhiteSpace(lyrics)
 434                && tryExtractEmbeddedLyrics)
 435            {
 436                await _lyricManager.SaveLyricAsync(audio, "lrc", lyrics).ConfigureAwait(false);
 437            }
 438        }
 439
 440        private void AddExternalLyrics(
 441            Audio audio,
 442            List<MediaStream> currentStreams,
 443            MetadataRefreshOptions options)
 444        {
 0445            var startIndex = currentStreams.Count == 0 ? 0 : (currentStreams.Select(i => i.Index).Max() + 1);
 0446            var externalLyricFiles = _lyricResolver.GetExternalStreams(audio, startIndex, options.DirectoryService, fals
 447
 0448            audio.LyricFiles = externalLyricFiles.Select(i => i.Path).Distinct().ToArray();
 0449            if (externalLyricFiles.Count > 0)
 450            {
 0451                currentStreams.Add(externalLyricFiles[0]);
 452            }
 0453        }
 454
 455        private List<string> SplitWithCustomDelimiter(string val, char[] tagDelimiters, string[] whitelist)
 456        {
 0457            var items = new List<string>();
 0458            var temp = val;
 0459            foreach (var whitelistItem in whitelist)
 460            {
 0461                if (string.IsNullOrWhiteSpace(whitelistItem))
 462                {
 463                    continue;
 464                }
 465
 0466                var originalTemp = temp;
 0467                temp = temp.Replace(whitelistItem, string.Empty, StringComparison.OrdinalIgnoreCase);
 468
 0469                if (!string.Equals(temp, originalTemp, StringComparison.OrdinalIgnoreCase))
 470                {
 0471                    items.Add(whitelistItem);
 472                }
 473            }
 474
 0475            var items2 = temp.Split(tagDelimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntrie
 0476            items.AddRange(items2);
 477
 0478            return items;
 479        }
 480
 481        // MusicBrainz IDs are multi-value tags, so we need to split them
 482        // However, our current provider can only have one single ID, which means we need to pick the first one
 483        private string? GetFirstMusicBrainzId(string tag, bool useCustomTagDelimiters, char[] tagDelimiters, string[] wh
 484        {
 0485            var val = tag.Split(InternalValueSeparator).FirstOrDefault();
 0486            if (val is not null && useCustomTagDelimiters)
 487            {
 0488                val = SplitWithCustomDelimiter(val, tagDelimiters, whitelist).FirstOrDefault();
 489            }
 490
 0491            return val;
 492        }
 493    }
 494}