< Summary - Jellyfin

Information
Class: MediaBrowser.Providers.MediaInfo.AudioFileProber
Assembly: MediaBrowser.Providers
File(s): /srv/git/jellyfin/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
Line coverage
4%
Covered lines: 12
Uncovered lines: 284
Coverable lines: 296
Total lines: 704
Line coverage: 4%
Branch coverage
0%
Covered branches: 0
Total branches: 306
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/2/2026 - 12:12:50 AM Line coverage: 4.4% (11/246) Branch coverage: 0% (0/244) Total lines: 5925/4/2026 - 12:15:16 AM Line coverage: 4.1% (12/289) Branch coverage: 0% (0/290) Total lines: 6866/28/2026 - 12:15:35 AM Line coverage: 4.1% (12/289) Branch coverage: 0% (0/294) Total lines: 6868/6/2026 - 12:17:15 AM Line coverage: 4% (12/296) Branch coverage: 0% (0/306) Total lines: 704 5/2/2026 - 12:12:50 AM Line coverage: 4.4% (11/246) Branch coverage: 0% (0/244) Total lines: 5925/4/2026 - 12:15:16 AM Line coverage: 4.1% (12/289) Branch coverage: 0% (0/290) Total lines: 6866/28/2026 - 12:15:35 AM Line coverage: 4.1% (12/289) Branch coverage: 0% (0/294) Total lines: 6868/6/2026 - 12:17:15 AM Line coverage: 4% (12/296) Branch coverage: 0% (0/306) Total lines: 704

Coverage delta

Coverage delta 1 -1

Metrics

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.Chapters;
 11using MediaBrowser.Controller.Entities;
 12using MediaBrowser.Controller.Entities.Audio;
 13using MediaBrowser.Controller.Library;
 14using MediaBrowser.Controller.Lyrics;
 15using MediaBrowser.Controller.MediaEncoding;
 16using MediaBrowser.Controller.Persistence;
 17using MediaBrowser.Controller.Providers;
 18using MediaBrowser.Model.Dlna;
 19using MediaBrowser.Model.Dto;
 20using MediaBrowser.Model.Entities;
 21using MediaBrowser.Model.Extensions;
 22using MediaBrowser.Model.MediaInfo;
 23using Microsoft.Extensions.Logging;
 24using static Jellyfin.Extensions.StringExtensions;
 25
 26namespace MediaBrowser.Providers.MediaInfo
 27{
 28    /// <summary>
 29    /// Probes audio files for metadata.
 30    /// </summary>
 31    public class AudioFileProber
 32    {
 33        private const char InternalValueSeparator = '\u001F';
 34
 35        private readonly IMediaEncoder _mediaEncoder;
 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        private readonly IChapterManager _chapterManager;
 43
 44        /// <summary>
 45        /// Initializes a new instance of the <see cref="AudioFileProber"/> class.
 46        /// </summary>
 47        /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
 48        /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
 49        /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> 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        /// <param name="chapterManager">Instance of the <see cref="IChapterManager"/> interface.</param>
 55        public AudioFileProber(
 56            ILogger<AudioFileProber> logger,
 57            IMediaSourceManager mediaSourceManager,
 58            IMediaEncoder mediaEncoder,
 59            ILibraryManager libraryManager,
 60            LyricResolver lyricResolver,
 61            ILyricManager lyricManager,
 62            IMediaStreamRepository mediaStreamRepository,
 63            IChapterManager chapterManager)
 64        {
 2265            _mediaEncoder = mediaEncoder;
 2266            _libraryManager = libraryManager;
 2267            _logger = logger;
 2268            _mediaSourceManager = mediaSourceManager;
 2269            _lyricResolver = lyricResolver;
 2270            _lyricManager = lyricManager;
 2271            _mediaStreamRepository = mediaStreamRepository;
 2272            _chapterManager = chapterManager;
 2273            ATL.Settings.DisplayValueSeparator = InternalValueSeparator;
 2274            ATL.Settings.UseFileNameWhenNoTitle = false;
 2275            ATL.Settings.ID3v2_separatev2v3Values = false;
 2276        }
 77
 78        /// <summary>
 79        /// Probes the specified item for metadata.
 80        /// </summary>
 81        /// <param name="item">The item to probe.</param>
 82        /// <param name="options">The <see cref="MetadataRefreshOptions"/>.</param>
 83        /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
 84        /// <typeparam name="T">The type of item to resolve.</typeparam>
 85        /// <returns>A <see cref="Task"/> probing the item for metadata.</returns>
 86        public async Task<ItemUpdateType> Probe<T>(
 87            T item,
 88            MetadataRefreshOptions options,
 89            CancellationToken cancellationToken)
 90            where T : Audio
 91        {
 092            var path = item.Path;
 093            var protocol = item.PathProtocol ?? MediaProtocol.File;
 94
 095            if (!item.IsShortcut || options.EnableRemoteContentProbe)
 96            {
 097                if (item.IsShortcut)
 98                {
 099                    path = item.ShortcutPath;
 0100                    protocol = _mediaSourceManager.GetPathProtocol(path);
 101                }
 102
 0103                var result = await _mediaEncoder.GetMediaInfo(
 0104                    new MediaInfoRequest
 0105                    {
 0106                        MediaType = DlnaProfileType.Audio,
 0107                        ExtractChapters = item is AudioBook,
 0108                        MediaSource = new MediaSourceInfo
 0109                        {
 0110                            Path = path,
 0111                            Protocol = protocol
 0112                        }
 0113                    },
 0114                    cancellationToken).ConfigureAwait(false);
 115
 0116                cancellationToken.ThrowIfCancellationRequested();
 117
 0118                await FetchAsync(item, result, options, cancellationToken).ConfigureAwait(false);
 119            }
 120
 0121            return ItemUpdateType.MetadataImport;
 0122        }
 123
 124        /// <summary>
 125        /// Fetches the specified audio.
 126        /// </summary>
 127        /// <param name="audio">The <see cref="Audio"/>.</param>
 128        /// <param name="mediaInfo">The <see cref="Model.MediaInfo.MediaInfo"/>.</param>
 129        /// <param name="options">The <see cref="MetadataRefreshOptions"/>.</param>
 130        /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
 131        /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
 132        private async Task FetchAsync(
 133            Audio audio,
 134            Model.MediaInfo.MediaInfo mediaInfo,
 135            MetadataRefreshOptions options,
 136            CancellationToken cancellationToken)
 137        {
 0138            audio.Container = mediaInfo.Container;
 0139            audio.TotalBitrate = mediaInfo.Bitrate;
 140
 0141            audio.RunTimeTicks = mediaInfo.RunTimeTicks;
 142
 143            // Add external lyrics first to prevent the lrc file get overwritten on first scan
 0144            var mediaStreams = new List<MediaStream>(mediaInfo.MediaStreams);
 0145            AddExternalLyrics(audio, mediaStreams, options);
 0146            var tryExtractEmbeddedLyrics = mediaStreams.All(s => s.Type != MediaStreamType.Lyric);
 147
 0148            if (!audio.IsLocked)
 149            {
 0150                await FetchDataFromTags(audio, mediaInfo, options, tryExtractEmbeddedLyrics).ConfigureAwait(false);
 0151                if (tryExtractEmbeddedLyrics)
 152                {
 0153                    AddExternalLyrics(audio, mediaStreams, options);
 154                }
 155            }
 156
 0157            audio.HasLyrics = mediaStreams.Any(s => s.Type == MediaStreamType.Lyric);
 158
 0159            _mediaStreamRepository.SaveMediaStreams(audio.Id, mediaStreams, cancellationToken);
 160
 0161            if (audio is AudioBook && mediaInfo.Chapters is { Length: > 0 })
 162            {
 0163                _chapterManager.SaveChapters(audio, mediaInfo.Chapters);
 164            }
 0165        }
 166
 167        /// <summary>
 168        /// Fetches data from the tags.
 169        /// </summary>
 170        /// <param name="audio">The <see cref="Audio"/>.</param>
 171        /// <param name="mediaInfo">The <see cref="Model.MediaInfo.MediaInfo"/>.</param>
 172        /// <param name="options">The <see cref="MetadataRefreshOptions"/>.</param>
 173        /// <param name="tryExtractEmbeddedLyrics">Whether to extract embedded lyrics to lrc file. </param>
 174        private async Task FetchDataFromTags(Audio audio, Model.MediaInfo.MediaInfo mediaInfo, MetadataRefreshOptions op
 175        {
 0176            var libraryOptions = _libraryManager.GetLibraryOptions(audio);
 0177            Track track = new Track(audio.Path);
 178
 0179            if (track.MetadataFormats
 0180                .All(mf => string.Equals(mf.ShortName, "ID3v1", StringComparison.OrdinalIgnoreCase)))
 181            {
 0182                _logger.LogWarning("File {File} only has ID3v1 tags, some fields may be truncated", audio.Path);
 183            }
 184
 185            // We should never use the property setter of the ATL.Track class.
 186            // That setter is meant for its own tag parser and external editor usage and will have unwanted side effects
 187            // For example, setting the Year property will also set the Date property, which is not what we want here.
 188            // To properly handle fallback values, we make a clone of those fields when valid.
 0189            var trackTitle = (string.IsNullOrEmpty(track.Title) ? mediaInfo.Name : track.Title)?.Trim();
 0190            var trackAlbum = (string.IsNullOrEmpty(track.Album) ? mediaInfo.Album : track.Album)?.Trim();
 0191            var trackYear = track.Year is null or 0 ? mediaInfo.ProductionYear : track.Year;
 0192            var trackTrackNumber = track.TrackNumber is null or 0 ? mediaInfo.IndexNumber : track.TrackNumber;
 0193            var trackDiscNumber = track.DiscNumber is null or 0 ? mediaInfo.ParentIndexNumber : track.DiscNumber;
 194
 195            // Some users may use a misbehaved tag editor that writes a null character in the tag when not allowed by th
 0196            trackTitle = GetSanitizedStringTag(trackTitle, audio.Path);
 0197            trackAlbum = GetSanitizedStringTag(trackAlbum, audio.Path);
 0198            var trackAlbumArtist = GetSanitizedStringTag(track.AlbumArtist, audio.Path);
 0199            var trackArist = GetSanitizedStringTag(track.Artist, audio.Path);
 0200            var trackComposer = GetSanitizedStringTag(track.Composer, audio.Path);
 0201            var trackGenre = GetSanitizedStringTag(track.Genre, audio.Path);
 202
 0203            if (audio.SupportsPeople && !audio.LockedFields.Contains(MetadataField.Cast))
 204            {
 0205                var people = new List<PersonInfo>();
 0206                string[]? albumArtists = null;
 0207                if (libraryOptions.PreferNonstandardArtistsTag)
 208                {
 0209                    TryGetSanitizedAdditionalFields(track, "ALBUMARTISTS", out var albumArtistsTagString);
 0210                    if (albumArtistsTagString is not null)
 211                    {
 0212                        albumArtists = albumArtistsTagString.Split(InternalValueSeparator);
 213                    }
 214                }
 215
 0216                if (albumArtists is null || albumArtists.Length == 0)
 217                {
 0218                    albumArtists = string.IsNullOrEmpty(trackAlbumArtist) ? [] : trackAlbumArtist.Split(InternalValueSep
 219                }
 220
 0221                if (libraryOptions.UseCustomTagDelimiters)
 222                {
 0223                    albumArtists = albumArtists.SelectMany(a => SplitWithCustomDelimiter(a, libraryOptions.GetCustomTagD
 224                }
 225
 0226                string[]? performers = null;
 0227                if (libraryOptions.PreferNonstandardArtistsTag)
 228                {
 0229                    TryGetSanitizedAdditionalFields(track, "ARTISTS", out var artistsTagString);
 0230                    if (artistsTagString is not null)
 231                    {
 0232                        performers = artistsTagString.Split(InternalValueSeparator);
 233                    }
 234                }
 235
 0236                if (performers is null || performers.Length == 0)
 237                {
 0238                    performers = string.IsNullOrEmpty(trackArist) ? [] : trackArist.Split(InternalValueSeparator);
 239                }
 240
 0241                if (libraryOptions.UseCustomTagDelimiters)
 242                {
 0243                    performers = performers.SelectMany(p => SplitWithCustomDelimiter(p, libraryOptions.GetCustomTagDelim
 244                }
 245
 0246                var isAudioBook = audio is AudioBook;
 247
 0248                if (isAudioBook)
 249                {
 250                    // For audiobooks: AlbumArtists/Performers = Author, NARRATOR tag = Narrator,
 251                    // ILLUSTRATOR tag = Illustrator, Composer = fallback Narrator, other performers = Cast.
 252                    // If album_artist is missing, fall back to artist/performers for the author role.
 0253                    var authorSource = albumArtists.Length > 0 ? albumArtists : performers;
 0254                    var authorNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 255
 0256                    foreach (var author in authorSource)
 257                    {
 0258                        if (!string.IsNullOrWhiteSpace(author))
 259                        {
 0260                            authorNames.Add(author.Trim());
 0261                            PeopleHelper.AddPerson(people, new PersonInfo
 0262                            {
 0263                                Name = author.Trim(),
 0264                                Type = PersonKind.Author
 0265                            });
 266                        }
 267                    }
 268
 269                    // Composer tag = Narrator (Audiobookshelf and other tools use Composer for narrator)
 0270                    if (!string.IsNullOrWhiteSpace(trackComposer))
 271                    {
 0272                        foreach (var composer in trackComposer.Split(InternalValueSeparator))
 273                        {
 0274                            if (!string.IsNullOrWhiteSpace(composer))
 275                            {
 0276                                PeopleHelper.AddPerson(people, new PersonInfo
 0277                                {
 0278                                    Name = composer.Trim(),
 0279                                    Type = PersonKind.Narrator
 0280                                });
 281                            }
 282                        }
 283                    }
 284
 285                    // Any performers not already listed as authors get added as cast
 0286                    foreach (var performer in performers)
 287                    {
 0288                        if (!string.IsNullOrWhiteSpace(performer) && !authorNames.Contains(performer.Trim()))
 289                        {
 0290                            PeopleHelper.AddPerson(people, new PersonInfo
 0291                            {
 0292                                Name = performer.Trim(),
 0293                                Type = PersonKind.Actor
 0294                            });
 295                        }
 296                    }
 297                }
 298                else
 299                {
 300                    // Standard music track handling
 0301                    foreach (var albumArtist in albumArtists)
 302                    {
 0303                        if (!string.IsNullOrWhiteSpace(albumArtist))
 304                        {
 0305                            PeopleHelper.AddPerson(people, new PersonInfo
 0306                            {
 0307                                Name = albumArtist,
 0308                                Type = PersonKind.AlbumArtist
 0309                            });
 310                        }
 311                    }
 312
 0313                    foreach (var performer in performers)
 314                    {
 0315                        if (!string.IsNullOrWhiteSpace(performer))
 316                        {
 0317                            PeopleHelper.AddPerson(people, new PersonInfo
 0318                            {
 0319                                Name = performer,
 0320                                Type = PersonKind.Artist
 0321                            });
 322                        }
 323                    }
 324
 0325                    if (!string.IsNullOrWhiteSpace(trackComposer))
 326                    {
 0327                        foreach (var composer in trackComposer.Split(InternalValueSeparator))
 328                        {
 0329                            if (!string.IsNullOrWhiteSpace(composer))
 330                            {
 0331                                PeopleHelper.AddPerson(people, new PersonInfo
 0332                                {
 0333                                    Name = composer,
 0334                                    Type = PersonKind.Composer
 0335                                });
 336                            }
 337                        }
 338                    }
 339                }
 340
 0341                _libraryManager.UpdatePeople(audio, people);
 342
 0343                if (options.ReplaceAllMetadata && performers.Length != 0)
 344                {
 0345                    audio.Artists = performers;
 346                }
 0347                else if (!options.ReplaceAllMetadata
 0348                         && (audio.Artists is null || audio.Artists.Count == 0))
 349                {
 0350                    audio.Artists = performers;
 351                }
 352
 0353                if (albumArtists.Length == 0)
 354                {
 355                    // Album artists not provided, fall back to performers (artists).
 0356                    albumArtists = performers;
 357                }
 358
 0359                if (options.ReplaceAllMetadata && albumArtists.Length != 0)
 360                {
 0361                    audio.AlbumArtists = albumArtists;
 362                }
 0363                else if (!options.ReplaceAllMetadata
 0364                         && (audio.AlbumArtists is null || audio.AlbumArtists.Count == 0))
 365                {
 0366                    audio.AlbumArtists = albumArtists;
 367                }
 368            }
 369
 0370            if (!audio.LockedFields.Contains(MetadataField.Name) && !string.IsNullOrEmpty(trackTitle))
 371            {
 0372                audio.Name = trackTitle;
 373            }
 374
 0375            if (options.ReplaceAllMetadata)
 376            {
 0377                audio.Album = trackAlbum;
 0378                audio.IndexNumber = trackTrackNumber;
 0379                audio.ParentIndexNumber = trackDiscNumber;
 380            }
 381            else
 382            {
 0383                audio.Album ??= trackAlbum;
 0384                audio.IndexNumber ??= trackTrackNumber;
 0385                audio.ParentIndexNumber ??= trackDiscNumber;
 386            }
 387
 0388            if (track.Date.HasValue)
 389            {
 0390                audio.PremiereDate = track.Date;
 391            }
 392
 0393            if (trackYear.HasValue)
 394            {
 0395                var year = trackYear.Value;
 0396                audio.ProductionYear = year;
 397
 398                // ATL library handles such fallback this with its own internal logic, but we also need to handle it her
 0399                if (!audio.PremiereDate.HasValue)
 400                {
 401                    try
 402                    {
 0403                        audio.PremiereDate = new DateTime(year, 01, 01);
 0404                    }
 0405                    catch (ArgumentOutOfRangeException ex)
 406                    {
 0407                        _logger.LogError(ex, "Error parsing YEAR tag in {File}. '{TagValue}' is an invalid year", audio.
 0408                    }
 409                }
 410            }
 411
 0412            if (!audio.LockedFields.Contains(MetadataField.Genres))
 413            {
 0414                var genres = string.IsNullOrEmpty(trackGenre) ? [] : trackGenre.Split(InternalValueSeparator).Distinct(S
 415
 0416                if (libraryOptions.UseCustomTagDelimiters)
 417                {
 0418                    genres = genres.SelectMany(g => SplitWithCustomDelimiter(g, libraryOptions.GetCustomTagDelimiters(),
 419                }
 420
 0421                genres = genres.Trimmed().Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
 422
 0423                if (options.ReplaceAllMetadata || audio.Genres is null || audio.Genres.Length == 0 || audio.Genres.All(s
 424                {
 0425                    audio.Genres = genres;
 426                }
 427            }
 428
 429            // Audiobook-specific metadata: Overview, Publisher, Series
 0430            if (audio is AudioBook audioBook)
 431            {
 0432                if (!audio.LockedFields.Contains(MetadataField.Overview))
 433                {
 0434                    var trackDescription = GetSanitizedStringTag(track.Description, audio.Path);
 0435                    var trackComment = GetSanitizedStringTag(track.Comment, audio.Path);
 0436                    var overview = !string.IsNullOrWhiteSpace(trackDescription) ? trackDescription : trackComment;
 437
 0438                    if (!string.IsNullOrWhiteSpace(overview))
 439                    {
 0440                        if (options.ReplaceAllMetadata || string.IsNullOrEmpty(audio.Overview))
 441                        {
 0442                            audio.Overview = overview;
 443                        }
 444                    }
 445                }
 446
 447                // Publisher → Studio
 0448                var trackPublisher = GetSanitizedStringTag(track.Publisher, audio.Path);
 0449                if (!string.IsNullOrWhiteSpace(trackPublisher)
 0450                    && (options.ReplaceAllMetadata || audio.Studios is null || audio.Studios.Length == 0))
 451                {
 0452                    audio.SetStudios(new[] { trackPublisher! });
 453                }
 454            }
 455
 0456            TryGetSanitizedAdditionalFields(track, "REPLAYGAIN_TRACK_GAIN", out var trackGainTag);
 457
 0458            if (trackGainTag is not null)
 459            {
 0460                if (trackGainTag.EndsWith("db", StringComparison.OrdinalIgnoreCase))
 461                {
 0462                    trackGainTag = trackGainTag[..^2].Trim();
 463                }
 464
 0465                if (float.TryParse(trackGainTag, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) && flo
 466                {
 0467                    audio.NormalizationGain = value;
 468                }
 469            }
 470
 0471            if (audio.AlbumEntity is not null && !audio.AlbumEntity.NormalizationGain.HasValue)
 472            {
 0473                TryGetSanitizedAdditionalFields(track, "REPLAYGAIN_ALBUM_GAIN", out var trackAlbumGainTag);
 474
 0475                if (trackAlbumGainTag is not null)
 476                {
 0477                    if (trackAlbumGainTag.EndsWith("db", StringComparison.OrdinalIgnoreCase))
 478                    {
 0479                        trackAlbumGainTag = trackAlbumGainTag[..^2].Trim();
 480                    }
 481
 0482                    if (float.TryParse(trackAlbumGainTag, NumberStyles.Float, CultureInfo.InvariantCulture, out var valu
 483                    {
 0484                        audio.AlbumEntity.NormalizationGain = value;
 485                    }
 486                }
 487            }
 488
 0489            if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _))
 490            {
 0491                if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_ARTISTID", out var musicBrainzArtistTag)
 0492                     || TryGetSanitizedAdditionalFields(track, "MusicBrainz Artist Id", out musicBrainzArtistTag))
 0493                    && !string.IsNullOrEmpty(musicBrainzArtistTag))
 494                {
 0495                    var id = GetFirstMusicBrainzId(musicBrainzArtistTag, libraryOptions.UseCustomTagDelimiters, libraryO
 0496                    audio.TrySetProviderId(MetadataProvider.MusicBrainzArtist, id);
 497                }
 498            }
 499
 0500            if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out _))
 501            {
 0502                if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_ALBUMARTISTID", out var musicBrainzReleaseArtis
 0503                     || TryGetSanitizedAdditionalFields(track, "MusicBrainz Album Artist Id", out musicBrainzReleaseArti
 0504                    && !string.IsNullOrEmpty(musicBrainzReleaseArtistIdTag))
 505                {
 0506                    var id = GetFirstMusicBrainzId(musicBrainzReleaseArtistIdTag, libraryOptions.UseCustomTagDelimiters,
 0507                    audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbumArtist, id);
 508                }
 509            }
 510
 0511            if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out _))
 512            {
 0513                if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_ALBUMID", out var musicBrainzReleaseIdTag)
 0514                     || TryGetSanitizedAdditionalFields(track, "MusicBrainz Album Id", out musicBrainzReleaseIdTag))
 0515                    && !string.IsNullOrEmpty(musicBrainzReleaseIdTag))
 516                {
 0517                    var id = GetFirstMusicBrainzId(musicBrainzReleaseIdTag, libraryOptions.UseCustomTagDelimiters, libra
 0518                    audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbum, id);
 519                }
 520            }
 521
 0522            if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out _))
 523            {
 0524                if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_RELEASEGROUPID", out var musicBrainzReleaseGrou
 0525                     || TryGetSanitizedAdditionalFields(track, "MusicBrainz Release Group Id", out musicBrainzReleaseGro
 0526                    && !string.IsNullOrEmpty(musicBrainzReleaseGroupIdTag))
 527                {
 0528                    var id = GetFirstMusicBrainzId(musicBrainzReleaseGroupIdTag, libraryOptions.UseCustomTagDelimiters, 
 0529                    audio.TrySetProviderId(MetadataProvider.MusicBrainzReleaseGroup, id);
 530                }
 531            }
 532
 0533            if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out _))
 534            {
 0535                if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_RELEASETRACKID", out var trackMbId)
 0536                     || TryGetSanitizedAdditionalFields(track, "MusicBrainz Release Track Id", out trackMbId))
 0537                    && !string.IsNullOrEmpty(trackMbId))
 538                {
 0539                    var id = GetFirstMusicBrainzId(trackMbId, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetC
 0540                    audio.TrySetProviderId(MetadataProvider.MusicBrainzTrack, id);
 541                }
 542            }
 543
 0544            if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzRecording, out _))
 545            {
 0546                if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_TRACKID", out var recordingMbId)
 0547                     || TryGetSanitizedAdditionalFields(track, "MusicBrainz Track Id", out recordingMbId))
 0548                    && !string.IsNullOrEmpty(recordingMbId))
 549                {
 0550                    audio.TrySetProviderId(MetadataProvider.MusicBrainzRecording, recordingMbId);
 551                }
 0552                else if (TryGetSanitizedUFIDFields(track, out var owner, out var identifier) && !string.IsNullOrEmpty(ow
 553                {
 554                    // If tagged with MB Picard, the format is 'http://musicbrainz.org\0<recording MBID>'
 0555                    if (owner.Contains("musicbrainz.org", StringComparison.OrdinalIgnoreCase))
 556                    {
 0557                        audio.TrySetProviderId(MetadataProvider.MusicBrainzRecording, identifier);
 558                    }
 559                }
 560            }
 561
 562            // Save extracted lyrics if they exist,
 563            // and if the audio doesn't yet have lyrics.
 564            // ATL supports both SRT and LRC formats as synchronized lyrics, but we only want to save LRC format.
 0565            var supportedLyrics = track.Lyrics.Where(l => l.Format != LyricsInfo.LyricsFormat.SRT).ToList();
 0566            var candidateSynchronizedLyric = supportedLyrics.FirstOrDefault(l => l.Format is not LyricsInfo.LyricsFormat
 0567            var candidateUnsynchronizedLyric = supportedLyrics.FirstOrDefault(l => l.Format is LyricsInfo.LyricsFormat.U
 0568            var lyrics = candidateSynchronizedLyric is not null ? candidateSynchronizedLyric.FormatSynch() : candidateUn
 0569            if (!string.IsNullOrWhiteSpace(lyrics)
 0570                && (tryExtractEmbeddedLyrics || options.ReplaceAllMetadata))
 571            {
 0572                await _lyricManager.SaveLyricAsync(audio, "lrc", lyrics).ConfigureAwait(false);
 573            }
 0574        }
 575
 576        private void AddExternalLyrics(
 577            Audio audio,
 578            List<MediaStream> currentStreams,
 579            MetadataRefreshOptions options)
 580        {
 0581            var startIndex = currentStreams.Count == 0 ? 0 : (currentStreams.Select(i => i.Index).Max() + 1);
 0582            var externalLyricFiles = _lyricResolver.GetExternalStreams(audio, startIndex, options.DirectoryService, fals
 583
 0584            audio.LyricFiles = externalLyricFiles.Select(i => i.Path).Distinct().ToArray();
 0585            if (externalLyricFiles.Count > 0)
 586            {
 0587                currentStreams.Add(externalLyricFiles[0]);
 588            }
 0589        }
 590
 591        private List<string> SplitWithCustomDelimiter(string val, char[] tagDelimiters, string[] whitelist)
 592        {
 0593            var items = new List<string>();
 0594            var temp = val;
 0595            foreach (var whitelistItem in whitelist)
 596            {
 0597                if (string.IsNullOrWhiteSpace(whitelistItem))
 598                {
 599                    continue;
 600                }
 601
 0602                var originalTemp = temp;
 0603                temp = temp.Replace(whitelistItem, string.Empty, StringComparison.OrdinalIgnoreCase);
 604
 0605                if (!string.Equals(temp, originalTemp, StringComparison.OrdinalIgnoreCase))
 606                {
 0607                    items.Add(whitelistItem);
 608                }
 609            }
 610
 0611            var items2 = temp.Split(tagDelimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntrie
 0612            items.AddRange(items2);
 613
 0614            return items;
 615        }
 616
 617        // MusicBrainz IDs are multi-value tags, so we need to split them
 618        // However, our current provider can only have one single ID, which means we need to pick the first one
 619        private string? GetFirstMusicBrainzId(string tag, bool useCustomTagDelimiters, char[] tagDelimiters, string[] wh
 620        {
 0621            var val = tag.Split(InternalValueSeparator).FirstOrDefault();
 0622            if (val is not null && useCustomTagDelimiters)
 623            {
 0624                val = SplitWithCustomDelimiter(val, tagDelimiters, whitelist).FirstOrDefault();
 625            }
 626
 0627            return val;
 628        }
 629
 630        private string? GetSanitizedStringTag(string? tag, string filePath)
 631        {
 0632            if (string.IsNullOrEmpty(tag))
 633            {
 0634                return null;
 635            }
 636
 0637            var result = tag.TruncateAtNull();
 0638            if (result.Length != tag.Length)
 639            {
 0640                _logger.LogWarning("Audio file {File} contains a null character in its tag, but this is not allowed by i
 641            }
 642
 0643            return result;
 644        }
 645
 646        private bool TryGetSanitizedAdditionalFields(Track track, string field, out string? value)
 647        {
 0648            var hasField = TryGetAdditionalFieldWithFallback(track, field, out value);
 0649            value = GetSanitizedStringTag(value, track.Path);
 0650            return hasField;
 651        }
 652
 653        private bool TryGetSanitizedUFIDFields(Track track, out string? owner, out string? identifier)
 654        {
 0655            var hasField = TryGetAdditionalFieldWithFallback(track, "UFID", out string? value);
 0656            if (hasField && !string.IsNullOrEmpty(value))
 657            {
 0658                string[] parts = value.Split('\0');
 0659                if (parts.Length == 2)
 660                {
 0661                    owner = GetSanitizedStringTag(parts[0], track.Path);
 0662                    identifier = GetSanitizedStringTag(parts[1], track.Path);
 0663                    return true;
 664                }
 665            }
 666
 0667            owner = null;
 0668            identifier = null;
 0669            return false;
 670        }
 671
 672        // Build the explicit mka-style fallback key (e.g., ARTISTS -> track.artists, "MusicBrainz Artist Id" -> track.m
 673        private static string GetMkaFallbackKey(string key)
 674        {
 0675            if (string.IsNullOrWhiteSpace(key))
 676            {
 0677                return key;
 678            }
 679
 0680            var normalized = key.Trim().Replace(' ', '_').ToLowerInvariant();
 0681            return "track." + normalized;
 682        }
 683
 684        // First try the normal key exactly; if missing, try the mka-style fallback key.
 685        private bool TryGetAdditionalFieldWithFallback(Track track, string key, out string? value)
 686        {
 687            // Prefer the normal key (as-is, case-sensitive)
 0688            if (track.AdditionalFields.TryGetValue(key, out value))
 689            {
 0690                return true;
 691            }
 692
 693            // Fallback to mka-style: "track." + lower-case(original key)
 0694            var fallbackKey = GetMkaFallbackKey(key);
 0695            if (track.AdditionalFields.TryGetValue(fallbackKey, out value))
 696            {
 0697                return true;
 698            }
 699
 0700            value = null;
 0701            return false;
 702        }
 703    }
 704}