| | 1 | | using System; |
| | 2 | | using System.Collections.Generic; |
| | 3 | | using System.Globalization; |
| | 4 | | using System.Linq; |
| | 5 | | using System.Threading; |
| | 6 | | using System.Threading.Tasks; |
| | 7 | | using ATL; |
| | 8 | | using Jellyfin.Data.Enums; |
| | 9 | | using MediaBrowser.Controller.Entities; |
| | 10 | | using MediaBrowser.Controller.Entities.Audio; |
| | 11 | | using MediaBrowser.Controller.Library; |
| | 12 | | using MediaBrowser.Controller.Lyrics; |
| | 13 | | using MediaBrowser.Controller.MediaEncoding; |
| | 14 | | using MediaBrowser.Controller.Persistence; |
| | 15 | | using MediaBrowser.Controller.Providers; |
| | 16 | | using MediaBrowser.Model.Dlna; |
| | 17 | | using MediaBrowser.Model.Dto; |
| | 18 | | using MediaBrowser.Model.Entities; |
| | 19 | | using MediaBrowser.Model.MediaInfo; |
| | 20 | | using Microsoft.Extensions.Logging; |
| | 21 | |
|
| | 22 | | namespace MediaBrowser.Providers.MediaInfo |
| | 23 | | { |
| | 24 | | /// <summary> |
| | 25 | | /// Probes audio files for metadata. |
| | 26 | | /// </summary> |
| | 27 | | public class AudioFileProber |
| | 28 | | { |
| | 29 | | private const char InternalValueSeparator = '\u001F'; |
| | 30 | | private readonly IMediaEncoder _mediaEncoder; |
| | 31 | | private readonly IItemRepository _itemRepo; |
| | 32 | | private readonly ILibraryManager _libraryManager; |
| | 33 | | private readonly ILogger<AudioFileProber> _logger; |
| | 34 | | private readonly IMediaSourceManager _mediaSourceManager; |
| | 35 | | private readonly LyricResolver _lyricResolver; |
| | 36 | | private readonly ILyricManager _lyricManager; |
| | 37 | |
|
| | 38 | | /// <summary> |
| | 39 | | /// Initializes a new instance of the <see cref="AudioFileProber"/> class. |
| | 40 | | /// </summary> |
| | 41 | | /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> |
| | 42 | | /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> |
| | 43 | | /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> |
| | 44 | | /// <param name="itemRepo">Instance of the <see cref="IItemRepository"/> interface.</param> |
| | 45 | | /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> |
| | 46 | | /// <param name="lyricResolver">Instance of the <see cref="LyricResolver"/> interface.</param> |
| | 47 | | /// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param> |
| | 48 | | public AudioFileProber( |
| | 49 | | ILogger<AudioFileProber> logger, |
| | 50 | | IMediaSourceManager mediaSourceManager, |
| | 51 | | IMediaEncoder mediaEncoder, |
| | 52 | | IItemRepository itemRepo, |
| | 53 | | ILibraryManager libraryManager, |
| | 54 | | LyricResolver lyricResolver, |
| | 55 | | ILyricManager lyricManager) |
| | 56 | | { |
| 22 | 57 | | _mediaEncoder = mediaEncoder; |
| 22 | 58 | | _itemRepo = itemRepo; |
| 22 | 59 | | _libraryManager = libraryManager; |
| 22 | 60 | | _logger = logger; |
| 22 | 61 | | _mediaSourceManager = mediaSourceManager; |
| 22 | 62 | | _lyricResolver = lyricResolver; |
| 22 | 63 | | _lyricManager = lyricManager; |
| 22 | 64 | | ATL.Settings.DisplayValueSeparator = InternalValueSeparator; |
| 22 | 65 | | } |
| | 66 | |
|
| | 67 | | /// <summary> |
| | 68 | | /// Probes the specified item for metadata. |
| | 69 | | /// </summary> |
| | 70 | | /// <param name="item">The item to probe.</param> |
| | 71 | | /// <param name="options">The <see cref="MetadataRefreshOptions"/>.</param> |
| | 72 | | /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param> |
| | 73 | | /// <typeparam name="T">The type of item to resolve.</typeparam> |
| | 74 | | /// <returns>A <see cref="Task"/> probing the item for metadata.</returns> |
| | 75 | | public async Task<ItemUpdateType> Probe<T>( |
| | 76 | | T item, |
| | 77 | | MetadataRefreshOptions options, |
| | 78 | | CancellationToken cancellationToken) |
| | 79 | | where T : Audio |
| | 80 | | { |
| | 81 | | var path = item.Path; |
| | 82 | | var protocol = item.PathProtocol ?? MediaProtocol.File; |
| | 83 | |
|
| | 84 | | if (!item.IsShortcut || options.EnableRemoteContentProbe) |
| | 85 | | { |
| | 86 | | if (item.IsShortcut) |
| | 87 | | { |
| | 88 | | path = item.ShortcutPath; |
| | 89 | | protocol = _mediaSourceManager.GetPathProtocol(path); |
| | 90 | | } |
| | 91 | |
|
| | 92 | | var result = await _mediaEncoder.GetMediaInfo( |
| | 93 | | new MediaInfoRequest |
| | 94 | | { |
| | 95 | | MediaType = DlnaProfileType.Audio, |
| | 96 | | MediaSource = new MediaSourceInfo |
| | 97 | | { |
| | 98 | | Path = path, |
| | 99 | | Protocol = protocol |
| | 100 | | } |
| | 101 | | }, |
| | 102 | | cancellationToken).ConfigureAwait(false); |
| | 103 | |
|
| | 104 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 105 | |
|
| | 106 | | await FetchAsync(item, result, options, cancellationToken).ConfigureAwait(false); |
| | 107 | | } |
| | 108 | |
|
| | 109 | | return ItemUpdateType.MetadataImport; |
| | 110 | | } |
| | 111 | |
|
| | 112 | | /// <summary> |
| | 113 | | /// Fetches the specified audio. |
| | 114 | | /// </summary> |
| | 115 | | /// <param name="audio">The <see cref="Audio"/>.</param> |
| | 116 | | /// <param name="mediaInfo">The <see cref="Model.MediaInfo.MediaInfo"/>.</param> |
| | 117 | | /// <param name="options">The <see cref="MetadataRefreshOptions"/>.</param> |
| | 118 | | /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param> |
| | 119 | | /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> |
| | 120 | | private async Task FetchAsync( |
| | 121 | | Audio audio, |
| | 122 | | Model.MediaInfo.MediaInfo mediaInfo, |
| | 123 | | MetadataRefreshOptions options, |
| | 124 | | CancellationToken cancellationToken) |
| | 125 | | { |
| | 126 | | audio.Container = mediaInfo.Container; |
| | 127 | | audio.TotalBitrate = mediaInfo.Bitrate; |
| | 128 | |
|
| | 129 | | audio.RunTimeTicks = mediaInfo.RunTimeTicks; |
| | 130 | | audio.Size = mediaInfo.Size; |
| | 131 | |
|
| | 132 | | // Add external lyrics first to prevent the lrc file get overwritten on first scan |
| | 133 | | var mediaStreams = new List<MediaStream>(mediaInfo.MediaStreams); |
| | 134 | | AddExternalLyrics(audio, mediaStreams, options); |
| | 135 | | var tryExtractEmbeddedLyrics = mediaStreams.All(s => s.Type != MediaStreamType.Lyric); |
| | 136 | |
|
| | 137 | | if (!audio.IsLocked) |
| | 138 | | { |
| | 139 | | await FetchDataFromTags(audio, mediaInfo, options, tryExtractEmbeddedLyrics).ConfigureAwait(false); |
| | 140 | | if (tryExtractEmbeddedLyrics) |
| | 141 | | { |
| | 142 | | AddExternalLyrics(audio, mediaStreams, options); |
| | 143 | | } |
| | 144 | | } |
| | 145 | |
|
| | 146 | | audio.HasLyrics = mediaStreams.Any(s => s.Type == MediaStreamType.Lyric); |
| | 147 | |
|
| | 148 | | _itemRepo.SaveMediaStreams(audio.Id, mediaStreams, cancellationToken); |
| | 149 | | } |
| | 150 | |
|
| | 151 | | /// <summary> |
| | 152 | | /// Fetches data from the tags. |
| | 153 | | /// </summary> |
| | 154 | | /// <param name="audio">The <see cref="Audio"/>.</param> |
| | 155 | | /// <param name="mediaInfo">The <see cref="Model.MediaInfo.MediaInfo"/>.</param> |
| | 156 | | /// <param name="options">The <see cref="MetadataRefreshOptions"/>.</param> |
| | 157 | | /// <param name="tryExtractEmbeddedLyrics">Whether to extract embedded lyrics to lrc file. </param> |
| | 158 | | private async Task FetchDataFromTags(Audio audio, Model.MediaInfo.MediaInfo mediaInfo, MetadataRefreshOptions op |
| | 159 | | { |
| | 160 | | var libraryOptions = _libraryManager.GetLibraryOptions(audio); |
| | 161 | | Track track = new Track(audio.Path); |
| | 162 | |
|
| | 163 | | // ATL will fall back to filename as title when it does not understand the metadata |
| | 164 | | if (track.MetadataFormats.All(mf => mf.Equals(ATL.Factory.UNKNOWN_FORMAT))) |
| | 165 | | { |
| | 166 | | track.Title = mediaInfo.Name; |
| | 167 | | } |
| | 168 | |
|
| | 169 | | track.Album = string.IsNullOrEmpty(track.Album) ? mediaInfo.Album : track.Album; |
| | 170 | | track.Year ??= mediaInfo.ProductionYear; |
| | 171 | | track.TrackNumber ??= mediaInfo.IndexNumber; |
| | 172 | | track.DiscNumber ??= mediaInfo.ParentIndexNumber; |
| | 173 | |
|
| | 174 | | if (audio.SupportsPeople && !audio.LockedFields.Contains(MetadataField.Cast)) |
| | 175 | | { |
| | 176 | | var people = new List<PersonInfo>(); |
| | 177 | | var albumArtists = string.IsNullOrEmpty(track.AlbumArtist) ? mediaInfo.AlbumArtists : track.AlbumArtist. |
| | 178 | |
|
| | 179 | | if (libraryOptions.UseCustomTagDelimiters) |
| | 180 | | { |
| | 181 | | albumArtists = albumArtists.SelectMany(a => SplitWithCustomDelimiter(a, libraryOptions.CustomTagDeli |
| | 182 | | } |
| | 183 | |
|
| | 184 | | foreach (var albumArtist in albumArtists) |
| | 185 | | { |
| | 186 | | if (!string.IsNullOrEmpty(albumArtist)) |
| | 187 | | { |
| | 188 | | PeopleHelper.AddPerson(people, new PersonInfo |
| | 189 | | { |
| | 190 | | Name = albumArtist, |
| | 191 | | Type = PersonKind.AlbumArtist |
| | 192 | | }); |
| | 193 | | } |
| | 194 | | } |
| | 195 | |
|
| | 196 | | string[]? performers = null; |
| | 197 | | if (libraryOptions.PreferNonstandardArtistsTag) |
| | 198 | | { |
| | 199 | | track.AdditionalFields.TryGetValue("ARTISTS", out var artistsTagString); |
| | 200 | | if (artistsTagString is not null) |
| | 201 | | { |
| | 202 | | performers = artistsTagString.Split(InternalValueSeparator); |
| | 203 | | } |
| | 204 | | } |
| | 205 | |
|
| | 206 | | if (performers is null || performers.Length == 0) |
| | 207 | | { |
| | 208 | | performers = string.IsNullOrEmpty(track.Artist) ? mediaInfo.Artists : track.Artist.Split(InternalVal |
| | 209 | | } |
| | 210 | |
|
| | 211 | | if (libraryOptions.UseCustomTagDelimiters) |
| | 212 | | { |
| | 213 | | performers = performers.SelectMany(p => SplitWithCustomDelimiter(p, libraryOptions.CustomTagDelimite |
| | 214 | | } |
| | 215 | |
|
| | 216 | | foreach (var performer in performers) |
| | 217 | | { |
| | 218 | | if (!string.IsNullOrEmpty(performer)) |
| | 219 | | { |
| | 220 | | PeopleHelper.AddPerson(people, new PersonInfo |
| | 221 | | { |
| | 222 | | Name = performer, |
| | 223 | | Type = PersonKind.Artist |
| | 224 | | }); |
| | 225 | | } |
| | 226 | | } |
| | 227 | |
|
| | 228 | | foreach (var composer in track.Composer.Split(InternalValueSeparator)) |
| | 229 | | { |
| | 230 | | if (!string.IsNullOrEmpty(composer)) |
| | 231 | | { |
| | 232 | | PeopleHelper.AddPerson(people, new PersonInfo |
| | 233 | | { |
| | 234 | | Name = composer, |
| | 235 | | Type = PersonKind.Composer |
| | 236 | | }); |
| | 237 | | } |
| | 238 | | } |
| | 239 | |
|
| | 240 | | _libraryManager.UpdatePeople(audio, people); |
| | 241 | |
|
| | 242 | | if (options.ReplaceAllMetadata && performers.Length != 0) |
| | 243 | | { |
| | 244 | | audio.Artists = performers; |
| | 245 | | } |
| | 246 | | else if (!options.ReplaceAllMetadata |
| | 247 | | && (audio.Artists is null || audio.Artists.Count == 0)) |
| | 248 | | { |
| | 249 | | audio.Artists = performers; |
| | 250 | | } |
| | 251 | |
|
| | 252 | | if (albumArtists.Length == 0) |
| | 253 | | { |
| | 254 | | // Album artists not provided, fall back to performers (artists). |
| | 255 | | albumArtists = performers; |
| | 256 | | } |
| | 257 | |
|
| | 258 | | if (options.ReplaceAllMetadata && albumArtists.Length != 0) |
| | 259 | | { |
| | 260 | | audio.AlbumArtists = albumArtists; |
| | 261 | | } |
| | 262 | | else if (!options.ReplaceAllMetadata |
| | 263 | | && (audio.AlbumArtists is null || audio.AlbumArtists.Count == 0)) |
| | 264 | | { |
| | 265 | | audio.AlbumArtists = albumArtists; |
| | 266 | | } |
| | 267 | | } |
| | 268 | |
|
| | 269 | | if (!audio.LockedFields.Contains(MetadataField.Name) && !string.IsNullOrEmpty(track.Title)) |
| | 270 | | { |
| | 271 | | audio.Name = track.Title; |
| | 272 | | } |
| | 273 | |
|
| | 274 | | if (options.ReplaceAllMetadata) |
| | 275 | | { |
| | 276 | | audio.Album = track.Album; |
| | 277 | | audio.IndexNumber = track.TrackNumber; |
| | 278 | | audio.ParentIndexNumber = track.DiscNumber; |
| | 279 | | } |
| | 280 | | else |
| | 281 | | { |
| | 282 | | audio.Album ??= track.Album; |
| | 283 | | audio.IndexNumber ??= track.TrackNumber; |
| | 284 | | audio.ParentIndexNumber ??= track.DiscNumber; |
| | 285 | | } |
| | 286 | |
|
| | 287 | | if (track.Date.HasValue) |
| | 288 | | { |
| | 289 | | audio.PremiereDate = track.Date; |
| | 290 | | } |
| | 291 | |
|
| | 292 | | if (track.Year.HasValue) |
| | 293 | | { |
| | 294 | | var year = track.Year.Value; |
| | 295 | | audio.ProductionYear = year; |
| | 296 | |
|
| | 297 | | if (!audio.PremiereDate.HasValue) |
| | 298 | | { |
| | 299 | | try |
| | 300 | | { |
| | 301 | | audio.PremiereDate = new DateTime(year, 01, 01); |
| | 302 | | } |
| | 303 | | catch (ArgumentOutOfRangeException ex) |
| | 304 | | { |
| | 305 | | _logger.LogError(ex, "Error parsing YEAR tag in {File}. '{TagValue}' is an invalid year", audio. |
| | 306 | | } |
| | 307 | | } |
| | 308 | | } |
| | 309 | |
|
| | 310 | | if (!audio.LockedFields.Contains(MetadataField.Genres)) |
| | 311 | | { |
| | 312 | | var genres = string.IsNullOrEmpty(track.Genre) ? mediaInfo.Genres : track.Genre.Split(InternalValueSepar |
| | 313 | |
|
| | 314 | | if (libraryOptions.UseCustomTagDelimiters) |
| | 315 | | { |
| | 316 | | genres = genres.SelectMany(g => SplitWithCustomDelimiter(g, libraryOptions.CustomTagDelimiters, libr |
| | 317 | | } |
| | 318 | |
|
| | 319 | | audio.Genres = options.ReplaceAllMetadata || audio.Genres is null || audio.Genres.Length == 0 |
| | 320 | | ? genres |
| | 321 | | : audio.Genres; |
| | 322 | | } |
| | 323 | |
|
| | 324 | | track.AdditionalFields.TryGetValue("REPLAYGAIN_TRACK_GAIN", out var trackGainTag); |
| | 325 | |
|
| | 326 | | if (trackGainTag is not null) |
| | 327 | | { |
| | 328 | | if (trackGainTag.EndsWith("db", StringComparison.OrdinalIgnoreCase)) |
| | 329 | | { |
| | 330 | | trackGainTag = trackGainTag[..^2].Trim(); |
| | 331 | | } |
| | 332 | |
|
| | 333 | | if (float.TryParse(trackGainTag, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) |
| | 334 | | { |
| | 335 | | audio.NormalizationGain = value; |
| | 336 | | } |
| | 337 | | } |
| | 338 | |
|
| | 339 | | if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _)) |
| | 340 | | { |
| | 341 | | if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_ARTISTID", out var musicBrainzArtistTag) |
| | 342 | | || track.AdditionalFields.TryGetValue("MusicBrainz Artist Id", out musicBrainzArtistTag)) |
| | 343 | | && !string.IsNullOrEmpty(musicBrainzArtistTag)) |
| | 344 | | { |
| | 345 | | audio.TrySetProviderId(MetadataProvider.MusicBrainzArtist, musicBrainzArtistTag); |
| | 346 | | } |
| | 347 | | } |
| | 348 | |
|
| | 349 | | if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out _)) |
| | 350 | | { |
| | 351 | | if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_ALBUMARTISTID", out var musicBrainzReleaseArtistIdT |
| | 352 | | || track.AdditionalFields.TryGetValue("MusicBrainz Album Artist Id", out musicBrainzReleaseArtistId |
| | 353 | | && !string.IsNullOrEmpty(musicBrainzReleaseArtistIdTag)) |
| | 354 | | { |
| | 355 | | audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbumArtist, musicBrainzReleaseArtistIdTag); |
| | 356 | | } |
| | 357 | | } |
| | 358 | |
|
| | 359 | | if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out _)) |
| | 360 | | { |
| | 361 | | if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_ALBUMID", out var musicBrainzReleaseIdTag) |
| | 362 | | || track.AdditionalFields.TryGetValue("MusicBrainz Album Id", out musicBrainzReleaseIdTag)) |
| | 363 | | && !string.IsNullOrEmpty(musicBrainzReleaseIdTag)) |
| | 364 | | { |
| | 365 | | audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbum, musicBrainzReleaseIdTag); |
| | 366 | | } |
| | 367 | | } |
| | 368 | |
|
| | 369 | | if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out _)) |
| | 370 | | { |
| | 371 | | if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_RELEASEGROUPID", out var musicBrainzReleaseGroupIdT |
| | 372 | | || track.AdditionalFields.TryGetValue("MusicBrainz Release Group Id", out musicBrainzReleaseGroupId |
| | 373 | | && !string.IsNullOrEmpty(musicBrainzReleaseGroupIdTag)) |
| | 374 | | { |
| | 375 | | audio.TrySetProviderId(MetadataProvider.MusicBrainzReleaseGroup, musicBrainzReleaseGroupIdTag); |
| | 376 | | } |
| | 377 | | } |
| | 378 | |
|
| | 379 | | if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out _)) |
| | 380 | | { |
| | 381 | | if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_RELEASETRACKID", out var trackMbId) |
| | 382 | | || track.AdditionalFields.TryGetValue("MusicBrainz Release Track Id", out trackMbId)) |
| | 383 | | && !string.IsNullOrEmpty(trackMbId)) |
| | 384 | | { |
| | 385 | | audio.TrySetProviderId(MetadataProvider.MusicBrainzTrack, trackMbId); |
| | 386 | | } |
| | 387 | | } |
| | 388 | |
|
| | 389 | | // Save extracted lyrics if they exist, |
| | 390 | | // and if the audio doesn't yet have lyrics. |
| | 391 | | var lyrics = track.Lyrics.SynchronizedLyrics.Count > 0 ? track.Lyrics.FormatSynchToLRC() : track.Lyrics.Unsy |
| | 392 | | if (!string.IsNullOrWhiteSpace(lyrics) |
| | 393 | | && tryExtractEmbeddedLyrics) |
| | 394 | | { |
| | 395 | | await _lyricManager.SaveLyricAsync(audio, "lrc", lyrics).ConfigureAwait(false); |
| | 396 | | } |
| | 397 | | } |
| | 398 | |
|
| | 399 | | private void AddExternalLyrics( |
| | 400 | | Audio audio, |
| | 401 | | List<MediaStream> currentStreams, |
| | 402 | | MetadataRefreshOptions options) |
| | 403 | | { |
| 0 | 404 | | var startIndex = currentStreams.Count == 0 ? 0 : (currentStreams.Select(i => i.Index).Max() + 1); |
| 0 | 405 | | var externalLyricFiles = _lyricResolver.GetExternalStreams(audio, startIndex, options.DirectoryService, fals |
| | 406 | |
|
| 0 | 407 | | audio.LyricFiles = externalLyricFiles.Select(i => i.Path).Distinct().ToArray(); |
| 0 | 408 | | if (externalLyricFiles.Count > 0) |
| | 409 | | { |
| 0 | 410 | | currentStreams.Add(externalLyricFiles[0]); |
| | 411 | | } |
| 0 | 412 | | } |
| | 413 | |
|
| | 414 | | private List<string> SplitWithCustomDelimiter(string val, char[] tagDelimiters, string[] whitelist) |
| | 415 | | { |
| 0 | 416 | | var items = new List<string>(); |
| 0 | 417 | | var temp = val; |
| 0 | 418 | | foreach (var whitelistItem in whitelist) |
| | 419 | | { |
| 0 | 420 | | if (string.IsNullOrWhiteSpace(whitelistItem)) |
| | 421 | | { |
| | 422 | | continue; |
| | 423 | | } |
| | 424 | |
|
| 0 | 425 | | var originalTemp = temp; |
| 0 | 426 | | temp = temp.Replace(whitelistItem, string.Empty, StringComparison.OrdinalIgnoreCase); |
| | 427 | |
|
| 0 | 428 | | if (!string.Equals(temp, originalTemp, StringComparison.OrdinalIgnoreCase)) |
| | 429 | | { |
| 0 | 430 | | items.Add(whitelistItem); |
| | 431 | | } |
| | 432 | | } |
| | 433 | |
|
| 0 | 434 | | var items2 = temp.Split(tagDelimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntrie |
| 0 | 435 | | items.AddRange(items2); |
| | 436 | |
|
| 0 | 437 | | return items; |
| | 438 | | } |
| | 439 | | } |
| | 440 | | } |