From 5a9f68bb2820d3f393e8835415152451a63802f6 Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Sun, 20 Sep 2026 23:39:33 -0700 Subject: [PATCH] Write standard ID3 frames for TTS tags (#182819) --- homeassistant/components/tts/__init__.py | 22 +---- tests/components/tts/fixtures/id3v1.mp3 | Bin 0 -> 1040 bytes tests/components/tts/fixtures/untagged.mp3 | Bin 0 -> 912 bytes tests/components/tts/test_init.py | 110 +++++++++++++++++++++ 4 files changed, 114 insertions(+), 18 deletions(-) create mode 100644 tests/components/tts/fixtures/id3v1.mp3 create mode 100644 tests/components/tts/fixtures/untagged.mp3 diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 7a876c31d20d..1262e8132131 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -18,7 +18,7 @@ from typing import Any, Final, Protocol from aiohttp import web import mutagen -from mutagen.id3 import ID3, TextFrame as ID3Text +from mutagen.id3 import TALB, TIT2, TPE1, Encoding import probatio from propcache.api import cached_property @@ -1195,23 +1195,9 @@ class SpeechManager: if tts_file is not None: if not tts_file.tags: tts_file.add_tags() - if isinstance(tts_file.tags, ID3): - tts_file["artist"] = ID3Text( - encoding=3, - text=artist, # type: ignore[no-untyped-call] - ) - tts_file["album"] = ID3Text( - encoding=3, - text=album, # type: ignore[no-untyped-call] - ) - tts_file["title"] = ID3Text( - encoding=3, - text=message, # type: ignore[no-untyped-call] - ) - else: - tts_file["artist"] = artist - tts_file["album"] = album - tts_file["title"] = message + tts_file.tags.add(TPE1(encoding=Encoding.UTF8, text=artist)) # type: ignore[no-untyped-call] + tts_file.tags.add(TALB(encoding=Encoding.UTF8, text=album)) # type: ignore[no-untyped-call] + tts_file.tags.add(TIT2(encoding=Encoding.UTF8, text=message)) # type: ignore[no-untyped-call] data_bytes.seek(0) tts_file.save(data_bytes) except mutagen.MutagenError as err: diff --git a/tests/components/tts/fixtures/id3v1.mp3 b/tests/components/tts/fixtures/id3v1.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..441cb3223f3381575f2ee2be4f272eefd4abb9e1 GIT binary patch literal 1040 zcmezWx#a)@40z_HsY{lEC6J;PhwfJnUS861&9L! zDl7pICi4Wb3vcRS!f5pW&nZWMLd+f@aUVxt*U->V5M``qXkY+j(;I-?Qv-7k)bdgH zAYx%Cgb%{C^k;=Qy8Gv(D3oNDsY{lEC6J;PhwfJnUS861&9L! zDl7pICi4Wb3vcRS!f5pW&nZWMLd+f@aUVxt*U->V5M``qXkY+j(;I-?Qv-7k)bdgH PAYx%Cgb%{C^k)G8f(AFR literal 0 HcmV?d00001 diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index e6402ce2e786..c03cc2b380ab 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -11,6 +11,8 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import wave from freezegun.api import FrozenDateTimeFactory +import mutagen +from mutagen.id3 import TIT2, TPE1, Encoding import pytest from homeassistant.components import ffmpeg, tts @@ -2327,3 +2329,111 @@ def test_write_tags_keeps_single_id3_tag() -> None: assert tagged.startswith(b"ID3") assert tagged.count(b"ID3") == 1 + + +def test_write_tags_sets_standard_id3_frames() -> None: + """Test tagging audio carrying only the encoder frame sets standard frames.""" + data = load_fixture_bytes("tagged.mp3", DOMAIN) + assert list(mutagen.File(io.BytesIO(data)).tags) == ["TSSE"] + + tagged = ORIG_WRITE_TAGS( + "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3", + data, + "Test", + "There is someone at the door.", + "en", + None, + ) + + tags = mutagen.File(io.BytesIO(tagged)).tags + assert tags["TPE1"].text == ["en"] + assert tags["TALB"].text == ["Test"] + assert tags["TIT2"].text == ["There is someone at the door."] + assert "TSSE" in tags + + +def test_write_tags_adds_tag_to_untagged_audio() -> None: + """Test audio arriving without an ID3 tag gets one holding the frames.""" + data = load_fixture_bytes("untagged.mp3", DOMAIN) + assert not data.startswith(b"ID3") + + tagged = ORIG_WRITE_TAGS( + "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3", + data, + "Test", + "There is someone at the door.", + "en", + None, + ) + + assert tagged.startswith(b"ID3") + assert tagged.count(b"ID3") == 1 + tags = mutagen.File(io.BytesIO(tagged)).tags + assert tags["TPE1"].text == ["en"] + assert tags["TALB"].text == ["Test"] + assert tags["TIT2"].text == ["There is someone at the door."] + + +def test_write_tags_overwrites_id3v1_metadata() -> None: + """Test audio arriving with only an ID3v1 trailer gets the frames rewritten.""" + data = load_fixture_bytes("id3v1.mp3", DOMAIN) + assert not data.startswith(b"ID3") + assert data[-128:-125] == b"TAG" + + tagged = ORIG_WRITE_TAGS( + "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3", + data, + "Test", + "There is someone at the door.", + "en", + None, + ) + + assert tagged.startswith(b"ID3") + tags = mutagen.File(io.BytesIO(tagged)).tags + assert tags["TPE1"].text == ["en"] + assert tags["TALB"].text == ["Test"] + assert tags["TIT2"].text == ["There is someone at the door."] + + +def test_write_tags_replaces_existing_frames() -> None: + """Test frames carried through conversion from the provider are replaced.""" + data = load_fixture_bytes("untagged.mp3", DOMAIN) + source = io.BytesIO(data) + source.name = "source.mp3" + source_file = mutagen.File(source) + source_file.add_tags() + source_file.tags.add(TIT2(encoding=Encoding.UTF8, text="Provider title")) + source_file.tags.add(TPE1(encoding=Encoding.UTF8, text="Provider artist")) + source.seek(0) + source_file.save(source) + + tagged = ORIG_WRITE_TAGS( + "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3", + source.getvalue(), + "Test", + "There is someone at the door.", + "en", + None, + ) + + tags = mutagen.File(io.BytesIO(tagged)).tags + assert tags["TIT2"].text == ["There is someone at the door."] + assert tags["TPE1"].text == ["en"] + + +def test_write_tags_uses_voice_as_artist() -> None: + """Test the voice option replaces the language as the artist frame.""" + data = load_fixture_bytes("tagged.mp3", DOMAIN) + + tagged = ORIG_WRITE_TAGS( + "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3", + data, + "Test", + "There is someone at the door.", + "en", + {"voice": "JennyNeural"}, + ) + + tags = mutagen.File(io.BytesIO(tagged)).tags + assert tags["TPE1"].text == ["JennyNeural"]