Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions src/TagLibSharp2/Core/CombinedTag.cs
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 { }
}
Comment on lines +93 to +96
Copy link

Copilot AI Apr 19, 2026

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

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_WriteThroughToEveryMember and Mp3FileTests.Tag_Setter_WritesThroughToBothIdTags cover this.


/// <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 () { }
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CombinedTag.Render returns BinaryData.Empty and Clear is a no-op, which violates Tag.Render/Clear semantics (serialize/clear tag data). If CombinedTag is intended to be a read-only facade, consider throwing NotSupportedException (or delegating to the canonical underlying tag) so callers don’t get an "empty tag" byte payload or a Clear() that appears to succeed but does nothing.

Suggested change
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.");

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

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.

}
8 changes: 2 additions & 6 deletions src/TagLibSharp2/Core/MediaFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ static MediaFileResult OpenFlac (ReadOnlyMemory<byte> data)
if (!result.IsSuccess)
return MediaFileResult.Failure (result.Error!);

return MediaFileResult.Success (result.File!, result.File!.VorbisComment, MediaFormat.Flac);
return MediaFileResult.Success (result.File!, result.File!.Tag, MediaFormat.Flac);
}

static MediaFileResult OpenOggVorbis (ReadOnlyMemory<byte> data)
Expand All @@ -413,11 +413,7 @@ static MediaFileResult OpenMp3 (ReadOnlyMemory<byte> data)
if (!result.IsSuccess)
return MediaFileResult.Failure (result.Error!);

// Prefer ID3v2 tag, fall back to ID3v1
Tag? tag = result.File!.Id3v2Tag is not null
? result.File.Id3v2Tag
: result.File.Id3v1Tag;
return MediaFileResult.Success (result.File, tag, MediaFormat.Mp3);
return MediaFileResult.Success (result.File!, result.File!.Tag, MediaFormat.Mp3);
}

static MediaFileResult OpenWav (ReadOnlyMemory<byte> data)
Expand Down
2 changes: 1 addition & 1 deletion src/TagLibSharp2/Mpeg/Mp3File.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mp3File.Tag now always returns a new CombinedTag instance even when both Id3v2Tag and Id3v1Tag are null. This changes the prior “Tag is null when absent” behavior used elsewhere in the codebase (many IMediaFile implementations return null if no tag), and it also exposes the read-only/no-op setter behavior of CombinedTag to callers trying to modify metadata via IMediaFile.Tag. Consider returning null when both tags are absent, and using a writable facade that forwards writes to ID3v2 (creating it lazily) to preserve modifiability of Tag.

Suggested change
public Tag? Tag => new CombinedTag (Id3v2Tag, Id3v1Tag);
public Tag? Tag => Id3v2Tag ?? Id3v1Tag;

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

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.


/// <inheritdoc />
IMediaProperties? IMediaFile.AudioProperties => Properties;
Expand Down
4 changes: 3 additions & 1 deletion src/TagLibSharp2/Xiph/FlacFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FlacFile.Tag now always returns a FlacTag instance, even when the file has no VorbisComment and no picture blocks. That’s inconsistent with most other IMediaFile implementations in this repo, which return null when the tag block is absent, and it changes the observable behavior of Tag from “missing” to “present but empty”. Consider returning null when there is no underlying metadata (e.g., VorbisComment is null and Pictures is empty) and only instantiating FlacTag when there is something to surface or mutate.

Suggested change
public Tag? Tag => _tag ??= new FlacTag (this);
public Tag? Tag => _tag ?? ((VorbisComment is null && _pictures.Count == 0) ? null : (_tag = new FlacTag (this)));

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

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.


/// <inheritdoc />
IMediaProperties? IMediaFile.AudioProperties => Properties;
Expand Down
119 changes: 119 additions & 0 deletions src/TagLibSharp2/Xiph/FlacTag.cs
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 () { }
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FlacTag.Render returns BinaryData.Empty and Clear() is a no-op, but Tag.Render/Clear are documented as “serialize the tag” / “clear all tag data”. For a writable facade like FlacTag, Clear should clear the VorbisComment fields and remove picture blocks (and legacy METADATA_BLOCK_PICTURE entries), and Render should either delegate to an appropriate underlying representation or throw NotSupportedException to avoid silently returning empty data.

Suggested change
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 ();
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

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.

}
71 changes: 71 additions & 0 deletions tests/TagLibSharp2.Tests/Core/CombinedTagTests.cs
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);
}
}
Loading
Loading