From f6094e01f51ca890efed59b253f2841f07d5c9d3 Mon Sep 17 00:00:00 2001 From: Rouzax Date: Tue, 12 May 2026 14:25:22 +0200 Subject: [PATCH 1/3] test: add MP3 fixtures for multi-value TXXX frames Four fixtures covering null-separated multi-value TXXX fields (2-value, 3-value, empty-slot, and single-value regression guard) plus the Python script that generates them. Signed-off-by: Rouzax --- t/generate_txxx_fixtures.py | 87 +++++++++++++++++++++++++++ t/mp3/v2.4-txxx-multivalue-3.mp3 | Bin 0 -> 4377 bytes t/mp3/v2.4-txxx-multivalue-empty.mp3 | Bin 0 -> 4370 bytes t/mp3/v2.4-txxx-multivalue.mp3 | Bin 0 -> 4413 bytes t/mp3/v2.4-txxx-single.mp3 | Bin 0 -> 4397 bytes 5 files changed, 87 insertions(+) create mode 100644 t/generate_txxx_fixtures.py create mode 100644 t/mp3/v2.4-txxx-multivalue-3.mp3 create mode 100644 t/mp3/v2.4-txxx-multivalue-empty.mp3 create mode 100644 t/mp3/v2.4-txxx-multivalue.mp3 create mode 100644 t/mp3/v2.4-txxx-single.mp3 diff --git a/t/generate_txxx_fixtures.py b/t/generate_txxx_fixtures.py new file mode 100644 index 0000000..969d241 --- /dev/null +++ b/t/generate_txxx_fixtures.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Generate MP3 test fixtures for Audio::Scan TXXX multi-value tests. + +Uses a valid MPEG frame extracted from an existing fixture as the audio +payload, then hand-builds ID3v2.4 tags with null-separated TXXX values +so we get exact byte-level control over the multi-value encoding. +""" +import struct, os + +OUTDIR = "/home/martijn/github/Audio-Scan/t/mp3" +# Use an existing valid MP3 as the audio base +BASE_MP3 = "/home/martijn/github/Audio-Scan/t/mp3/no-tags-mp1l3.mp3" + + +def get_audio_payload(): + """Read raw MPEG audio frames from an existing no-tags fixture.""" + with open(BASE_MP3, 'rb') as f: + return f.read() + + +def syncsafe_encode(size): + """Encode an integer as a 4-byte syncsafe integer (ID3v2.4 spec).""" + return ( + ((size & 0x0FE00000) << 3) | + ((size & 0x001FC000) << 2) | + ((size & 0x00003F80) << 1) | + (size & 0x0000007F) + ) + + +def make_txxx_frame(desc, values, encoding=3): + """Build an ID3v2.4 TXXX frame with null-separated values. + encoding: 0=Latin1, 3=UTF-8 + """ + if encoding == 3: + sep = b'\x00' + desc_bytes = desc.encode('utf-8') + b'\x00' + val_bytes = sep.join(v.encode('utf-8') for v in values) + else: + sep = b'\x00' + desc_bytes = desc.encode('latin-1') + b'\x00' + val_bytes = sep.join(v.encode('latin-1') for v in values) + + data = bytes([encoding]) + desc_bytes + val_bytes + size = len(data) + frame = b'TXXX' + struct.pack('>I', syncsafe_encode(size)) + b'\x00\x00' + data + return frame + + +def make_id3v2_tag(frames): + """Wrap frames in an ID3v2.4 tag header.""" + body = b''.join(frames) + size = len(body) + header = b'ID3' + b'\x04\x00' + b'\x00' + struct.pack('>I', syncsafe_encode(size)) + return header + body + + +def write_fixture(name, frames): + tag = make_id3v2_tag(frames) + audio = get_audio_payload() + path = os.path.join(OUTDIR, name) + with open(path, 'wb') as f: + f.write(tag + audio) + print(f"Wrote {path} ({len(tag) + len(audio)} bytes)") + + +# Fixture 1: Two-value TXXX (ALBUMARTISTS) +write_fixture("v2.4-txxx-multivalue.mp3", [ + make_txxx_frame("ALBUMARTISTS", ["Artist1", "Artist2"]), + make_txxx_frame("ARTISTS", ["TrackArtist1", "TrackArtist2"]), +]) + +# Fixture 2: Three-value TXXX +write_fixture("v2.4-txxx-multivalue-3.mp3", [ + make_txxx_frame("ALBUMARTISTS", ["Artist1", "Artist2", "Artist3"]), +]) + +# Fixture 3: Multi-value with empty slot (tagger bug simulation) +write_fixture("v2.4-txxx-multivalue-empty.mp3", [ + make_txxx_frame("ALBUMARTISTS", ["Artist1", "", "Artist3"]), +]) + +# Fixture 4: Single-value TXXX (regression guard) +write_fixture("v2.4-txxx-single.mp3", [ + make_txxx_frame("ALBUMARTISTS", ["OnlyArtist"]), + make_txxx_frame("USER FRAME", ["SingleValue"]), +]) diff --git a/t/mp3/v2.4-txxx-multivalue-3.mp3 b/t/mp3/v2.4-txxx-multivalue-3.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..491ed39f01ff88d77225d2109852f215f780200e GIT binary patch literal 4377 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 literal 0 HcmV?d00001 diff --git a/t/mp3/v2.4-txxx-multivalue-empty.mp3 b/t/mp3/v2.4-txxx-multivalue-empty.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..ff5c69beaa0165b5382aeacb7ee1cf257b25daf8 GIT binary patch literal 4370 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 literal 0 HcmV?d00001 diff --git a/t/mp3/v2.4-txxx-multivalue.mp3 b/t/mp3/v2.4-txxx-multivalue.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..da69f60b7c5d8ded1c8c23b06d791f2dbbd08b33 GIT binary patch literal 4413 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 bn2&gf?x2>P%w}3)=#K~h{)=+_YwQ05qw^cD literal 0 HcmV?d00001 From f723e4236a2c6077a4987231bda30d00e4ac46bd Mon Sep 17 00:00:00 2001 From: Rouzax Date: Tue, 12 May 2026 14:28:15 +0200 Subject: [PATCH 2/3] Support multi-value TXXX/WXXX frames in ID3v2 ID3v2.4 allows null-separated multiple strings in TXXX value fields. Audio::Scan previously read only the first value. Add a loop mirroring the existing STRINGLIST handler for standard text frames: returns an arrayref when multiple values are present, scalar when single. This enables plural tags like TXXX:ALBUMARTISTS written by Picard to reach consumers as arrays instead of being truncated. Signed-off-by: Rouzax --- src/id3.c | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/id3.c b/src/id3.c index 9485405..5960f73 100644 --- a/src/id3.c +++ b/src/id3.c @@ -798,24 +798,50 @@ _id3_parse_v2_frame_data(id3info *id3, char const *id, uint32_t size, id3_framet // Read key and uppercase it SV *key = NULL; SV *value = NULL; + AV *array = NULL; + int count = 0; read += _id3_get_utf8_string(id3, &key, size - read, encoding); if (key != NULL && SvPOK(key) && sv_len(key)) { upcase(SvPVX(key)); - // Read value + // Read value(s) if (frametype->fields[2] == ID3_FIELD_TYPE_LATIN1) { // WXXX frames have a latin1 value field regardless of encoding byte encoding = ISO_8859_1; } - read += _id3_get_utf8_string(id3, &value, size - read, encoding); + // ID3v2.4 allows multiple null-separated strings in TXXX value field. + // Loop mirrors the STRINGLIST handler for standard T* frames. + while (read < size) { + if (count++ == 1 && value != NULL) { + array = newAV(); + av_push(array, value); + } + value = NULL; - // (T|W)XXX frames don't support multiple strings separated by nulls, even in v2.4 + read += _id3_get_utf8_string(id3, &value, size - read, encoding); - // Only one tag per unique key value is allowed, that's why there is no array support here - if (value != NULL && SvPOK(value)) { + if (array != NULL && value != NULL && SvPOK(value)) { + // Bug 16452, do not add an empty string + if (sv_len(value) > 0) + av_push(array, value); + } + } + + if (array != NULL) { + if (av_len(array) == 0) { + // Multiple strings but only one non-empty: collapse to scalar + my_hv_store_ent( id3->tags, key, av_shift(array) ); + SvREFCNT_dec(array); + } + else { + my_hv_store_ent( id3->tags, key, newRV_noinc( (SV *)array ) ); + } + array = NULL; + } + else if (value != NULL && SvPOK(value)) { my_hv_store_ent( id3->tags, key, value ); } else { From 42e70b2dff11f2e213ba2fb31cd1182639e828bd Mon Sep 17 00:00:00 2001 From: Rouzax Date: Tue, 12 May 2026 14:30:45 +0200 Subject: [PATCH 3/3] test: add multi-value TXXX test cases Tests cover: two values, three values, empty slot skipping, and existing single-value regression (covered by pre-existing tests). Signed-off-by: Rouzax --- t/mp3.t | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/t/mp3.t b/t/mp3.t index ee3a4cc..761fc54 100644 --- a/t/mp3.t +++ b/t/mp3.t @@ -3,7 +3,7 @@ use strict; use Digest::MD5 qw(md5_hex); use File::Spec::Functions; use FindBin (); -use Test::More tests => 401; +use Test::More tests => 413; use Test::Warn; use Audio::Scan; @@ -1374,6 +1374,39 @@ eval { is( $tags->{MP3GAIN_MINMAX}, '123,203', 'bad APE tag MP3GAIN_MINMAX ok' ); } +# Multi-value TXXX frames (ID3v2.4 null-separated values) +{ + my $s = Audio::Scan->scan( _f('v2.4-txxx-multivalue.mp3') ); + my $tags = $s->{tags}; + + is( ref $tags->{ALBUMARTISTS}, 'ARRAY', 'ID3v2.4 multi-value TXXX returns arrayref' ); + is( $tags->{ALBUMARTISTS}->[0], 'Artist1', 'ID3v2.4 multi-value TXXX value 1 ok' ); + is( $tags->{ALBUMARTISTS}->[1], 'Artist2', 'ID3v2.4 multi-value TXXX value 2 ok' ); + is( ref $tags->{ARTISTS}, 'ARRAY', 'ID3v2.4 multi-value TXXX ARTISTS returns arrayref' ); + is( $tags->{ARTISTS}->[0], 'TrackArtist1', 'ID3v2.4 multi-value TXXX ARTISTS value 1 ok' ); + is( $tags->{ARTISTS}->[1], 'TrackArtist2', 'ID3v2.4 multi-value TXXX ARTISTS value 2 ok' ); +} + +# Multi-value TXXX with 3 values +{ + my $s = Audio::Scan->scan( _f('v2.4-txxx-multivalue-3.mp3') ); + my $tags = $s->{tags}; + + is( ref $tags->{ALBUMARTISTS}, 'ARRAY', 'ID3v2.4 3-value TXXX returns arrayref' ); + is( scalar @{$tags->{ALBUMARTISTS}}, 3, 'ID3v2.4 3-value TXXX has 3 elements' ); + is( $tags->{ALBUMARTISTS}->[2], 'Artist3', 'ID3v2.4 3-value TXXX value 3 ok' ); +} + +# Multi-value TXXX with empty slot (tagger bug) +{ + my $s = Audio::Scan->scan( _f('v2.4-txxx-multivalue-empty.mp3') ); + my $tags = $s->{tags}; + + is( ref $tags->{ALBUMARTISTS}, 'ARRAY', 'ID3v2.4 TXXX with empty slot returns arrayref' ); + is( scalar @{$tags->{ALBUMARTISTS}}, 2, 'ID3v2.4 TXXX empty slot is skipped' ); + is( $tags->{ALBUMARTISTS}->[1], 'Artist3', 'ID3v2.4 TXXX value after empty slot ok' ); +} + sub _f { return catfile( $FindBin::Bin, 'mp3', shift ); }