diff --git a/picard/formats/id3.py b/picard/formats/id3.py index f23fbbdf1..424d4cc4f 100644 --- a/picard/formats/id3.py +++ b/picard/formats/id3.py @@ -33,6 +33,7 @@ from collections import defaultdict +from enum import IntEnum import re from urllib.parse import urlparse @@ -82,12 +83,25 @@ __ID3_IMAGE_TYPE_MAP = dict(__IMAGE_TYPES) __ID3_REVERSE_IMAGE_TYPE_MAP = dict([(v, k) for k, v in __IMAGE_TYPES]) +class Id3Encoding(IntEnum): + LATIN1 = 0 + UTF16 = 1 + UTF16BE = 2 + UTF8 = 3 + + def from_config(id3v2_encoding): + return { + 'utf-8': Id3Encoding.UTF8, + 'utf-16': Id3Encoding.UTF16 + }.get(id3v2_encoding, Id3Encoding.LATIN1) + + def id3text(text, encoding): """Returns a string which only contains code points which can be encododed with the given numeric id3 encoding. """ - if encoding == 0: + if encoding == Id3Encoding.LATIN1: return text.encode("latin1", "replace").decode("latin1") return text @@ -259,7 +273,7 @@ class ID3File(File): tags = file.tags or {} config = get_config() itunes_compatible = config.setting['itunes_compatible_grouping'] - rating_user_email = id3text(config.setting['rating_user_email'], 0) + rating_user_email = id3text(config.setting['rating_user_email'], Id3Encoding.LATIN1) rating_steps = config.setting['rating_steps'] # upgrade custom 2.3 frames to 2.4 for old, new in self.__upgrade.items(): @@ -391,28 +405,28 @@ class ID3File(File): if images_to_save: tags.delall('APIC') - encoding = {'utf-8': 3, 'utf-16': 1}.get(config.setting['id3v2_encoding'], 0) + encoding = Id3Encoding.from_config(config.setting['id3v2_encoding']) if 'tracknumber' in metadata: if 'totaltracks' in metadata: text = '%s/%s' % (metadata['tracknumber'], metadata['totaltracks']) else: text = metadata['tracknumber'] - tags.add(id3.TRCK(encoding=0, text=id3text(text, 0))) + tags.add(id3.TRCK(encoding=Id3Encoding.LATIN1, text=id3text(text, Id3Encoding.LATIN1))) if 'discnumber' in metadata: if 'totaldiscs' in metadata: text = '%s/%s' % (metadata['discnumber'], metadata['totaldiscs']) else: text = metadata['discnumber'] - tags.add(id3.TPOS(encoding=0, text=id3text(text, 0))) + tags.add(id3.TPOS(encoding=Id3Encoding.LATIN1, text=id3text(text, Id3Encoding.LATIN1))) if 'movementnumber' in metadata: if 'movementtotal' in metadata: text = '%s/%s' % (metadata['movementnumber'], metadata['movementtotal']) else: text = metadata['movementnumber'] - tags.add(id3.MVIN(encoding=0, text=id3text(text, 0))) + tags.add(id3.MVIN(encoding=Id3Encoding.LATIN1, text=id3text(text, Id3Encoding.LATIN1))) # This is necessary because mutagens HashKey for APIC frames only # includes the FrameID (APIC) and description - it's basically @@ -427,10 +441,10 @@ class ID3File(File): else: desctag = "(%i)" % counters[desc] counters[desc] += 1 - tags.add(id3.APIC(encoding=0, + tags.add(id3.APIC(encoding=Id3Encoding.LATIN1, mime=image.mimetype, type=image_type_as_id3_num(image.maintype), - desc=id3text(desctag, 0), + desc=id3text(desctag, Id3Encoding.LATIN1), data=image.data)) tmcl = mutagen.id3.TMCL(encoding=encoding, people=[]) @@ -457,7 +471,7 @@ class ID3File(File): (lang, desc) = parse_comment_tag(name) if desc.lower()[:4] == 'itun': tags.delall('COMM:' + desc) - tags.add(id3.COMM(encoding=0, desc=desc, lang='eng', text=[v + '\x00' for v in values])) + tags.add(id3.COMM(encoding=Id3Encoding.LATIN1, desc=desc, lang='eng', text=[v + '\x00' for v in values])) else: tags.add(id3.COMM(encoding=encoding, desc=desc, lang=lang, text=values)) elif name.startswith('lyrics:') or name == 'lyrics': @@ -473,7 +487,7 @@ class ID3File(File): elif name == 'musicbrainz_recordingid': tags.add(id3.UFID(owner='http://musicbrainz.org', data=bytes(values[0], 'ascii'))) elif name == '~rating': - rating_user_email = id3text(config.setting['rating_user_email'], 0) + rating_user_email = id3text(config.setting['rating_user_email'], Id3Encoding.LATIN1) # Search for an existing POPM frame to get the current playcount for frame in tags.values(): if frame.FrameID == 'POPM' and frame.email == rating_user_email: @@ -597,7 +611,7 @@ class ID3File(File): tags.delall(real_name) tags.delall('TXXX:' + self.__rtranslate_freetext[name]) elif real_name == 'POPM': - rating_user_email = id3text(config.setting['rating_user_email'], 0) + rating_user_email = id3text(config.setting['rating_user_email'], Id3Encoding.LATIN1) for key, frame in list(tags.items()): if frame.FrameID == 'POPM' and frame.email == rating_user_email: del tags[key] diff --git a/test/formats/test_id3.py b/test/formats/test_id3.py index 7a1438e46..ed0ab0cdc 100644 --- a/test/formats/test_id3.py +++ b/test/formats/test_id3.py @@ -644,11 +644,17 @@ class AIFFTest(CommonId3Tests.Id3TestCase): class Id3UtilTest(PicardTestCase): + def test_id3encoding_from_config(self): + self.assertEqual(id3.Id3Encoding.LATIN1, id3.Id3Encoding.from_config('iso-8859-1')) + self.assertEqual(id3.Id3Encoding.UTF16, id3.Id3Encoding.from_config('utf-16')) + self.assertEqual(id3.Id3Encoding.UTF8, id3.Id3Encoding.from_config('utf-8')) + def test_id3text(self): teststring = '日本語testÖäß' - self.assertEqual(id3.id3text(teststring, 0), '???testÖäß') - self.assertEqual(id3.id3text(teststring, 1), teststring) - self.assertEqual(id3.id3text(teststring, 3), teststring) + self.assertEqual(id3.id3text(teststring, id3.Id3Encoding.LATIN1), '???testÖäß') + self.assertEqual(id3.id3text(teststring, id3.Id3Encoding.UTF16), teststring) + self.assertEqual(id3.id3text(teststring, id3.Id3Encoding.UTF16BE), teststring) + self.assertEqual(id3.id3text(teststring, id3.Id3Encoding.UTF8), teststring) def test_image_type_from_id3_num(self): self.assertEqual(id3.image_type_from_id3_num(0), 'other') diff --git a/test/test_compatid3.py b/test/test_compatid3.py index d713aaee1..f7940be17 100644 --- a/test/test_compatid3.py +++ b/test/test_compatid3.py @@ -6,7 +6,7 @@ # Copyright (C) 2013, 2018 Laurent Monin # Copyright (C) 2016 Christoph Reiter # Copyright (C) 2018 Wieland Hoffmann -# Copyright (C) 2019 Philipp Wolfer +# Copyright (C) 2019, 2021 Philipp Wolfer # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License @@ -27,23 +27,17 @@ from mutagen import id3 from test.picardtestcase import PicardTestCase -from picard.formats.id3 import id3text +from picard.formats.id3 import Id3Encoding from picard.formats.mutagenext import compatid3 class UpdateToV23Test(PicardTestCase): - def test_id3text(self): - self.assertEqual(id3text("\u1234", 0), "?") - self.assertEqual(id3text("\u1234", 1), "\u1234") - self.assertEqual(id3text("\u1234", 2), "\u1234") - self.assertEqual(id3text("\u1234", 3), "\u1234") - def test_keep_some_v24_tag(self): tags = compatid3.CompatID3() - tags.add(id3.TSOP(encoding=0, text=["foo"])) - tags.add(id3.TSOA(encoding=0, text=["foo"])) - tags.add(id3.TSOT(encoding=0, text=["foo"])) + tags.add(id3.TSOP(encoding=Id3Encoding.LATIN1, text=["foo"])) + tags.add(id3.TSOA(encoding=Id3Encoding.LATIN1, text=["foo"])) + tags.add(id3.TSOT(encoding=Id3Encoding.LATIN1, text=["foo"])) tags.update_to_v23() self.assertEqual(tags["TSOP"].text, ["foo"]) self.assertEqual(tags["TSOA"].text, ["foo"]) @@ -51,7 +45,7 @@ class UpdateToV23Test(PicardTestCase): def test_tdrc(self): tags = compatid3.CompatID3() - tags.add(id3.TDRC(encoding=1, text="2003-04-05 12:03")) + tags.add(id3.TDRC(encoding=Id3Encoding.UTF16, text="2003-04-05 12:03")) tags.update_to_v23() self.assertEqual(tags["TYER"].text, ["2003"]) self.assertEqual(tags["TDAT"].text, ["0504"]) @@ -59,30 +53,30 @@ class UpdateToV23Test(PicardTestCase): def test_tdor(self): tags = compatid3.CompatID3() - tags.add(id3.TDOR(encoding=1, text="2003-04-05 12:03")) + tags.add(id3.TDOR(encoding=Id3Encoding.UTF16, text="2003-04-05 12:03")) tags.update_to_v23() self.assertEqual(tags["TORY"].text, ["2003"]) def test_genre_from_v24_1(self): tags = compatid3.CompatID3() - tags.add(id3.TCON(encoding=1, text=["4", "Rock"])) + tags.add(id3.TCON(encoding=Id3Encoding.UTF16, text=["4", "Rock"])) tags.update_to_v23() self.assertEqual(tags["TCON"].text, ["Disco", "Rock"]) def test_genre_from_v24_2(self): tags = compatid3.CompatID3() - tags.add(id3.TCON(encoding=1, text=["RX", "3", "CR"])) + tags.add(id3.TCON(encoding=Id3Encoding.UTF16, text=["RX", "3", "CR"])) tags.update_to_v23() self.assertEqual(tags["TCON"].text, ["Remix", "Dance", "Cover"]) def test_genre_from_v23_1(self): tags = compatid3.CompatID3() - tags.add(id3.TCON(encoding=1, text=["(4)Rock"])) + tags.add(id3.TCON(encoding=Id3Encoding.UTF16, text=["(4)Rock"])) tags.update_to_v23() self.assertEqual(tags["TCON"].text, ["Disco", "Rock"]) def test_genre_from_v23_2(self): tags = compatid3.CompatID3() - tags.add(id3.TCON(encoding=1, text=["(RX)(3)(CR)"])) + tags.add(id3.TCON(encoding=Id3Encoding.UTF16, text=["(RX)(3)(CR)"])) tags.update_to_v23() self.assertEqual(tags["TCON"].text, ["Remix", "Dance", "Cover"])