Skip to content

Commit 18bbe22

Browse files
committed
review: Address Copilot feedback on unified Tag views
Five review comments on #30 identified that the CombinedTag and FlacTag facades had semantics inconsistent with the Tag abstraction and with the rest of the IMediaFile implementations. All five are addressed: 1. CombinedTag.Render was a silent no-op returning empty bytes. A CombinedTag is a view, not a serializable block -- rendering it should surface misuse. Now throws NotSupportedException with a message pointing callers to the owning file or a specific underlying tag. 2. CombinedTag.Clear was a silent no-op. "Clear the tag" must actually zero out the metadata, not succeed while leaving the values intact. Now clears every non-null underlying tag. 3. CombinedTag setters were silent no-ops. Code in this repo and documented examples assume `file.Tag.Title = "x"` mutates the file, so silently dropping writes broke a load-bearing contract. Setters now write through to every non-null underlying tag, which also keeps ID3v2 + ID3v1 consistent on save. 4. FlacFile.Tag and Mp3File.Tag always returned a non-null facade even on files with no metadata. Other IMediaFile implementations (Dsf, Dff, Musepack, WavPack, MonkeysAudio, Ogg*, Asf, Mp4, Aiff) return null when the tag block is absent. Both now match that convention: null when the file has neither an underlying tag nor pictures. 5. FlacTag.Render/Clear had the same issue as #1-2. Render now throws NotSupportedException (a FlacTag spans multiple metadata blocks -- render the FlacFile); Clear now wipes the VorbisComment fields (including embedded METADATA_BLOCK_PICTURE entries via VorbisComment.Clear) and removes native PICTURE blocks. Test updates: * CombinedTagTests: replaced the three no-op characterization tests with their mirror-image positive tests (setters write through, Render throws, Clear clears every member). * FlacTagTests: replaced the combined "Render+Clear are no-ops" test with separate positive tests for Render throwing and Clear clearing. Added Tag_AbsentMetadata_ReturnsNull to lock in the nullability contract. Updated tests that started from a CreateMinimal() FLAC (Tag == null now) to seed with a VorbisComment first. * Mp3FileTests: added Tag_NoTagsPresent_ReturnsNull for the nullability contract and Tag_Setter_WritesThroughToBothIdTags to verify the write-through behavior keeps v2 and v1 in sync. All 3807 functional tests pass on net8.0 and net10.0.
1 parent a33bfa0 commit 18bbe22

7 files changed

Lines changed: 218 additions & 58 deletions

File tree

src/TagLibSharp2/Core/CombinedTag.cs

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,16 @@ namespace TagLibSharp2.Core;
1414
/// PictureData).
1515
/// </para>
1616
/// <para>
17-
/// Writes are no-ops on this base facade; callers wanting mutation should operate
18-
/// on the underlying tags directly. Format-specific subclasses (e.g. the FLAC tag
19-
/// view) override individual members to forward writes to the appropriate storage.
17+
/// Setters write through to <b>every</b> non-null underlying tag so that content
18+
/// stays consistent across formats. This matches user expectations on files that
19+
/// carry redundant metadata (e.g. an MP3 with both ID3v2 and ID3v1) and prevents
20+
/// post-save drift where one tag has the new value and the other has the old one.
21+
/// Format-specific limits still apply per tag (ID3v1 truncates to 30 bytes, etc.).
22+
/// </para>
23+
/// <para>
24+
/// <see cref="Render"/> throws because a <see cref="CombinedTag"/> is a view, not
25+
/// a serializable block: render the owning file or a specific underlying tag instead.
26+
/// <see cref="Clear"/> clears every non-null underlying tag.
2027
/// </para>
2128
/// <para>
2229
/// The motivating case is an MP3 file that carries both an ID3v2 tag
@@ -62,6 +69,14 @@ public CombinedTag (params Tag?[] tags)
6269
uint? FirstNonNullUInt (Func<Tag, uint?> selector) =>
6370
FirstNonDefault<uint?> (selector, v => !v.HasValue);
6471

72+
void WriteToAll (Action<Tag> setter)
73+
{
74+
foreach (var tag in _tags) {
75+
if (tag is not null)
76+
setter (tag);
77+
}
78+
}
79+
6580
/// <inheritdoc/>
6681
public override TagTypes TagType {
6782
get {
@@ -77,43 +92,43 @@ public override TagTypes TagType {
7792
/// <inheritdoc/>
7893
public override string? Title {
7994
get => FirstNonEmptyString (t => t.Title);
80-
set { }
95+
set => WriteToAll (t => t.Title = value);
8196
}
8297

8398
/// <inheritdoc/>
8499
public override string? Artist {
85100
get => FirstNonEmptyString (t => t.Artist);
86-
set { }
101+
set => WriteToAll (t => t.Artist = value);
87102
}
88103

89104
/// <inheritdoc/>
90105
public override string? Album {
91106
get => FirstNonEmptyString (t => t.Album);
92-
set { }
107+
set => WriteToAll (t => t.Album = value);
93108
}
94109

95110
/// <inheritdoc/>
96111
public override string? Year {
97112
get => FirstNonEmptyString (t => t.Year);
98-
set { }
113+
set => WriteToAll (t => t.Year = value);
99114
}
100115

101116
/// <inheritdoc/>
102117
public override string? Comment {
103118
get => FirstNonEmptyString (t => t.Comment);
104-
set { }
119+
set => WriteToAll (t => t.Comment = value);
105120
}
106121

107122
/// <inheritdoc/>
108123
public override string? Genre {
109124
get => FirstNonEmptyString (t => t.Genre);
110-
set { }
125+
set => WriteToAll (t => t.Genre = value);
111126
}
112127

113128
/// <inheritdoc/>
114129
public override uint? Track {
115130
get => FirstNonNullUInt (t => t.Track);
116-
set { }
131+
set => WriteToAll (t => t.Track = value);
117132
}
118133

119134
/// <inheritdoc/>
@@ -132,13 +147,25 @@ public override IPicture[] Pictures {
132147
}
133148
return [.. merged];
134149
}
135-
set { }
150+
set => WriteToAll (t => t.Pictures = value ?? []);
136151
}
137152
#pragma warning restore CA1819
138153

139154
/// <inheritdoc/>
140-
public override BinaryData Render () => BinaryData.Empty;
155+
/// <exception cref="NotSupportedException">
156+
/// <see cref="CombinedTag"/> is a view over multiple tags and does not produce its
157+
/// own serialized representation. Render the owning file or a specific underlying
158+
/// tag instead.
159+
/// </exception>
160+
public override BinaryData Render () =>
161+
throw new NotSupportedException (
162+
"CombinedTag is a view over multiple tags; it has no standalone binary representation. "
163+
+ "Render the owning file (e.g. Mp3File.Render) or a specific underlying tag instead.");
141164

142165
/// <inheritdoc/>
143-
public override void Clear () { }
166+
/// <remarks>
167+
/// Clears every non-null underlying tag, leaving each instance in place but empty.
168+
/// </remarks>
169+
public override void Clear () =>
170+
WriteToAll (t => t.Clear ());
144171
}

src/TagLibSharp2/Mpeg/Mp3File.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,15 @@ public sealed class Mp3File : IMediaFile
8888
public bool HasId3v2Tag => Id3v2Tag is not null;
8989

9090
/// <inheritdoc />
91-
public Tag? Tag => new CombinedTag (Id3v2Tag, Id3v1Tag);
91+
/// <inheritdoc />
92+
/// <remarks>
93+
/// Returns a <see cref="CombinedTag"/> composing the ID3v2 and ID3v1 tags when at
94+
/// least one is present. ID3v2 takes priority; ID3v1 supplies fallback values for
95+
/// fields not set in v2 (and receives mirrored writes so both tags stay in sync
96+
/// on save). Returns null when neither tag is present.
97+
/// </remarks>
98+
public Tag? Tag =>
99+
(Id3v2Tag is null && Id3v1Tag is null) ? null : new CombinedTag (Id3v2Tag, Id3v1Tag);
92100

93101
/// <inheritdoc />
94102
IMediaProperties? IMediaFile.AudioProperties => Properties;

src/TagLibSharp2/Xiph/FlacFile.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,22 @@ public string? Comment {
274274
FlacTag? _tag;
275275

276276
/// <inheritdoc />
277-
public Tag? Tag => _tag ??= new FlacTag (this);
277+
/// <remarks>
278+
/// Returns null when the file has neither a VorbisComment nor any PICTURE blocks,
279+
/// matching the "tag is absent" semantics used by other <see cref="IMediaFile"/>
280+
/// implementations. Once instantiated, the <see cref="FlacTag"/> is cached so a
281+
/// later setter call (e.g. <c>file.Tag.Title = "..."</c> after creating a
282+
/// VorbisComment) sees the same view.
283+
/// </remarks>
284+
public Tag? Tag {
285+
get {
286+
if (_tag is not null)
287+
return _tag;
288+
if (VorbisComment is null && _pictures.Count == 0)
289+
return null;
290+
return _tag = new FlacTag (this);
291+
}
292+
}
278293

279294
/// <inheritdoc />
280295
IMediaProperties? IMediaFile.AudioProperties => Properties;

src/TagLibSharp2/Xiph/FlacTag.cs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,26 @@ public override IPicture[] Pictures {
112112
#pragma warning restore CA1819
113113

114114
/// <inheritdoc/>
115-
public override BinaryData Render () => BinaryData.Empty;
115+
/// <exception cref="NotSupportedException">
116+
/// A FLAC file's tag state lives in multiple metadata blocks (VORBIS_COMMENT plus
117+
/// zero or more PICTURE blocks) and is rendered as part of the full file layout.
118+
/// It has no standalone binary representation. Render the owning
119+
/// <see cref="FlacFile"/> instead.
120+
/// </exception>
121+
public override BinaryData Render () =>
122+
throw new NotSupportedException (
123+
"FlacTag is a view over multiple FLAC metadata blocks and has no standalone binary "
124+
+ "representation. Render the owning FlacFile instead (FlacFile.Render).");
116125

117126
/// <inheritdoc/>
118-
public override void Clear () { }
127+
/// <remarks>
128+
/// Clears every metadata source this view surfaces: the VorbisComment fields
129+
/// (including any METADATA_BLOCK_PICTURE entries) and the native PICTURE blocks
130+
/// held on the <see cref="FlacFile"/>.
131+
/// </remarks>
132+
public override void Clear ()
133+
{
134+
_file.VorbisComment?.Clear ();
135+
_file.RemoveAllPictures ();
136+
}
119137
}

tests/TagLibSharp2.Tests/Core/CombinedTagTests.cs

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -150,45 +150,58 @@ public void Pictures_UnionsAcrossTagsWithDedup ()
150150
}
151151

152152
/// <summary>
153-
/// The base <see cref="CombinedTag"/> exposes writes as no-ops so that it
154-
/// cannot accidentally mutate underlying tags without a subclass making an
155-
/// explicit decision. Format-specific subclasses override individual
156-
/// setters when write-through is the correct behavior.
153+
/// Setters write through to every non-null underlying tag so content stays
154+
/// consistent across formats. For MP3, that means a single
155+
/// <c>file.Tag.Title = "x"</c> updates both ID3v2 and ID3v1, matching the
156+
/// round-trip expectation that saving a file emits matching values in both.
157+
/// Per-format length/encoding limits (e.g. ID3v1's 30-byte cap,
158+
/// https://id3.org/ID3v1) still apply when each member stores the value.
157159
/// </summary>
158160
[TestMethod]
159-
public void Setters_AreNoOpsOnBaseFacade ()
161+
public void Setters_WriteThroughToEveryMember ()
160162
{
161-
var primary = new Id3v2Tag { Title = "Original" };
162-
var combined = new CombinedTag (primary);
163+
var primary = new Id3v2Tag { Title = "Original 2", Track = 1 };
164+
var secondary = new Id3v1Tag { Title = "Original 1", Track = 2 };
165+
var combined = new CombinedTag (primary, secondary);
163166

164167
combined.Title = "Changed";
165168
combined.Track = 42;
166-
combined.Pictures = [];
167169

168-
Assert.AreEqual ("Original", primary.Title, "Base CombinedTag.Title setter does not mutate members");
169-
Assert.IsNull (primary.Track, "Base CombinedTag.Track setter does not mutate members");
170+
Assert.AreEqual ("Changed", primary.Title);
171+
Assert.AreEqual ("Changed", secondary.Title);
172+
Assert.AreEqual ((uint)42, primary.Track);
173+
Assert.AreEqual ((uint)42, secondary.Track);
170174
}
171175

176+
/// <summary>
177+
/// A <see cref="CombinedTag"/> is a view — it has no standalone binary
178+
/// representation. Callers should render the owning file or a specific
179+
/// underlying tag; calling <see cref="Tag.Render"/> on the facade throws
180+
/// to surface the misuse instead of silently returning empty bytes.
181+
/// </summary>
172182
[TestMethod]
173-
public void Render_ReturnsEmptyBinaryData ()
183+
public void Render_ThrowsNotSupported ()
174184
{
175185
var combined = new CombinedTag (new Id3v2Tag { Title = "x" });
176186

177-
var rendered = combined.Render ();
178-
179-
Assert.IsTrue (rendered.IsEmpty,
180-
"CombinedTag is a view; it does not own bytes and Render returns empty");
187+
Assert.ThrowsExactly<NotSupportedException> (() => combined.Render ());
181188
}
182189

190+
/// <summary>
191+
/// <see cref="Tag.Clear"/> on the facade clears every non-null underlying
192+
/// tag. Callers expect "clear the tag" to actually zero out the metadata,
193+
/// not silently succeed with the old values intact.
194+
/// </summary>
183195
[TestMethod]
184-
public void Clear_IsNoOp ()
196+
public void Clear_ClearsEveryUnderlyingMember ()
185197
{
186-
var primary = new Id3v2Tag { Title = "Keep Me" };
187-
var combined = new CombinedTag (primary);
198+
var primary = new Id3v2Tag { Title = "Wipe Me" };
199+
var secondary = new Id3v1Tag { Title = "Also Wipe Me" };
200+
var combined = new CombinedTag (primary, secondary);
188201

189202
combined.Clear ();
190203

191-
Assert.AreEqual ("Keep Me", primary.Title,
192-
"Base CombinedTag.Clear does not mutate members");
204+
Assert.IsTrue (string.IsNullOrEmpty (primary.Title));
205+
Assert.IsTrue (string.IsNullOrEmpty (secondary.Title));
193206
}
194207
}

tests/TagLibSharp2.Tests/Mpeg/Mp3FileTests.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,51 @@ public void Tag_BothIdTags_PrefersId3v2ButFallsBackToId3v1 ()
124124
"Genre should fall back to ID3v1 when ID3v2 has no Genre");
125125
}
126126

127+
/// <summary>
128+
/// An MP3 file with no ID3v2 and no ID3v1 must expose <c>Tag == null</c>,
129+
/// matching the "tag is absent" convention used by other
130+
/// <see cref="IMediaFile"/> implementations in this repo. A Mp3File that
131+
/// always materialized a facade would lie about the absence of metadata.
132+
/// </summary>
133+
[TestMethod]
134+
public void Tag_NoTagsPresent_ReturnsNull ()
135+
{
136+
var audioOnly = new byte[256];
137+
138+
var result = Mp3File.Read (audioOnly);
139+
140+
Assert.IsTrue (result.IsSuccess);
141+
Assert.IsNull (result.File!.Id3v2Tag);
142+
Assert.IsNull (result.File.Id3v1Tag);
143+
Assert.IsNull (result.File.Tag);
144+
}
145+
146+
/// <summary>
147+
/// Writing through the combined Tag view updates both ID3v2 and ID3v1 so the
148+
/// file saves with consistent metadata in both tags. ID3v1's format-specific
149+
/// limits still apply (30-byte cap per https://id3.org/ID3v1), but the
150+
/// values round-trip as far as each format allows.
151+
/// </summary>
152+
[TestMethod]
153+
public void Tag_Setter_WritesThroughToBothIdTags ()
154+
{
155+
var id3v2 = new Id3v2Tag { Title = "Before" };
156+
var id3v1 = new Id3v1Tag { Title = "Before" };
157+
var v2Data = id3v2.Render ();
158+
var v1Data = id3v1.Render ();
159+
160+
var audioData = new byte[256];
161+
var fullData = new byte[v2Data.Length + audioData.Length + v1Data.Length];
162+
v2Data.Span.CopyTo (fullData);
163+
v1Data.Span.CopyTo (fullData.AsSpan (v2Data.Length + audioData.Length));
164+
165+
var file = Mp3File.Read (fullData).File!;
166+
file.Tag!.Title = "After";
167+
168+
Assert.AreEqual ("After", file.Id3v2Tag!.Title, "ID3v2 updated");
169+
Assert.AreEqual ("After", file.Id3v1Tag!.Title, "ID3v1 updated");
170+
}
171+
127172
[TestMethod]
128173
public void Read_BothTags_ParsesBoth ()
129174
{

0 commit comments

Comments
 (0)