Skip to content

Commit 8215d40

Browse files
Merge pull request #55 from BehindTheMusicTree/feature/include-all-metadata-in-get-full-metadata
feature: include all metadata in get full metadata
2 parents 1742101 + d733bf7 commit 8215d40

10 files changed

Lines changed: 308 additions & 84 deletions

File tree

.cursor/rules/mastodon-posts.mdc

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
description: Mastodon and social announcement post length (494 char max)
3+
alwaysApply: true
4+
---
5+
6+
# Mastodon / Social Announcement Posts
7+
8+
When drafting **Mastodon posts** or **release/social announcement text** for this project:
9+
10+
- **Maximum length: 494 characters** (including spaces, links, and hashtags).
11+
- Keep the post under this limit so it fits common instance limits and stays readable.
12+
- Prefer a short, scannable version with links (e.g. PyPI, GitHub) over a long post; link to changelog/README for details.
13+
14+
If asked for "with links", include:
15+
- PyPI: https://pypi.org/project/audiometa-python/
16+
- GitHub: https://github.com/BehindTheMusicTree/audiometa

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ All contributors (including maintainers) should update `CHANGELOG.md` when creat
4848

4949
## [Unreleased]
5050

51+
### Added
52+
53+
- **get_full_metadata raw_metadata returns all tags**: `raw_metadata` now consistently includes every tag present in the file per format. RIFF INFO extractor and \_RiffManager no longer filter by `RiffTagKey`—every INFO chunk FourCC (known or custom) is returned in `raw_metadata["riff"]["parsed_fields"]`, with a parsing guard that only accepts valid 4-byte printable-ASCII FourCCs. Integration coverage for custom ID3v2 TXXX, custom Vorbis comments, ID3v1 parsed fields, custom RIFF FourCCs, and BWF bext; test helpers for custom TXXX and custom RIFF INFO fields. Documentation updated for raw-metadata guarantees.
54+
5155
## [1.1.0] - 2025-02-23
5256

5357
### Added

audiometa/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1277,6 +1277,11 @@ def get_full_metadata(
12771277
- Raw metadata details from each format. When include_raw_binary_data is False (default),
12781278
binary/opaque frames (e.g. APIC, PRIV, TRAKTOR4) are summarized as size placeholders.
12791279
1280+
The result's raw_metadata contains every tag present in the file for each format:
1281+
ID3v2 (all frames including custom TXXX/PRIV), Vorbis (all comments), ID3v1 (fixed set),
1282+
RIFF (all INFO chunk FourCCs including custom), and BWF bext in chunk_structure.
1283+
Per-format keys: id3v2.frames, vorbis.comments, id3v1/riff.parsed_fields, riff.chunk_structure.bext.
1284+
12801285
Args:
12811286
file: Audio file path (str or Path)
12821287
include_headers: Whether to include format-specific header information (default: True)

audiometa/manager/_rating_supporting/riff/_RiffManager.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ class _RiffManager(_RatingSupportingMetadataManager):
9595
Note: This manager is the preferred way to handle WAV metadata, as it uses the format's native metadata system
9696
rather than non-standard alternatives like ID3v2 tags. The custom implementation ensures proper handling of RIFF
9797
chunk structures, maintaining word alignment and size fields according to the specification.
98+
99+
Raw metadata: get_raw_metadata_info() (and thus get_full_metadata()["raw_metadata"]["riff"]) includes every
100+
INFO chunk FourCC present in the file in parsed_fields (no filtering by RiffTagKey). Only subchunks with
101+
a valid 4-byte printable-ASCII FourCC are included; custom and known FourCCs are both returned.
98102
"""
99103

100104
class RiffTagKey(RawMetadataKey):
@@ -193,7 +197,7 @@ def _extract_riff_metadata_directly(self, file_data: bytes) -> dict[str, list[st
193197
194198
This method directly parses the RIFF structure to extract metadata from the INFO chunk.
195199
"""
196-
return extract_riff_metadata_directly(file_data, self._skip_id3v2_tags, self.RiffTagKey)
200+
return extract_riff_metadata_directly(file_data, self._skip_id3v2_tags)
197201

198202
def _extract_bext_chunk(self, file_data: bytes) -> dict[str, Any] | None:
199203
"""Extract and parse the bext chunk from BWF files."""
@@ -253,10 +257,8 @@ def _convert_raw_mutagen_metadata_to_dict_with_potential_duplicate_keys(
253257
if hasattr(raw_mutagen_metadata_wav, "info") and raw_mutagen_metadata_wav.info is not None:
254258
info_tags = raw_mutagen_metadata_wav.info
255259
for key, value in info_tags.items():
256-
# key is a FourCC string; check against enum member values
257-
if any(key == member.value for member in self.RiffTagKey.__members__.values()):
258-
# info_tags now contains lists of values, so we can pass them directly
259-
raw_metadata_dict[key] = value
260+
# info_tags contains lists of values; include every FourCC (known and custom)
261+
raw_metadata_dict[key] = value
260262

261263
return raw_metadata_dict
262264

audiometa/manager/_rating_supporting/riff/_riff_info_chunk.py

Lines changed: 19 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,78 +5,66 @@
55
"""
66

77
from collections.abc import Callable
8-
from typing import TYPE_CHECKING, cast
98

109
from ....utils.types import RawMetadataKey
10+
from ._riff_constants import RIFF_CHUNK_ID_SIZE, RIFF_HEADER_SIZE, RIFF_WAVE_FORMAT_POSITION
1111

12-
if TYPE_CHECKING:
13-
pass
12+
_FOURCC_MIN = 0x20
13+
_FOURCC_MAX = 0x7E
1414

15-
from ._riff_constants import RIFF_CHUNK_ID_SIZE, RIFF_HEADER_SIZE, RIFF_WAVE_FORMAT_POSITION
15+
16+
def _is_valid_fourcc(fourcc_bytes: bytes) -> bool:
17+
"""Return True if the 4 bytes form a valid INFO chunk FourCC (printable ASCII)."""
18+
if len(fourcc_bytes) != RIFF_CHUNK_ID_SIZE:
19+
return False
20+
return all(_FOURCC_MIN <= b <= _FOURCC_MAX for b in fourcc_bytes)
1621

1722

1823
def extract_riff_metadata_directly(
19-
file_data: bytes, skip_id3v2_tags_func: Callable[[bytes], bytes], riff_tag_key_class: type[object]
24+
file_data: bytes, skip_id3v2_tags_func: Callable[[bytes], bytes]
2025
) -> dict[str, list[str]]:
2126
"""Manually extract metadata from RIFF chunks without relying on external libraries.
2227
23-
This function directly parses the RIFF structure to extract metadata from the INFO chunk.
24-
25-
Args:
26-
file_data: Full file data (may include ID3v2 tags)
27-
skip_id3v2_tags_func: Function to skip ID3v2 tags from file data
28-
riff_tag_key_class: RiffTagKey class for validation
29-
30-
Returns:
31-
Dictionary mapping RIFF tag IDs to lists of values
28+
Parses the RIFF structure and returns every INFO chunk field. No filtering by
29+
RiffTagKey—known and custom FourCCs are included. Only subchunks with a valid
30+
4-byte printable-ASCII FourCC are accepted.
3231
"""
3332
info_tags: dict[str, list[str]] = {}
3433

35-
# Skip ID3v2 if present
3634
file_data = skip_id3v2_tags_func(file_data)
3735

38-
# Validate RIFF header
3936
if (
4037
len(file_data) < RIFF_HEADER_SIZE
4138
or file_data[:RIFF_CHUNK_ID_SIZE] != b"RIFF"
4239
or file_data[RIFF_WAVE_FORMAT_POSITION:RIFF_HEADER_SIZE] != b"WAVE"
4340
):
4441
return info_tags
4542

46-
pos = 12 # Start after RIFF header
43+
pos = 12
4744
while pos < len(file_data) - 8:
4845
chunk_id = file_data[pos : pos + 4]
4946
chunk_size = int.from_bytes(file_data[pos + 4 : pos + 8], "little")
5047

5148
if chunk_id == b"LIST" and pos + 12 <= len(file_data) and file_data[pos + 8 : pos + 12] == b"INFO":
52-
# Process INFO chunk
5349
info_pos = pos + 12
5450
info_end = pos + 8 + chunk_size
5551

5652
while info_pos < info_end - 8:
57-
# Extract each metadata field
58-
field_id = file_data[info_pos : info_pos + 4].decode("ascii", errors="ignore")
53+
field_id_bytes = file_data[info_pos : info_pos + 4]
5954
field_size = int.from_bytes(file_data[info_pos + 4 : info_pos + 8], "little")
6055

61-
if field_size > 0 and info_pos + 8 + field_size <= info_end:
62-
# -1 to exclude null terminator
56+
if field_size > 0 and info_pos + 8 + field_size <= info_end and _is_valid_fourcc(field_id_bytes):
57+
field_id = field_id_bytes.decode("ascii")
6358
field_data = file_data[info_pos + 8 : info_pos + 8 + field_size - 1]
6459
try:
65-
# Decode and handle null-terminated strings
66-
field_value = field_data.decode("utf-8", errors="ignore")
67-
# Split on null byte and take first part if exists
68-
field_value = field_value.split("\x00")[0].strip()
69-
# Compare field_id with enum member values (FourCC strings)
70-
# Use getattr to safely access __members__ for type checking
71-
members = getattr(riff_tag_key_class, "__members__", {})
72-
if any(field_id == member.value for member in cast(dict, members).values()) and field_value:
60+
field_value = field_data.decode("utf-8", errors="ignore").split("\x00")[0].strip()
61+
if field_value:
7362
if field_id not in info_tags:
7463
info_tags[field_id] = []
7564
info_tags[field_id].append(field_value)
7665
except UnicodeDecodeError:
7766
pass
7867

79-
# Move to next field, maintaining alignment
8068
info_pos += 8 + ((field_size + 1) & ~1)
8169
break
8270

audiometa/test/helpers/id3v2/id3v2_metadata_setter.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,3 +667,14 @@ def set_ufid_with_owner(file_path: Path, owner: str, data: str) -> None:
667667

668668
data_bytes = data.encode("utf-8")
669669
ManualID3v2FrameCreator.create_ufid_frame(file_path, owner, data_bytes, version="2.4")
670+
671+
@staticmethod
672+
def set_custom_txxx(file_path: Path, description: str, value: str) -> None:
673+
"""Set a custom TXXX frame (for testing raw_metadata includes unsupported frames)."""
674+
command = [
675+
get_tool_path("mid3v2"),
676+
"--TXXX",
677+
f"{description}:{value}",
678+
str(file_path),
679+
]
680+
run_external_tool(command, "mid3v2")

audiometa/test/helpers/riff/riff_manual_metadata_creator.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,3 +342,10 @@ def _remove_existing_info_chunk(data: bytes) -> bytes:
342342
pos = chunk_end
343343

344344
return bytes(result)
345+
346+
@staticmethod
347+
def add_custom_info_field(file_path: Path, fourcc: str, value: str) -> None:
348+
"""Append a custom INFO chunk field (FourCC + value) for testing raw_metadata includes all tags."""
349+
existing_fields = ManualRIFFMetadataCreator._read_existing_info_fields(file_path)
350+
new_field = ManualRIFFMetadataCreator._create_info_field(fourcc, value)
351+
ManualRIFFMetadataCreator._write_riff_info_chunk(file_path, [*existing_fields, new_field])
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Tests that get_full_metadata raw_metadata includes tags per format.
2+
3+
ID3v2 and Vorbis expose all frames/comments (including custom/unsupported).
4+
ID3v1 has no extensible tags. RIFF INFO exposes all INFO chunk FourCCs (known and custom).
5+
BWF (bext) is exposed under RIFF chunk_structure.
6+
"""
7+
8+
import pytest
9+
10+
from audiometa import get_full_metadata
11+
from audiometa.test.helpers.id3v2.id3v2_metadata_setter import ID3v2MetadataSetter
12+
from audiometa.test.helpers.riff.riff_manual_metadata_creator import ManualRIFFMetadataCreator
13+
from audiometa.test.helpers.riff.riff_metadata_setter import RIFFMetadataSetter
14+
from audiometa.test.helpers.temp_file_with_metadata import temp_file_with_metadata
15+
from audiometa.test.helpers.vorbis.vorbis_metadata_setter import VorbisMetadataSetter
16+
from audiometa.utils.unified_metadata_key import UnifiedMetadataKey
17+
18+
19+
@pytest.mark.integration
20+
class TestGetFullMetadataRawMetadataIncludesUnsupportedTags:
21+
def test_id3v2_raw_metadata_includes_custom_txxx_frame(self):
22+
with temp_file_with_metadata({"title": "Track"}, "mp3") as test_file:
23+
ID3v2MetadataSetter.set_custom_txxx(test_file, "MYCUSTOMKEY", "custom value")
24+
result = get_full_metadata(test_file)
25+
frames = result.get("raw_metadata", {}).get("id3v2", {}).get("frames", {})
26+
assert "TXXX:MYCUSTOMKEY" in frames
27+
frame_data = frames["TXXX:MYCUSTOMKEY"]
28+
assert "text" in frame_data
29+
assert "custom value" in frame_data["text"]
30+
31+
def test_vorbis_raw_metadata_includes_custom_comment(self):
32+
with temp_file_with_metadata({"title": "Track"}, "flac") as test_file:
33+
VorbisMetadataSetter.set_tag(test_file, "MYCUSTOMKEY", "custom value")
34+
result = get_full_metadata(test_file)
35+
comments = result.get("raw_metadata", {}).get("vorbis", {}).get("comments", {})
36+
assert "MYCUSTOMKEY" in comments
37+
assert comments["MYCUSTOMKEY"] == ["custom value"]
38+
39+
def test_id3v1_raw_metadata_includes_parsed_fields(self):
40+
with temp_file_with_metadata(
41+
{"title": "ID3v1 Title", "artist": "ID3v1 Artist"},
42+
"id3v1",
43+
) as test_file:
44+
result = get_full_metadata(test_file)
45+
parsed = result.get("raw_metadata", {}).get("id3v1", {}).get("parsed_fields", {})
46+
assert UnifiedMetadataKey.TITLE in parsed
47+
assert parsed[UnifiedMetadataKey.TITLE] == "ID3v1 Title"
48+
assert UnifiedMetadataKey.ARTISTS in parsed
49+
assert parsed[UnifiedMetadataKey.ARTISTS] == "ID3v1 Artist"
50+
51+
def test_riff_raw_metadata_includes_known_info_tags(self):
52+
with temp_file_with_metadata(
53+
{"title": "RIFF Title", "artist": "RIFF Artist"},
54+
"wav",
55+
) as test_file:
56+
result = get_full_metadata(test_file)
57+
parsed = result.get("raw_metadata", {}).get("riff", {}).get("parsed_fields", {})
58+
assert "INAM" in parsed
59+
assert parsed["INAM"] == "RIFF Title"
60+
assert "IART" in parsed
61+
assert parsed["IART"] == "RIFF Artist"
62+
63+
def test_riff_raw_metadata_includes_custom_fourcc(self):
64+
with temp_file_with_metadata({}, "wav") as test_file:
65+
ManualRIFFMetadataCreator.add_custom_info_field(test_file, "CUST", "custom value")
66+
result = get_full_metadata(test_file)
67+
parsed = result.get("raw_metadata", {}).get("riff", {}).get("parsed_fields", {})
68+
assert "CUST" in parsed
69+
assert parsed["CUST"] == "custom value"
70+
71+
def test_riff_raw_metadata_includes_bext_in_chunk_structure(self):
72+
with temp_file_with_metadata({}, "wav") as test_file:
73+
RIFFMetadataSetter.set_bext_description(test_file, "BWF Description")
74+
RIFFMetadataSetter.set_bext_originator(test_file, "BWF Originator")
75+
result = get_full_metadata(test_file)
76+
chunk_structure = result.get("raw_metadata", {}).get("riff", {}).get("chunk_structure", {})
77+
assert "bext" in chunk_structure
78+
bext = chunk_structure["bext"]
79+
assert bext["Description"] == "BWF Description"
80+
assert bext["Originator"] == "BWF Originator"
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Unit tests for RIFF INFO chunk FourCC validation."""
2+
3+
import pytest
4+
5+
from audiometa.manager._rating_supporting.riff._riff_info_chunk import _is_valid_fourcc, extract_riff_metadata_directly
6+
7+
8+
@pytest.mark.unit
9+
class TestRiffInfoChunkFourCCValidation:
10+
@pytest.mark.parametrize(
11+
"fourcc_bytes",
12+
[
13+
b"INAM",
14+
b"IART",
15+
b"CUST",
16+
b" ",
17+
b"0123",
18+
b"AB\x20\x7e",
19+
],
20+
)
21+
def test_valid_fourcc_accepted(self, fourcc_bytes: bytes):
22+
assert _is_valid_fourcc(fourcc_bytes) is True
23+
24+
@pytest.mark.parametrize(
25+
"fourcc_bytes",
26+
[
27+
b"IN\x00M",
28+
b"\x00NAM",
29+
b"INAM\x00",
30+
b"\x1fABC",
31+
b"AB\x7f\x43",
32+
b"",
33+
b"I",
34+
b"INA",
35+
b"INAMX",
36+
],
37+
)
38+
def test_invalid_fourcc_rejected(self, fourcc_bytes: bytes):
39+
assert _is_valid_fourcc(fourcc_bytes) is False
40+
41+
def test_extract_includes_valid_fourcc_excludes_invalid(self):
42+
def no_skip(data: bytes) -> bytes:
43+
return data
44+
45+
# Minimal RIFF WAVE with LIST INFO: one valid (INAM) and one invalid (IN\x00M) subchunk
46+
inam_data = b"Title\x00"
47+
invalid_data = b"X\x00"
48+
inam_size = len(inam_data)
49+
invalid_size = len(invalid_data)
50+
if inam_size % 2:
51+
inam_size += 1
52+
if invalid_size % 2:
53+
invalid_size += 1
54+
list_payload = b"INFO" b"INAM" + inam_size.to_bytes(4, "little") + inam_data.ljust(
55+
inam_size, b"\x00"
56+
) + b"IN\x00M" + invalid_size.to_bytes(4, "little") + invalid_data.ljust(invalid_size, b"\x00")
57+
list_size = len(list_payload)
58+
if list_size % 2:
59+
list_payload += b"\x00"
60+
list_size += 1
61+
riff_body_size = 4 + 8 + list_size
62+
riff = (
63+
b"RIFF" + riff_body_size.to_bytes(4, "little") + b"WAVE"
64+
b"LIST" + list_size.to_bytes(4, "little") + list_payload
65+
)
66+
result = extract_riff_metadata_directly(riff, no_skip)
67+
assert "INAM" in result
68+
assert result["INAM"] == ["Title"]
69+
for key in result:
70+
assert key == "INAM", f"Only INAM should appear, got key {key!r}"

0 commit comments

Comments
 (0)