Skip to content

Commit bcfd472

Browse files
authored
Merge pull request #11 from Rouzax/master
I finally had a chance to review the changes and I had no issues. Works fine for Windows too.
2 parents 10a7a75 + 42e70b2 commit bcfd472

7 files changed

Lines changed: 152 additions & 6 deletions

src/id3.c

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -798,24 +798,50 @@ _id3_parse_v2_frame_data(id3info *id3, char const *id, uint32_t size, id3_framet
798798
// Read key and uppercase it
799799
SV *key = NULL;
800800
SV *value = NULL;
801+
AV *array = NULL;
802+
int count = 0;
801803

802804
read += _id3_get_utf8_string(id3, &key, size - read, encoding);
803805

804806
if (key != NULL && SvPOK(key) && sv_len(key)) {
805807
upcase(SvPVX(key));
806808

807-
// Read value
809+
// Read value(s)
808810
if (frametype->fields[2] == ID3_FIELD_TYPE_LATIN1) {
809811
// WXXX frames have a latin1 value field regardless of encoding byte
810812
encoding = ISO_8859_1;
811813
}
812814

813-
read += _id3_get_utf8_string(id3, &value, size - read, encoding);
815+
// ID3v2.4 allows multiple null-separated strings in TXXX value field.
816+
// Loop mirrors the STRINGLIST handler for standard T* frames.
817+
while (read < size) {
818+
if (count++ == 1 && value != NULL) {
819+
array = newAV();
820+
av_push(array, value);
821+
}
822+
value = NULL;
814823

815-
// (T|W)XXX frames don't support multiple strings separated by nulls, even in v2.4
824+
read += _id3_get_utf8_string(id3, &value, size - read, encoding);
816825

817-
// Only one tag per unique key value is allowed, that's why there is no array support here
818-
if (value != NULL && SvPOK(value)) {
826+
if (array != NULL && value != NULL && SvPOK(value)) {
827+
// Bug 16452, do not add an empty string
828+
if (sv_len(value) > 0)
829+
av_push(array, value);
830+
}
831+
}
832+
833+
if (array != NULL) {
834+
if (av_len(array) == 0) {
835+
// Multiple strings but only one non-empty: collapse to scalar
836+
my_hv_store_ent( id3->tags, key, av_shift(array) );
837+
SvREFCNT_dec(array);
838+
}
839+
else {
840+
my_hv_store_ent( id3->tags, key, newRV_noinc( (SV *)array ) );
841+
}
842+
array = NULL;
843+
}
844+
else if (value != NULL && SvPOK(value)) {
819845
my_hv_store_ent( id3->tags, key, value );
820846
}
821847
else {

t/generate_txxx_fixtures.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#!/usr/bin/env python3
2+
"""Generate MP3 test fixtures for Audio::Scan TXXX multi-value tests.
3+
4+
Uses a valid MPEG frame extracted from an existing fixture as the audio
5+
payload, then hand-builds ID3v2.4 tags with null-separated TXXX values
6+
so we get exact byte-level control over the multi-value encoding.
7+
"""
8+
import struct, os
9+
10+
OUTDIR = "/home/martijn/github/Audio-Scan/t/mp3"
11+
# Use an existing valid MP3 as the audio base
12+
BASE_MP3 = "/home/martijn/github/Audio-Scan/t/mp3/no-tags-mp1l3.mp3"
13+
14+
15+
def get_audio_payload():
16+
"""Read raw MPEG audio frames from an existing no-tags fixture."""
17+
with open(BASE_MP3, 'rb') as f:
18+
return f.read()
19+
20+
21+
def syncsafe_encode(size):
22+
"""Encode an integer as a 4-byte syncsafe integer (ID3v2.4 spec)."""
23+
return (
24+
((size & 0x0FE00000) << 3) |
25+
((size & 0x001FC000) << 2) |
26+
((size & 0x00003F80) << 1) |
27+
(size & 0x0000007F)
28+
)
29+
30+
31+
def make_txxx_frame(desc, values, encoding=3):
32+
"""Build an ID3v2.4 TXXX frame with null-separated values.
33+
encoding: 0=Latin1, 3=UTF-8
34+
"""
35+
if encoding == 3:
36+
sep = b'\x00'
37+
desc_bytes = desc.encode('utf-8') + b'\x00'
38+
val_bytes = sep.join(v.encode('utf-8') for v in values)
39+
else:
40+
sep = b'\x00'
41+
desc_bytes = desc.encode('latin-1') + b'\x00'
42+
val_bytes = sep.join(v.encode('latin-1') for v in values)
43+
44+
data = bytes([encoding]) + desc_bytes + val_bytes
45+
size = len(data)
46+
frame = b'TXXX' + struct.pack('>I', syncsafe_encode(size)) + b'\x00\x00' + data
47+
return frame
48+
49+
50+
def make_id3v2_tag(frames):
51+
"""Wrap frames in an ID3v2.4 tag header."""
52+
body = b''.join(frames)
53+
size = len(body)
54+
header = b'ID3' + b'\x04\x00' + b'\x00' + struct.pack('>I', syncsafe_encode(size))
55+
return header + body
56+
57+
58+
def write_fixture(name, frames):
59+
tag = make_id3v2_tag(frames)
60+
audio = get_audio_payload()
61+
path = os.path.join(OUTDIR, name)
62+
with open(path, 'wb') as f:
63+
f.write(tag + audio)
64+
print(f"Wrote {path} ({len(tag) + len(audio)} bytes)")
65+
66+
67+
# Fixture 1: Two-value TXXX (ALBUMARTISTS)
68+
write_fixture("v2.4-txxx-multivalue.mp3", [
69+
make_txxx_frame("ALBUMARTISTS", ["Artist1", "Artist2"]),
70+
make_txxx_frame("ARTISTS", ["TrackArtist1", "TrackArtist2"]),
71+
])
72+
73+
# Fixture 2: Three-value TXXX
74+
write_fixture("v2.4-txxx-multivalue-3.mp3", [
75+
make_txxx_frame("ALBUMARTISTS", ["Artist1", "Artist2", "Artist3"]),
76+
])
77+
78+
# Fixture 3: Multi-value with empty slot (tagger bug simulation)
79+
write_fixture("v2.4-txxx-multivalue-empty.mp3", [
80+
make_txxx_frame("ALBUMARTISTS", ["Artist1", "", "Artist3"]),
81+
])
82+
83+
# Fixture 4: Single-value TXXX (regression guard)
84+
write_fixture("v2.4-txxx-single.mp3", [
85+
make_txxx_frame("ALBUMARTISTS", ["OnlyArtist"]),
86+
make_txxx_frame("USER FRAME", ["SingleValue"]),
87+
])

t/mp3.t

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use strict;
33
use Digest::MD5 qw(md5_hex);
44
use File::Spec::Functions;
55
use FindBin ();
6-
use Test::More tests => 401;
6+
use Test::More tests => 413;
77
use Test::Warn;
88

99
use Audio::Scan;
@@ -1374,6 +1374,39 @@ eval {
13741374
is( $tags->{MP3GAIN_MINMAX}, '123,203', 'bad APE tag MP3GAIN_MINMAX ok' );
13751375
}
13761376

1377+
# Multi-value TXXX frames (ID3v2.4 null-separated values)
1378+
{
1379+
my $s = Audio::Scan->scan( _f('v2.4-txxx-multivalue.mp3') );
1380+
my $tags = $s->{tags};
1381+
1382+
is( ref $tags->{ALBUMARTISTS}, 'ARRAY', 'ID3v2.4 multi-value TXXX returns arrayref' );
1383+
is( $tags->{ALBUMARTISTS}->[0], 'Artist1', 'ID3v2.4 multi-value TXXX value 1 ok' );
1384+
is( $tags->{ALBUMARTISTS}->[1], 'Artist2', 'ID3v2.4 multi-value TXXX value 2 ok' );
1385+
is( ref $tags->{ARTISTS}, 'ARRAY', 'ID3v2.4 multi-value TXXX ARTISTS returns arrayref' );
1386+
is( $tags->{ARTISTS}->[0], 'TrackArtist1', 'ID3v2.4 multi-value TXXX ARTISTS value 1 ok' );
1387+
is( $tags->{ARTISTS}->[1], 'TrackArtist2', 'ID3v2.4 multi-value TXXX ARTISTS value 2 ok' );
1388+
}
1389+
1390+
# Multi-value TXXX with 3 values
1391+
{
1392+
my $s = Audio::Scan->scan( _f('v2.4-txxx-multivalue-3.mp3') );
1393+
my $tags = $s->{tags};
1394+
1395+
is( ref $tags->{ALBUMARTISTS}, 'ARRAY', 'ID3v2.4 3-value TXXX returns arrayref' );
1396+
is( scalar @{$tags->{ALBUMARTISTS}}, 3, 'ID3v2.4 3-value TXXX has 3 elements' );
1397+
is( $tags->{ALBUMARTISTS}->[2], 'Artist3', 'ID3v2.4 3-value TXXX value 3 ok' );
1398+
}
1399+
1400+
# Multi-value TXXX with empty slot (tagger bug)
1401+
{
1402+
my $s = Audio::Scan->scan( _f('v2.4-txxx-multivalue-empty.mp3') );
1403+
my $tags = $s->{tags};
1404+
1405+
is( ref $tags->{ALBUMARTISTS}, 'ARRAY', 'ID3v2.4 TXXX with empty slot returns arrayref' );
1406+
is( scalar @{$tags->{ALBUMARTISTS}}, 2, 'ID3v2.4 TXXX empty slot is skipped' );
1407+
is( $tags->{ALBUMARTISTS}->[1], 'Artist3', 'ID3v2.4 TXXX value after empty slot ok' );
1408+
}
1409+
13771410
sub _f {
13781411
return catfile( $FindBin::Bin, 'mp3', shift );
13791412
}

t/mp3/v2.4-txxx-multivalue-3.mp3

4.27 KB
Binary file not shown.
4.27 KB
Binary file not shown.

t/mp3/v2.4-txxx-multivalue.mp3

4.31 KB
Binary file not shown.

t/mp3/v2.4-txxx-single.mp3

4.29 KB
Binary file not shown.

0 commit comments

Comments
 (0)