< 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: 277
Coverable lines: 289
Total lines: 686
Line coverage: 4.1%
Branch coverage
0%
Covered branches: 0
Total branches: 290
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 1/23/2026 - 12:11:06 AM Line coverage: 17.7% (11/62) Branch coverage: 0% (0/28) Total lines: 5924/19/2026 - 12:14:27 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: 686 1/23/2026 - 12:11:06 AM Line coverage: 17.7% (11/62) Branch coverage: 0% (0/28) Total lines: 5924/19/2026 - 12:14:27 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: 686

Coverage delta

Coverage delta 14 -14

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        {
 2165            _mediaEncoder = mediaEncoder;
 2166            _libraryManager = libraryManager;
 2167            _logger = logger;
 2168            _mediaSourceManager = mediaSourceManager;
 2169            _lyricResolver = lyricResolver;
 2170            _lyricManager = lyricManager;
 2171            _mediaStreamRepository = mediaStreamRepository;
 2172            _chapterManager = chapterManager;
 2173            ATL.Settings.DisplayValueSeparator = InternalValueSeparator;
 2174            ATL.Settings.UseFileNameWhenNoTitle = false;
 2175            ATL.Settings.ID3v2_separatev2v3Values = false;
 2176        }
 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 (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _))
 472            {
 0473                if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_ARTISTID", out var musicBrainzArtistTag)
 0474                     || TryGetSanitizedAdditionalFields(track, "MusicBrainz Artist Id", out musicBrainzArtistTag))
 0475                    && !string.IsNullOrEmpty(musicBrainzArtistTag))
 476                {
 0477                    var id = GetFirstMusicBrainzId(musicBrainzArtistTag, libraryOptions.UseCustomTagDelimiters, libraryO
 0478                    audio.TrySetProviderId(MetadataProvider.MusicBrainzArtist, id);
 479                }
 480            }
 481
 0482            if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out _))
 483            {
 0484                if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_ALBUMARTISTID", out var musicBrainzReleaseArtis
 0485                     || TryGetSanitizedAdditionalFields(track, "MusicBrainz Album Artist Id", out musicBrainzReleaseArti
 0486                    && !string.IsNullOrEmpty(musicBrainzReleaseArtistIdTag))
 487                {
 0488                    var id = GetFirstMusicBrainzId(musicBrainzReleaseArtistIdTag, libraryOptions.UseCustomTagDelimiters,
 0489                    audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbumArtist, id);
 490                }
 491            }
 492
 0493            if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out _))
 494            {
 0495                if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_ALBUMID", out var musicBrainzReleaseIdTag)
 0496                     || TryGetSanitizedAdditionalFields(track, "MusicBrainz Album Id", out musicBrainzReleaseIdTag))
 0497                    && !string.IsNullOrEmpty(musicBrainzReleaseIdTag))
 498                {
 0499                    var id = GetFirstMusicBrainzId(musicBrainzReleaseIdTag, libraryOptions.UseCustomTagDelimiters, libra
 0500                    audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbum, id);
 501                }
 502            }
 503
 0504            if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out _))
 505            {
 0506                if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_RELEASEGROUPID", out var musicBrainzReleaseGrou
 0507                     || TryGetSanitizedAdditionalFields(track, "MusicBrainz Release Group Id", out musicBrainzReleaseGro
 0508                    && !string.IsNullOrEmpty(musicBrainzReleaseGroupIdTag))
 509                {
 0510                    var id = GetFirstMusicBrainzId(musicBrainzReleaseGroupIdTag, libraryOptions.UseCustomTagDelimiters, 
 0511                    audio.TrySetProviderId(MetadataProvider.MusicBrainzReleaseGroup, id);
 512                }
 513            }
 514
 0515            if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out _))
 516            {
 0517                if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_RELEASETRACKID", out var trackMbId)
 0518                     || TryGetSanitizedAdditionalFields(track, "MusicBrainz Release Track Id", out trackMbId))
 0519                    && !string.IsNullOrEmpty(trackMbId))
 520                {
 0521                    var id = GetFirstMusicBrainzId(trackMbId, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetC
 0522                    audio.TrySetProviderId(MetadataProvider.MusicBrainzTrack, id);
 523                }
 524            }
 525
 0526            if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzRecording, out _))
 527            {
 0528                if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_TRACKID", out var recordingMbId)
 0529                     || TryGetSanitizedAdditionalFields(track, "MusicBrainz Track Id", out recordingMbId))
 0530                    && !string.IsNullOrEmpty(recordingMbId))
 531                {
 0532                    audio.TrySetProviderId(MetadataProvider.MusicBrainzRecording, recordingMbId);
 533                }
 0534                else if (TryGetSanitizedUFIDFields(track, out var owner, out var identifier) && !string.IsNullOrEmpty(ow
 535                {
 536                    // If tagged with MB Picard, the format is 'http://musicbrainz.org\0<recording MBID>'
 0537                    if (owner.Contains("musicbrainz.org", StringComparison.OrdinalIgnoreCase))
 538                    {
 0539                        audio.TrySetProviderId(MetadataProvider.MusicBrainzRecording, identifier);
 540                    }
 541                }
 542            }
 543
 544            // Save extracted lyrics if they exist,
 545            // and if the audio doesn't yet have lyrics.
 546            // ATL supports both SRT and LRC formats as synchronized lyrics, but we only want to save LRC format.
 0547            var supportedLyrics = track.Lyrics.Where(l => l.Format != LyricsInfo.LyricsFormat.SRT).ToList();
 0548            var candidateSynchronizedLyric = supportedLyrics.FirstOrDefault(l => l.Format is not LyricsInfo.LyricsFormat
 0549            var candidateUnsynchronizedLyric = supportedLyrics.FirstOrDefault(l => l.Format is LyricsInfo.LyricsFormat.U
 0550            var lyrics = candidateSynchronizedLyric is not null ? candidateSynchronizedLyric.FormatSynch() : candidateUn
 0551            if (!string.IsNullOrWhiteSpace(lyrics)
 0552                && tryExtractEmbeddedLyrics)
 553            {
 0554                await _lyricManager.SaveLyricAsync(audio, "lrc", lyrics).ConfigureAwait(false);
 555            }
 0556        }
 557
 558        private void AddExternalLyrics(
 559            Audio audio,
 560            List<MediaStream> currentStreams,
 561            MetadataRefreshOptions options)
 562        {
 0563            var startIndex = currentStreams.Count == 0 ? 0 : (currentStreams.Select(i => i.Index).Max() + 1);
 0564            var externalLyricFiles = _lyricResolver.GetExternalStreams(audio, startIndex, options.DirectoryService, fals
 565
 0566            audio.LyricFiles = externalLyricFiles.Select(i => i.Path).Distinct().ToArray();
 0567            if (externalLyricFiles.Count > 0)
 568            {
 0569                currentStreams.Add(externalLyricFiles[0]);
 570            }
 0571        }
 572
 573        private List<string> SplitWithCustomDelimiter(string val, char[] tagDelimiters, string[] whitelist)
 574        {
 0575            var items = new List<string>();
 0576            var temp = val;
 0577            foreach (var whitelistItem in whitelist)
 578            {
 0579                if (string.IsNullOrWhiteSpace(whitelistItem))
 580                {
 581                    continue;
 582                }
 583
 0584                var originalTemp = temp;
 0585                temp = temp.Replace(whitelistItem, string.Empty, StringComparison.OrdinalIgnoreCase);
 586
 0587                if (!string.Equals(temp, originalTemp, StringComparison.OrdinalIgnoreCase))
 588                {
 0589                    items.Add(whitelistItem);
 590                }
 591            }
 592
 0593            var items2 = temp.Split(tagDelimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntrie
 0594            items.AddRange(items2);
 595
 0596            return items;
 597        }
 598
 599        // MusicBrainz IDs are multi-value tags, so we need to split them
 600        // However, our current provider can only have one single ID, which means we need to pick the first one
 601        private string? GetFirstMusicBrainzId(string tag, bool useCustomTagDelimiters, char[] tagDelimiters, string[] wh
 602        {
 0603            var val = tag.Split(InternalValueSeparator).FirstOrDefault();
 0604            if (val is not null && useCustomTagDelimiters)
 605            {
 0606                val = SplitWithCustomDelimiter(val, tagDelimiters, whitelist).FirstOrDefault();
 607            }
 608
 0609            return val;
 610        }
 611
 612        private string? GetSanitizedStringTag(string? tag, string filePath)
 613        {
 0614            if (string.IsNullOrEmpty(tag))
 615            {
 0616                return null;
 617            }
 618
 0619            var result = tag.TruncateAtNull();
 0620            if (result.Length != tag.Length)
 621            {
 0622                _logger.LogWarning("Audio file {File} contains a null character in its tag, but this is not allowed by i
 623            }
 624
 0625            return result;
 626        }
 627
 628        private bool TryGetSanitizedAdditionalFields(Track track, string field, out string? value)
 629        {
 0630            var hasField = TryGetAdditionalFieldWithFallback(track, field, out value);
 0631            value = GetSanitizedStringTag(value, track.Path);
 0632            return hasField;
 633        }
 634
 635        private bool TryGetSanitizedUFIDFields(Track track, out string? owner, out string? identifier)
 636        {
 0637            var hasField = TryGetAdditionalFieldWithFallback(track, "UFID", out string? value);
 0638            if (hasField && !string.IsNullOrEmpty(value))
 639            {
 0640                string[] parts = value.Split('\0');
 0641                if (parts.Length == 2)
 642                {
 0643                    owner = GetSanitizedStringTag(parts[0], track.Path);
 0644                    identifier = GetSanitizedStringTag(parts[1], track.Path);
 0645                    return true;
 646                }
 647            }
 648
 0649            owner = null;
 0650            identifier = null;
 0651            return false;
 652        }
 653
 654        // Build the explicit mka-style fallback key (e.g., ARTISTS -> track.artists, "MusicBrainz Artist Id" -> track.m
 655        private static string GetMkaFallbackKey(string key)
 656        {
 0657            if (string.IsNullOrWhiteSpace(key))
 658            {
 0659                return key;
 660            }
 661
 0662            var normalized = key.Trim().Replace(' ', '_').ToLowerInvariant();
 0663            return "track." + normalized;
 664        }
 665
 666        // First try the normal key exactly; if missing, try the mka-style fallback key.
 667        private bool TryGetAdditionalFieldWithFallback(Track track, string key, out string? value)
 668        {
 669            // Prefer the normal key (as-is, case-sensitive)
 0670            if (track.AdditionalFields.TryGetValue(key, out value))
 671            {
 0672                return true;
 673            }
 674
 675            // Fallback to mka-style: "track." + lower-case(original key)
 0676            var fallbackKey = GetMkaFallbackKey(key);
 0677            if (track.AdditionalFields.TryGetValue(fallbackKey, out value))
 678            {
 0679                return true;
 680            }
 681
 0682            value = null;
 0683            return false;
 684        }
 685    }
 686}