-
Notifications
You must be signed in to change notification settings - Fork 4
fix: Surface unified Tag view over all metadata sources (#6) #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,144 @@ | ||||||||||||||||||||||||||
| // Copyright (c) 2025-2026 Stephen Shaw and contributors | ||||||||||||||||||||||||||
| // Licensed under the MIT License. See LICENSE file in the project root for full license information. | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| namespace TagLibSharp2.Core; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /// <summary> | ||||||||||||||||||||||||||
| /// A tag facade that exposes a unified view over multiple underlying <see cref="Tag"/> | ||||||||||||||||||||||||||
| /// instances in priority order. | ||||||||||||||||||||||||||
| /// </summary> | ||||||||||||||||||||||||||
| /// <remarks> | ||||||||||||||||||||||||||
| /// <para> | ||||||||||||||||||||||||||
| /// Getters return the first non-null/non-empty value from the ordered list of tags. | ||||||||||||||||||||||||||
| /// Pictures are unioned across members and deduplicated on (PictureType, MimeType, | ||||||||||||||||||||||||||
| /// PictureData). | ||||||||||||||||||||||||||
| /// </para> | ||||||||||||||||||||||||||
| /// <para> | ||||||||||||||||||||||||||
| /// Writes are no-ops on this base facade; callers wanting mutation should operate | ||||||||||||||||||||||||||
| /// on the underlying tags directly. Format-specific subclasses (e.g. the FLAC tag | ||||||||||||||||||||||||||
| /// view) override individual members to forward writes to the appropriate storage. | ||||||||||||||||||||||||||
| /// </para> | ||||||||||||||||||||||||||
| /// <para> | ||||||||||||||||||||||||||
| /// The motivating case is an MP3 file that carries both an ID3v2 tag | ||||||||||||||||||||||||||
| /// (https://id3.org/id3v2.4.0-structure) and an ID3v1 tag (https://id3.org/ID3v1). | ||||||||||||||||||||||||||
| /// ID3v2 is authoritative when both are present, but ID3v1-only fields must still | ||||||||||||||||||||||||||
| /// surface through the unified view so legacy data is not silently lost. | ||||||||||||||||||||||||||
| /// </para> | ||||||||||||||||||||||||||
| /// </remarks> | ||||||||||||||||||||||||||
| public class CombinedTag : Tag | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| readonly Tag?[] _tags; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /// <summary> | ||||||||||||||||||||||||||
| /// Initializes a new <see cref="CombinedTag"/> with the supplied tags in | ||||||||||||||||||||||||||
| /// priority order. The first non-null tag's value wins for each field. | ||||||||||||||||||||||||||
| /// </summary> | ||||||||||||||||||||||||||
| /// <param name="tags">Tags in priority order. Null entries are allowed and ignored.</param> | ||||||||||||||||||||||||||
| public CombinedTag (params Tag?[] tags) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| _tags = tags ?? []; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /// <summary> | ||||||||||||||||||||||||||
| /// Gets the underlying tags in priority order. Null entries are preserved. | ||||||||||||||||||||||||||
| /// </summary> | ||||||||||||||||||||||||||
| protected IReadOnlyList<Tag?> Tags => _tags; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| T? FirstNonDefault<T> (Func<Tag, T?> selector, Func<T?, bool> isDefault) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| foreach (var tag in _tags) { | ||||||||||||||||||||||||||
| if (tag is null) | ||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||
| var value = selector (tag); | ||||||||||||||||||||||||||
| if (!isDefault (value)) | ||||||||||||||||||||||||||
| return value; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| return default; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| string? FirstNonEmptyString (Func<Tag, string?> selector) => | ||||||||||||||||||||||||||
| FirstNonDefault (selector, string.IsNullOrEmpty); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| uint? FirstNonNullUInt (Func<Tag, uint?> selector) => | ||||||||||||||||||||||||||
| FirstNonDefault<uint?> (selector, v => !v.HasValue); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||
| public override TagTypes TagType { | ||||||||||||||||||||||||||
| get { | ||||||||||||||||||||||||||
| var types = TagTypes.None; | ||||||||||||||||||||||||||
| foreach (var tag in _tags) { | ||||||||||||||||||||||||||
| if (tag is not null) | ||||||||||||||||||||||||||
| types |= tag.TagType; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| return types; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||
| public override string? Title { | ||||||||||||||||||||||||||
| get => FirstNonEmptyString (t => t.Title); | ||||||||||||||||||||||||||
| set { } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||
| public override string? Artist { | ||||||||||||||||||||||||||
| get => FirstNonEmptyString (t => t.Artist); | ||||||||||||||||||||||||||
| set { } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||
| public override string? Album { | ||||||||||||||||||||||||||
| get => FirstNonEmptyString (t => t.Album); | ||||||||||||||||||||||||||
| set { } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||
| public override string? Year { | ||||||||||||||||||||||||||
| get => FirstNonEmptyString (t => t.Year); | ||||||||||||||||||||||||||
| set { } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||
| public override string? Comment { | ||||||||||||||||||||||||||
| get => FirstNonEmptyString (t => t.Comment); | ||||||||||||||||||||||||||
| set { } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||
| public override string? Genre { | ||||||||||||||||||||||||||
| get => FirstNonEmptyString (t => t.Genre); | ||||||||||||||||||||||||||
| set { } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||
| public override uint? Track { | ||||||||||||||||||||||||||
| get => FirstNonNullUInt (t => t.Track); | ||||||||||||||||||||||||||
| set { } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||
| #pragma warning disable CA1819 // Properties should not return arrays - Tag API contract | ||||||||||||||||||||||||||
| public override IPicture[] Pictures { | ||||||||||||||||||||||||||
| get { | ||||||||||||||||||||||||||
| var merged = new List<IPicture> (); | ||||||||||||||||||||||||||
| var seen = new HashSet<(PictureType, string, BinaryData)> (); | ||||||||||||||||||||||||||
| foreach (var tag in _tags) { | ||||||||||||||||||||||||||
| if (tag is null) | ||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||
| foreach (var p in tag.Pictures) { | ||||||||||||||||||||||||||
| if (seen.Add ((p.PictureType, p.MimeType, p.PictureData))) | ||||||||||||||||||||||||||
| merged.Add (p); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| return [.. merged]; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| set { } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| #pragma warning restore CA1819 | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||
| public override BinaryData Render () => BinaryData.Empty; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||
| public override void Clear () { } | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
| public override BinaryData Render () => BinaryData.Empty; | |
| /// <inheritdoc/> | |
| public override void Clear () { } | |
| public override BinaryData Render () => | |
| throw new global::System.NotSupportedException ( | |
| "CombinedTag is a read-only facade and cannot be rendered directly. Render an underlying concrete tag instead."); | |
| /// <inheritdoc/> | |
| public override void Clear () => | |
| throw new global::System.NotSupportedException ( | |
| "CombinedTag is a read-only facade and cannot be cleared directly. Clear an underlying concrete tag instead."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in 18bbe22. Render throws NotSupportedException with a message pointing to the owning file / specific underlying tag; Clear calls Clear() on every non-null underlying tag.
Didn't take the "delegate to the canonical underlying tag" path for Render because there isn't one canonical choice (in the MP3 case, both ID3v2 and ID3v1 are real tags that the file persists, and they have different byte formats). Throwing makes the misuse explicit.
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -88,7 +88,7 @@ public sealed class Mp3File : IMediaFile | |||||
| public bool HasId3v2Tag => Id3v2Tag is not null; | ||||||
|
|
||||||
| /// <inheritdoc /> | ||||||
| public Tag? Tag => (Tag?)Id3v2Tag ?? Id3v1Tag; | ||||||
| public Tag? Tag => new CombinedTag (Id3v2Tag, Id3v1Tag); | ||||||
|
||||||
| public Tag? Tag => new CombinedTag (Id3v2Tag, Id3v1Tag); | |
| public Tag? Tag => Id3v2Tag ?? Id3v1Tag; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Partially adopted. Mp3File.Tag now returns null when both Id3v2Tag and Id3v1Tag are absent (the nullability part of your suggestion).
Didn't adopt Id3v2Tag ?? Id3v1Tag because that's the original bug this PR fixes: when both tags exist and a field is only in ID3v1 (e.g. Genre when ID3v2 has no TCON frame), the ?? form silently drops it. Mp3FileTests.Tag_BothIdTags_PrefersId3v2ButFallsBackToId3v1 covers that regression.
For the writability concern, CombinedTag setters now write through to every non-null underlying tag (see thread on CombinedTag.cs:81), so file.Tag.Title = "x" updates both v2 and v1 and keeps them consistent on save.
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -271,8 +271,10 @@ public string? Comment { | |||||
| /// </summary> | ||||||
| public AudioProperties Properties { get; } | ||||||
|
|
||||||
| FlacTag? _tag; | ||||||
|
|
||||||
| /// <inheritdoc /> | ||||||
| public Tag? Tag => VorbisComment; | ||||||
| public Tag? Tag => _tag ??= new FlacTag (this); | ||||||
|
||||||
| public Tag? Tag => _tag ??= new FlacTag (this); | |
| public Tag? Tag => _tag ?? ((VorbisComment is null && _pictures.Count == 0) ? null : (_tag = new FlacTag (this))); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adopted in 18bbe22. FlacFile.Tag now returns null when VorbisComment is null && _pictures.Count == 0; once either becomes non-empty, a cached FlacTag is materialized and reused. FlacTagTests.Tag_AbsentMetadata_ReturnsNull locks in the contract.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,119 @@ | ||||||||||||||||||||||||||||||||||||||||||||||
| // Copyright (c) 2025-2026 Stephen Shaw and contributors | ||||||||||||||||||||||||||||||||||||||||||||||
| // Licensed under the MIT License. See LICENSE file in the project root for full license information. | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| using TagLibSharp2.Core; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| namespace TagLibSharp2.Xiph; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// <summary> | ||||||||||||||||||||||||||||||||||||||||||||||
| /// Unified tag view over a <see cref="FlacFile"/>'s metadata. | ||||||||||||||||||||||||||||||||||||||||||||||
| /// </summary> | ||||||||||||||||||||||||||||||||||||||||||||||
| /// <remarks> | ||||||||||||||||||||||||||||||||||||||||||||||
| /// <para> | ||||||||||||||||||||||||||||||||||||||||||||||
| /// FLAC can store pictures in two spec-defined locations: | ||||||||||||||||||||||||||||||||||||||||||||||
| /// </para> | ||||||||||||||||||||||||||||||||||||||||||||||
| /// <list type="bullet"> | ||||||||||||||||||||||||||||||||||||||||||||||
| /// <item>Native PICTURE metadata blocks (block type 6), per RFC 9639 §8.8.</item> | ||||||||||||||||||||||||||||||||||||||||||||||
| /// <item>METADATA_BLOCK_PICTURE fields inside the VORBIS_COMMENT block (base64-encoded), | ||||||||||||||||||||||||||||||||||||||||||||||
| /// per https://wiki.xiph.org/VorbisComment#METADATA_BLOCK_PICTURE.</item> | ||||||||||||||||||||||||||||||||||||||||||||||
| /// </list> | ||||||||||||||||||||||||||||||||||||||||||||||
| /// <para> | ||||||||||||||||||||||||||||||||||||||||||||||
| /// This class provides a single Tag view that surfaces both so callers do not need | ||||||||||||||||||||||||||||||||||||||||||||||
| /// to know FLAC's internal storage layout. | ||||||||||||||||||||||||||||||||||||||||||||||
| /// </para> | ||||||||||||||||||||||||||||||||||||||||||||||
| /// </remarks> | ||||||||||||||||||||||||||||||||||||||||||||||
| public sealed class FlacTag : Tag | ||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||
| readonly FlacFile _file; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| internal FlacTag (FlacFile file) | ||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||
| _file = file; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||||||||||||||||||||||
| public override TagTypes TagType => TagTypes.Xiph | TagTypes.FlacMetadata; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||||||||||||||||||||||
| public override string? Title { | ||||||||||||||||||||||||||||||||||||||||||||||
| get => _file.VorbisComment?.Title; | ||||||||||||||||||||||||||||||||||||||||||||||
| set => EnsureVorbisComment ().Title = value; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||||||||||||||||||||||
| public override string? Artist { | ||||||||||||||||||||||||||||||||||||||||||||||
| get => _file.VorbisComment?.Artist; | ||||||||||||||||||||||||||||||||||||||||||||||
| set => EnsureVorbisComment ().Artist = value; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||||||||||||||||||||||
| public override string? Album { | ||||||||||||||||||||||||||||||||||||||||||||||
| get => _file.VorbisComment?.Album; | ||||||||||||||||||||||||||||||||||||||||||||||
| set => EnsureVorbisComment ().Album = value; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||||||||||||||||||||||
| public override string? Year { | ||||||||||||||||||||||||||||||||||||||||||||||
| get => _file.VorbisComment?.Year; | ||||||||||||||||||||||||||||||||||||||||||||||
| set => EnsureVorbisComment ().Year = value; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||||||||||||||||||||||
| public override string? Comment { | ||||||||||||||||||||||||||||||||||||||||||||||
| get => _file.VorbisComment?.Comment; | ||||||||||||||||||||||||||||||||||||||||||||||
| set => EnsureVorbisComment ().Comment = value; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||||||||||||||||||||||
| public override string? Genre { | ||||||||||||||||||||||||||||||||||||||||||||||
| get => _file.VorbisComment?.Genre; | ||||||||||||||||||||||||||||||||||||||||||||||
| set => EnsureVorbisComment ().Genre = value; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||||||||||||||||||||||
| public override uint? Track { | ||||||||||||||||||||||||||||||||||||||||||||||
| get => _file.VorbisComment?.Track; | ||||||||||||||||||||||||||||||||||||||||||||||
| set => EnsureVorbisComment ().Track = value; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| VorbisComment EnsureVorbisComment () => | ||||||||||||||||||||||||||||||||||||||||||||||
| _file.VorbisComment ??= new VorbisComment ("TagLibSharp2"); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||||||||||||||||||||||
| #pragma warning disable CA1819 // Properties should not return arrays - Tag API contract | ||||||||||||||||||||||||||||||||||||||||||||||
| public override IPicture[] Pictures { | ||||||||||||||||||||||||||||||||||||||||||||||
| get { | ||||||||||||||||||||||||||||||||||||||||||||||
| var blockPictures = _file.Pictures; | ||||||||||||||||||||||||||||||||||||||||||||||
| var embedded = _file.VorbisComment?.Pictures ?? []; | ||||||||||||||||||||||||||||||||||||||||||||||
| var merged = new List<IPicture> (blockPictures.Count + embedded.Length); | ||||||||||||||||||||||||||||||||||||||||||||||
| var seen = new HashSet<(PictureType, string, BinaryData)> (); | ||||||||||||||||||||||||||||||||||||||||||||||
| foreach (var p in blockPictures) { | ||||||||||||||||||||||||||||||||||||||||||||||
| if (seen.Add ((p.PictureType, p.MimeType, p.PictureData))) | ||||||||||||||||||||||||||||||||||||||||||||||
| merged.Add (p); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| foreach (var p in embedded) { | ||||||||||||||||||||||||||||||||||||||||||||||
| if (seen.Add ((p.PictureType, p.MimeType, p.PictureData))) | ||||||||||||||||||||||||||||||||||||||||||||||
| merged.Add (p); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| return [.. merged]; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| set { | ||||||||||||||||||||||||||||||||||||||||||||||
| _file.RemoveAllPictures (); | ||||||||||||||||||||||||||||||||||||||||||||||
| _file.VorbisComment?.RemoveAllPictures (); | ||||||||||||||||||||||||||||||||||||||||||||||
| if (value is null) | ||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||
| foreach (var p in value) { | ||||||||||||||||||||||||||||||||||||||||||||||
| var flacPic = p as FlacPicture ?? new FlacPicture ( | ||||||||||||||||||||||||||||||||||||||||||||||
| p.MimeType, p.PictureType, p.Description, p.PictureData, 0, 0, 0, 0); | ||||||||||||||||||||||||||||||||||||||||||||||
| _file.AddPicture (flacPic); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| #pragma warning restore CA1819 | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||||||||||||||||||||||
| public override BinaryData Render () => BinaryData.Empty; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||||||||||||||||||||||
| public override void Clear () { } | ||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
| public override BinaryData Render () => BinaryData.Empty; | |
| /// <inheritdoc/> | |
| public override void Clear () { } | |
| public override BinaryData Render () => | |
| throw new NotSupportedException ("FlacTag is a unified view over FLAC metadata blocks and cannot be rendered as a standalone binary tag. Render the owning FlacFile or an underlying metadata block instead."); | |
| /// <inheritdoc/> | |
| public override void Clear () | |
| { | |
| var vorbisComment = EnsureVorbisComment (); | |
| vorbisComment.Title = null; | |
| vorbisComment.Artist = null; | |
| vorbisComment.Album = null; | |
| vorbisComment.Year = null; | |
| vorbisComment.Comment = null; | |
| vorbisComment.Genre = null; | |
| vorbisComment.Track = null; | |
| _file.RemoveAllPictures (); | |
| vorbisComment.RemoveAllPictures (); | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in 18bbe22. Render throws NotSupportedException (a FlacTag spans multiple FLAC metadata blocks — render via FlacFile.Render instead). Clear now clears the VorbisComment fields via VorbisComment.Clear() (which includes any METADATA_BLOCK_PICTURE entries) and removes the native PICTURE blocks.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| // Copyright (c) 2025-2026 Stephen Shaw and contributors | ||
| // Licensed under the MIT License. See LICENSE file in the project root for full license information. | ||
|
|
||
| using TagLibSharp2.Core; | ||
| using TagLibSharp2.Id3; | ||
| using TagLibSharp2.Id3.Id3v2; | ||
|
|
||
| namespace TagLibSharp2.Tests.Core; | ||
|
|
||
| /// <summary> | ||
| /// Tests for <see cref="CombinedTag"/> — a Tag facade that exposes a unified view | ||
| /// over multiple underlying Tag instances in a well-defined priority order. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// The motivating case is an MP3 file that contains both an ID3v2 tag and an | ||
| /// ID3v1 tag. Per the ID3v1 specification (https://id3.org/ID3v1), ID3v1 has | ||
| /// strict field length limits (title/artist/album capped at 30 bytes, year at | ||
| /// 4 bytes, etc.). ID3v2 has no such limits. When both are present, taggers | ||
| /// generally treat ID3v2 as authoritative and ID3v1 as a legacy-compat copy, | ||
| /// so reads must prefer ID3v2 and fall back to ID3v1 for fields missing in v2. | ||
| /// </remarks> | ||
| [TestClass] | ||
| [TestCategory ("Unit")] | ||
| [TestCategory ("Core")] | ||
| public class CombinedTagTests | ||
| { | ||
| /// <summary> | ||
| /// When the primary tag has a field value, that value wins regardless of what | ||
| /// later tags hold. This implements the "ID3v2 is authoritative" convention. | ||
| /// </summary> | ||
| [TestMethod] | ||
| public void Title_PrimaryTagValueWinsOverSecondary () | ||
| { | ||
| var primary = new Id3v2Tag { Title = "From Primary" }; | ||
| var secondary = new Id3v1Tag { Title = "From Secondary" }; | ||
|
|
||
| var combined = new CombinedTag (primary, secondary); | ||
|
|
||
| Assert.AreEqual ("From Primary", combined.Title); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// When the primary tag does not have a value for a field, the combined tag | ||
| /// falls back to later tags. This prevents ID3v1-only information (typical | ||
| /// for files tagged prior to widespread ID3v2 adoption) from being lost. | ||
| /// </summary> | ||
| [TestMethod] | ||
| public void Title_FallsBackToSecondaryWhenPrimaryIsEmpty () | ||
| { | ||
| var primary = new Id3v2Tag (); | ||
| var secondary = new Id3v1Tag { Title = "Only In Secondary" }; | ||
|
|
||
| var combined = new CombinedTag (primary, secondary); | ||
|
|
||
| Assert.AreEqual ("Only In Secondary", combined.Title); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// A null tag in the priority list is skipped. This supports the common | ||
| /// case where a file is being read and one of the tag blocks is absent. | ||
| /// </summary> | ||
| [TestMethod] | ||
| public void Title_SkipsNullTagsInPriorityList () | ||
| { | ||
| var secondary = new Id3v1Tag { Title = "From Secondary" }; | ||
|
|
||
| var combined = new CombinedTag (null, secondary); | ||
|
|
||
| Assert.AreEqual ("From Secondary", combined.Title); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CombinedTag property setters are empty (no-op). That means writing via an IMediaFile.Tag facade (e.g., Mp3File.Tag or MediaFileResult.Tag) will silently drop changes, and Tag.CopyTo(target) will also be ineffective when the target is a CombinedTag. Consider either (a) forwarding setters to a canonical/primary underlying tag (and documenting which one), or (b) throwing NotSupportedException from setters to avoid silent failure.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adopted option (a) in 18bbe22. Setters now forward to every non-null underlying tag (not just one canonical member). Rationale: for MP3, both ID3v2 and ID3v1 persist on the file, so a user-facing
file.Tag.Title = "x"expects both to save with the same value rather than silently diverging. Format-specific limits still apply when each member stores the value (ID3v1 truncates to 30 bytes per https://id3.org/ID3v1).CombinedTagTests.Setters_WriteThroughToEveryMemberandMp3FileTests.Tag_Setter_WritesThroughToBothIdTagscover this.