diff --git a/picard/formats/apev2.py b/picard/formats/apev2.py index 5c8db4c17..0245bd1c9 100644 --- a/picard/formats/apev2.py +++ b/picard/formats/apev2.py @@ -55,6 +55,8 @@ UNSUPPORTED_TAGS = [ 'podcasturl', 'show', 'showsort', + 'r128_album_gain', + 'r128_track_gain', ] @@ -97,6 +99,13 @@ class APEv2File(File): "musicbrainz_trackid": "musicbrainz_recordingid", "musicbrainz_releasetrackid": "musicbrainz_trackid", "Original Artist": "originalartist", + "REPLAYGAIN_ALBUM_GAIN": "replaygain_album_gain", + "REPLAYGAIN_ALBUM_PEAK": "replaygain_album_peak", + "REPLAYGAIN_ALBUM_RANGE": "replaygain_album_range", + "REPLAYGAIN_TRACK_GAIN": "replaygain_track_gain", + "REPLAYGAIN_TRACK_PEAK": "replaygain_track_peak", + "REPLAYGAIN_TRACK_RANGE": "replaygain_track_range", + "REPLAYGAIN_REFERENCE_LOUDNESS": "replaygain_reference_loudness", } __rtranslate = dict([(v, k) for k, v in __translate.items()]) diff --git a/picard/formats/asf.py b/picard/formats/asf.py index d65f0be82..78a57a772 100644 --- a/picard/formats/asf.py +++ b/picard/formats/asf.py @@ -37,6 +37,7 @@ from picard.formats.id3 import ( image_type_as_id3_num, types_from_id3, ) +from picard.formats.mutagenext import delall_ci from picard.metadata import Metadata from picard.util import encode_filename @@ -161,8 +162,25 @@ class ASFFile(File): } __RTRANS = dict([(b, a) for a, b in __TRANS.items()]) + # Tags to load case insensitive + __TRANS_CI = { + 'replaygain_album_gain': 'REPLAYGAIN_ALBUM_GAIN', + 'replaygain_album_peak': 'REPLAYGAIN_ALBUM_PEAK', + 'replaygain_album_range': 'REPLAYGAIN_ALBUM_RANGE', + 'replaygain_track_gain': 'REPLAYGAIN_TRACK_GAIN', + 'replaygain_track_peak': 'REPLAYGAIN_TRACK_PEAK', + 'replaygain_track_range': 'REPLAYGAIN_TRACK_RANGE', + 'replaygain_reference_loudness': 'REPLAYGAIN_REFERENCE_LOUDNESS', + } + __RTRANS_CI = dict([(b.lower(), a) for a, b in __TRANS_CI.items()]) + + def __init__(self, filename): + super().__init__(filename) + self.__casemap = {} + def _load(self, filename): log.debug("Loading file %r", filename) + self.__casemap = {} file = ASF(encode_filename(filename)) metadata = Metadata() for name, values in file.tags.items(): @@ -184,8 +202,6 @@ class ASFFile(File): else: metadata.images.append(coverartimage) - continue - elif name not in self.__RTRANS: continue elif name == 'WM/SharedUserRating': # Rating in WMA ranges from 0 to 99, normalize this to the range 0 to 5 @@ -195,7 +211,15 @@ class ASFFile(File): if len(disc) > 1: metadata["totaldiscs"] = disc[1] values[0] = disc[0] - name = self.__RTRANS[name] + name_lower = name.lower() + if name in self.__RTRANS: + name = self.__RTRANS[name] + elif name_lower in self.__RTRANS_CI: + orig_name = name + name = self.__RTRANS_CI[name_lower] + self.__casemap[name] = orig_name + else: + continue values = [str(value) for value in values if value] if values: metadata[name] = values @@ -224,9 +248,16 @@ class ASFFile(File): values = [int(values[0]) * 99 // (config.setting['rating_steps'] - 1)] elif name == 'discnumber' and 'totaldiscs' in metadata: values = ['%s/%s' % (metadata['discnumber'], metadata['totaldiscs'])] - if name not in self.__TRANS: + if name in self.__TRANS: + name = self.__TRANS[name] + elif name in self.__TRANS_CI: + if name in self.__casemap: + name = self.__casemap[name] + else: + name = self.__TRANS_CI[name] + delall_ci(tags, name) + else: continue - name = self.__TRANS[name] tags[name] = values self._remove_deleted_tags(metadata, tags) @@ -243,6 +274,7 @@ class ASFFile(File): @classmethod def supports_tag(cls, name): return (name in cls.__TRANS + or name in cls.__TRANS_CI or name in ('~rating', '~length', 'totaldiscs') or name.startswith('lyrics')) diff --git a/picard/formats/id3.py b/picard/formats/id3.py index 8709b4bf6..84f42eeb3 100644 --- a/picard/formats/id3.py +++ b/picard/formats/id3.py @@ -37,7 +37,10 @@ from picard.coverart.image import ( TagCoverArtImage, ) from picard.file import File -from picard.formats.mutagenext import compatid3 +from picard.formats.mutagenext import ( + compatid3, + delall_ci, +) from picard.metadata import Metadata from picard.util import ( encode_filename, @@ -177,6 +180,18 @@ class ID3File(File): __rtranslate_freetext = dict([(v, k) for k, v in __translate_freetext.items()]) __translate_freetext['writer'] = 'writer' # For backward compatibility of case + # Freetext fields that are loaded case-insensitive + __rtranslate_freetext_ci = { + 'replaygain_album_gain': 'REPLAYGAIN_ALBUM_GAIN', + 'replaygain_album_peak': 'REPLAYGAIN_ALBUM_PEAK', + 'replaygain_album_range': 'REPLAYGAIN_ALBUM_RANGE', + 'replaygain_track_gain': 'REPLAYGAIN_TRACK_GAIN', + 'replaygain_track_peak': 'REPLAYGAIN_TRACK_PEAK', + 'replaygain_track_range': 'REPLAYGAIN_TRACK_RANGE', + 'replaygain_reference_loudness': 'REPLAYGAIN_REFERENCE_LOUDNESS', + } + __translate_freetext_ci = dict([(b.lower(), a) for a, b in __rtranslate_freetext_ci.items()]) + # Obsolete tag names which will still be loaded, but will get renamed on saving __rename_freetext = { 'Artists': 'ARTISTS', @@ -202,6 +217,10 @@ class ID3File(File): 'MVIN': re.compile(r'^(?P\d+)(?:/(?P\d+))?$') } + def __init__(self, filename): + super().__init__(filename) + self.__casemap = {} + def build_TXXX(self, encoding, desc, values): """Construct and return a TXXX frame.""" # This is here so that plugins can customize the behavior of TXXX @@ -214,6 +233,7 @@ class ID3File(File): def _load(self, filename): log.debug("Loading file %r", filename) + self.__casemap = {} file = self._get_file(encode_filename(filename)) tags = file.tags or {} # upgrade custom 2.3 frames to 2.4 @@ -256,9 +276,14 @@ class ID3File(File): metadata.add('performer:%s' % role, name) elif frameid == 'TXXX': name = frame.desc + name_lower = name.lower() if name in self.__rename_freetext: name = self.__rename_freetext[name] - if name in self.__translate_freetext: + if name_lower in self.__translate_freetext_ci: + orig_name = name + name = self.__translate_freetext_ci[name_lower] + self.__casemap[name] = orig_name + elif name in self.__translate_freetext: name = self.__translate_freetext[name] elif ((name in self.__rtranslate) != (name in self.__rtranslate_freetext)): @@ -370,7 +395,6 @@ class ID3File(File): tmcl = mutagen.id3.TMCL(encoding=encoding, people=[]) tipl = mutagen.id3.TIPL(encoding=encoding, people=[]) - for name, values in metadata.rawitems(): values = [id3text(v, encoding) for v in values] name = id3text(name, encoding) @@ -451,6 +475,13 @@ class ID3File(File): tags.delall('XSOP') elif frameid == 'TSO2': tags.delall('TXXX:ALBUMARTISTSORT') + elif name in self.__rtranslate_freetext_ci: + if name in self.__casemap: + description = self.__casemap[name] + else: + description = self.__rtranslate_freetext_ci[name] + delall_ci(tags, 'TXXX:' + description) + tags.add(self.build_TXXX(encoding, description, values)) elif name in self.__rtranslate_freetext: description = self.__rtranslate_freetext[name] if description in self.__rrename_freetext: @@ -542,7 +573,7 @@ class ID3File(File): @classmethod def supports_tag(cls, name): - unsupported_tags = {} + unsupported_tags = ['r128_album_gain', 'r128_track_gain'] return ((name and not name.startswith("~") and name not in unsupported_tags) or name in ("~rating", "~length") or name.startswith("~id3")) diff --git a/picard/formats/mp4.py b/picard/formats/mp4.py index b73008ec4..de78da6a3 100644 --- a/picard/formats/mp4.py +++ b/picard/formats/mp4.py @@ -31,6 +31,7 @@ from picard.coverart.image import ( TagCoverArtImage, ) from picard.file import File +from picard.formats.mutagenext import delall_ci from picard.metadata import Metadata from picard.util import encode_filename @@ -124,15 +125,33 @@ class MP4File(File): } __r_freeform_tags = dict([(v, k) for k, v in __freeform_tags.items()]) + # Tags to load case insensitive + __r_freeform_tags_ci = { + "replaygain_album_gain": "----:com.apple.iTunes:REPLAYGAIN_ALBUM_GAIN", + "replaygain_album_peak": "----:com.apple.iTunes:REPLAYGAIN_ALBUM_PEAK", + "replaygain_album_range": "----:com.apple.iTunes:REPLAYGAIN_ALBUM_RANGE", + "replaygain_track_gain": "----:com.apple.iTunes:REPLAYGAIN_TRACK_GAIN", + "replaygain_track_peak": "----:com.apple.iTunes:REPLAYGAIN_TRACK_PEAK", + "replaygain_track_range": "----:com.apple.iTunes:REPLAYGAIN_TRACK_RANGE", + "replaygain_reference_loudness": "----:com.apple.iTunes:REPLAYGAIN_REFERENCE_LOUDNESS", + } + __freeform_tags_ci = dict([(b.lower(), a) for a, b in __r_freeform_tags_ci.items()]) + __other_supported_tags = ("discnumber", "tracknumber", "totaldiscs", "totaltracks") + def __init__(self, filename): + super().__init__(filename) + self.__casemap = {} + def _load(self, filename): log.debug("Loading file %r", filename) + self.__casemap = {} file = MP4(encode_filename(filename)) tags = file.tags or {} metadata = Metadata() for name, values in tags.items(): + name_lower = name.lower() if name in self.__text_tags: for value in values: metadata.add(self.__text_tags[name], value) @@ -145,6 +164,12 @@ class MP4File(File): for value in values: value = value.decode("utf-8", "replace").strip("\x00") metadata.add(self.__freeform_tags[name], value) + elif name_lower in self.__freeform_tags_ci: + for value in values: + value = value.decode("utf-8", "replace").strip("\x00") + tag_name = self.__freeform_tags_ci[name_lower] + metadata.add(tag_name, value) + self.__casemap[tag_name] = name elif name == "----:com.apple.iTunes:fingerprint": for value in values: value = value.decode("utf-8", "replace").strip("\x00") @@ -201,6 +226,14 @@ class MP4File(File): elif name in self.__r_freeform_tags: values = [v.encode("utf-8") for v in values] tags[self.__r_freeform_tags[name]] = values + elif name in self.__r_freeform_tags_ci: + values = [v.encode("utf-8") for v in values] + delall_ci(tags, self.__r_freeform_tags_ci[name]) + if name in self.__casemap: + name = self.__casemap[name] + else: + name = self.__r_freeform_tags_ci[name] + tags[name] = values elif name == "musicip_fingerprint": tags["----:com.apple.iTunes:fingerprint"] = [b"MusicMagic Fingerprint%s" % v.encode('ascii') for v in values] @@ -244,6 +277,7 @@ class MP4File(File): return (name in cls.__r_text_tags or name in cls.__r_bool_tags or name in cls.__r_freeform_tags + or name in cls.__r_freeform_tags_ci or name in cls.__r_int_tags or name in cls.__other_supported_tags or name.startswith('lyrics:') @@ -260,6 +294,8 @@ class MP4File(File): return self.__r_int_tags[name] elif name in self.__r_freeform_tags: return self.__r_freeform_tags[name] + elif name in self.__r_freeform_tags_ci: + return self.__r_freeform_tags_ci[name] elif name == "musicip_fingerprint": return "----:com.apple.iTunes:fingerprint" elif name in ("tracknumber", "totaltracks"): diff --git a/picard/formats/mutagenext/__init__.py b/picard/formats/mutagenext/__init__.py index e69de29bb..98ac8136c 100644 --- a/picard/formats/mutagenext/__init__.py +++ b/picard/formats/mutagenext/__init__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# Copyright (C) 2019 Philipp Wolfer +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + +def delall_ci(tags, key): + """Delete all tags with given key, case-insensitive""" + key = key.lower() + for k in list(tags.keys()): + if k.lower() == key: + del tags[k] diff --git a/picard/formats/vorbis.py b/picard/formats/vorbis.py index be37ee9ac..9ccf71c5f 100644 --- a/picard/formats/vorbis.py +++ b/picard/formats/vorbis.py @@ -304,7 +304,7 @@ class VCommentFile(File): @classmethod def supports_tag(cls, name): - unsupported_tags = {} + unsupported_tags = ['r128_album_gain', 'r128_track_gain'] return (bool(name) and name not in unsupported_tags and is_valid_key(name)) @@ -360,6 +360,14 @@ class OggOpusFile(VCommentFile): NAME = "Ogg Opus" _File = mutagen.oggopus.OggOpus + @classmethod + def supports_tag(cls, name): + if name.startswith('replaygain_'): + return False + elif name.startswith('r128_'): + return True + return VCommentFile.supports_tag(name) + def OggAudioFile(filename): """Generic Ogg audio file.""" diff --git a/picard/util/tags.py b/picard/util/tags.py index 07546bf4d..c0d4ccc23 100644 --- a/picard/util/tags.py +++ b/picard/util/tags.py @@ -96,6 +96,15 @@ TAG_NAMES = { 'musicbrainz_originalartistid': N_('MusicBrainz Original Artist Id'), 'originalalbum': N_('Original Album'), 'musicbrainz_originalalbumid': N_('MusicBrainz Original Release Id'), + 'replaygain_album_gain': N_('ReplayGain Album Gain'), + 'replaygain_album_peak': N_('ReplayGain Album Peak'), + 'replaygain_album_range': N_('ReplayGain Album Range'), + 'replaygain_track_gain': N_('ReplayGain Track Gain'), + 'replaygain_track_peak': N_('ReplayGain Track Peak'), + 'replaygain_track_range': N_('ReplayGain Track Range'), + 'replaygain_reference_loudness': N_('ReplayGain Reference Loudness'), + 'r128_album_gain': N_('R128 Album Gain'), + 'r128_track_gain': N_('R128 Track Gain'), } PRESERVED_TAGS = [ diff --git a/test/formats/common.py b/test/formats/common.py index 0a0c63231..af46e277d 100644 --- a/test/formats/common.py +++ b/test/formats/common.py @@ -11,6 +11,7 @@ from test.picardtestcase import PicardTestCase from picard import config import picard.formats from picard.formats import ext_to_format +from picard.formats.mutagenext.tak import TAK from picard.metadata import Metadata @@ -34,6 +35,8 @@ settings = { def save_metadata(filename, metadata): f = picard.formats.open_(filename) + loaded_metadata = f._load(filename) + f._copy_loaded_metadata(loaded_metadata) f._save(filename, metadata) @@ -52,7 +55,17 @@ def save_and_load_metadata(filename, metadata): def load_raw(filename): - return mutagen.File(filename) + f = mutagen.File(filename) + if f is None: + f = mutagen.File(filename, [TAK]) + return f + + +def save_raw(filename, tags): + f = load_raw(filename) + for k, v in tags.items(): + f[k] = v + f.save() TAGS = { @@ -130,6 +143,16 @@ TAGS = { 'work': 'Foo' } +REPLAYGAIN_TAGS = { + 'replaygain_album_gain': '-6.48 dB', + 'replaygain_album_peak': '0.978475', + 'replaygain_album_range': '7.84 dB', + 'replaygain_track_gain': '-6.16 dB', + 'replaygain_track_peak': '0.976991', + 'replaygain_track_range': '8.22 dB', + 'replaygain_reference_loudness': '-18.00 LUFS', +} + def skipUnlessTestfile(func): def _decorator(self, *args, **kwargs): @@ -190,6 +213,8 @@ class CommonTests: def setUp(self): super().setUp() self.tags = TAGS.copy() + self.replaygain_tags = REPLAYGAIN_TAGS.copy() + self.unsupported_tags = {} self.setup_tags() def setup_tags(self): @@ -208,9 +233,26 @@ class CommonTests: @skipUnlessTestfile def test_simple_tags(self): - metadata = Metadata(self.tags) - loaded_metadata = save_and_load_metadata(self.filename, metadata) - for (key, value) in self.tags.items(): + self._test_supported_tags(self.tags) + + @skipUnlessTestfile + def test_replaygain_tags(self): + self._test_supported_tags(self.replaygain_tags) + + @skipUnlessTestfile + def test_replaygain_tags_case_insensitive(self): + tags = { + 'replaygain_album_gain': '-6.48 dB', + 'Replaygain_Album_Peak': '0.978475', + 'replaygain_album_range': '7.84 dB', + 'replaygain_track_gain': '-6.16 dB', + 'replaygain_track_peak': '0.976991', + 'replaygain_track_range': '8.22 dB', + 'replaygain_reference_loudness': '-18.00 LUFS', + } + save_raw(self.filename, tags) + loaded_metadata = load_metadata(self.filename) + for (key, value) in self.replaygain_tags.items(): self.assertEqual(loaded_metadata[key], value, '%s: %r != %r' % (key, loaded_metadata[key], value)) @skipUnlessTestfile @@ -225,10 +267,7 @@ class CommonTests: @skipUnlessTestfile def test_unsupported_tags(self): - metadata = Metadata(self.unsupported_tags) - loaded_metadata = save_and_load_metadata(self.filename, metadata) - for tag in self.unsupported_tags: - self.assertTrue(tag not in loaded_metadata, '%s: %r != None' % (tag, loaded_metadata[tag])) + self._test_unsupported_tags(self.unsupported_tags) @skipUnlessTestfile def test_preserve_unchanged_tags(self): @@ -328,3 +367,15 @@ class CommonTests: self.assertEqual(f._fixed_splitext(f.EXTENSIONS[0]), ('', f.EXTENSIONS[0])) self.assertEqual(f._fixed_splitext('.test'), os.path.splitext('.test')) self.assertNotEqual(f._fixed_splitext(f.EXTENSIONS[0]), os.path.splitext(f.EXTENSIONS[0])) + + def _test_supported_tags(self, tags): + metadata = Metadata(tags) + loaded_metadata = save_and_load_metadata(self.filename, metadata) + for (key, value) in tags.items(): + self.assertEqual(loaded_metadata[key], value, '%s: %r != %r' % (key, loaded_metadata[key], value)) + + def _test_unsupported_tags(self, tags): + metadata = Metadata(tags) + loaded_metadata = save_and_load_metadata(self.filename, metadata) + for tag in tags: + self.assertTrue(tag not in loaded_metadata, '%s: %r != None' % (tag, loaded_metadata[tag])) diff --git a/test/formats/test_apev2.py b/test/formats/test_apev2.py index 3d81b28c5..37e36d017 100644 --- a/test/formats/test_apev2.py +++ b/test/formats/test_apev2.py @@ -36,6 +36,11 @@ SUPPORTED_TAGS = list(set(TAGS.keys()) - set(apev2.UNSUPPORTED_TAGS)) class CommonApeTests: class ApeTestCase(CommonTests.TagFormatsTestCase): + def setup_tags(self): + super().setup_tags() + self.unsupported_tags['r128_album_gain'] = '-2857' + self.unsupported_tags['r128_track_gain'] = '-2857' + def test_supports_tags(self): supports_tag = self.format.supports_tag for key in VALID_KEYS + SUPPORTED_TAGS: diff --git a/test/formats/test_asf.py b/test/formats/test_asf.py index 93ae2cdc6..d32c41cbf 100644 --- a/test/formats/test_asf.py +++ b/test/formats/test_asf.py @@ -3,13 +3,58 @@ from test.picardtestcase import ( create_fake_png, ) -from picard.formats import asf +from picard.formats import ( + asf, + ext_to_format, +) -from .common import CommonTests +from .common import ( + CommonTests, + load_metadata, + load_raw, + save_metadata, + save_raw, + skipUnlessTestfile, +) from .coverart import CommonCoverArtTests -class ASFTest(CommonTests.TagFormatsTestCase): +# prevent unittest to run tests in those classes +class CommonAsfTests: + + class AsfTestCase(CommonTests.TagFormatsTestCase): + + def test_supports_tag(self): + fmt = ext_to_format(self.testfile_ext[1:]) + self.assertTrue(fmt.supports_tag('copyright')) + self.assertTrue(fmt.supports_tag('compilation')) + self.assertTrue(fmt.supports_tag('bpm')) + self.assertTrue(fmt.supports_tag('djmixer')) + self.assertTrue(fmt.supports_tag('discnumber')) + self.assertTrue(fmt.supports_tag('lyrics:lead')) + self.assertTrue(fmt.supports_tag('~length')) + for tag in self.replaygain_tags.keys(): + self.assertTrue(fmt.supports_tag(tag)) + + @skipUnlessTestfile + def test_ci_tags_preserve_case(self): + # Ensure values are not duplicated on repeated save and are saved + # case preserving. + tags = { + 'Replaygain_Album_Peak': '-6.48 dB' + } + save_raw(self.filename, tags) + loaded_metadata = load_metadata(self.filename) + loaded_metadata['replaygain_album_peak'] = '1.0' + save_metadata(self.filename, loaded_metadata) + raw_metadata = load_raw(self.filename) + self.assertIn('Replaygain_Album_Peak', raw_metadata) + self.assertEqual(raw_metadata['Replaygain_Album_Peak'][0], loaded_metadata['replaygain_album_peak']) + self.assertEqual(1, len(raw_metadata['Replaygain_Album_Peak'])) + self.assertNotIn('REPLAYGAIN_ALBUM_PEAK', raw_metadata) + + +class ASFTest(CommonAsfTests.AsfTestCase): testfile = 'test.asf' supports_ratings = True expected_info = { @@ -20,7 +65,7 @@ class ASFTest(CommonTests.TagFormatsTestCase): } -class WMATest(CommonTests.TagFormatsTestCase): +class WMATest(CommonAsfTests.AsfTestCase): testfile = 'test.wma' supports_ratings = True expected_info = { diff --git a/test/formats/test_id3.py b/test/formats/test_id3.py index 45edee1c7..911250021 100644 --- a/test/formats/test_id3.py +++ b/test/formats/test_id3.py @@ -1,5 +1,7 @@ import os.path +import mutagen + from test.picardtestcase import PicardTestCase from picard import config @@ -12,6 +14,7 @@ from .common import ( load_raw, save_and_load_metadata, save_metadata, + save_raw, skipUnlessTestfile, ) from .coverart import CommonCoverArtTests @@ -28,6 +31,8 @@ class CommonId3Tests: self.set_tags({ 'originaldate': '1980' }) + self.unsupported_tags['r128_album_gain'] = '-2857' + self.unsupported_tags['r128_track_gain'] = '-2857' @skipUnlessTestfile def test_id3_freeform_delete(self): @@ -191,6 +196,39 @@ class CommonId3Tests: config.setting['write_id3v23'] = True self.test_preserve_unchanged_tags() + @skipUnlessTestfile + def test_replaygain_tags_case_insensitive(self): + tags = mutagen.id3.ID3Tags() + tags.add(mutagen.id3.TXXX(desc='replaygain_album_gain', text='-6.48 dB')) + tags.add(mutagen.id3.TXXX(desc='Replaygain_Album_Peak', text='0.978475')) + tags.add(mutagen.id3.TXXX(desc='replaygain_album_range', text='7.84 dB')) + tags.add(mutagen.id3.TXXX(desc='replaygain_track_gain', text='-6.16 dB')) + tags.add(mutagen.id3.TXXX(desc='REPLAYGAIN_track_peak', text='0.976991')) + tags.add(mutagen.id3.TXXX(desc='REPLAYGAIN_TRACK_RANGE', text='8.22 dB')) + tags.add(mutagen.id3.TXXX(desc='replaygain_reference_loudness', text='-18.00 LUFS')) + save_raw(self.filename, tags) + loaded_metadata = load_metadata(self.filename) + for (key, value) in self.replaygain_tags.items(): + self.assertEqual(loaded_metadata[key], value, '%s: %r != %r' % (key, loaded_metadata[key], value)) + + @skipUnlessTestfile + def test_ci_tags_preserve_case(self): + # Ensure values are not duplicated on repeated save and are saved + # case preserving. + tags = mutagen.id3.ID3Tags() + tags.add(mutagen.id3.TXXX(desc='Replaygain_Album_Peak', text='0.978475')) + save_raw(self.filename, tags) + loaded_metadata = load_metadata(self.filename) + loaded_metadata['replaygain_album_peak'] = '1.0' + save_metadata(self.filename, loaded_metadata) + raw_metadata = load_raw(self.filename) + self.assertIn('TXXX:Replaygain_Album_Peak', raw_metadata) + self.assertEqual( + raw_metadata['TXXX:Replaygain_Album_Peak'].text[0], + loaded_metadata['replaygain_album_peak']) + self.assertEqual(1, len(raw_metadata['TXXX:Replaygain_Album_Peak'].text)) + self.assertNotIn('TXXX:REPLAYGAIN_ALBUM_PEAK', raw_metadata) + class MP3Test(CommonId3Tests.Id3TestCase): testfile = 'test.mp3' diff --git a/test/formats/test_mp4.py b/test/formats/test_mp4.py index eaeaf5915..386ee3bb2 100644 --- a/test/formats/test_mp4.py +++ b/test/formats/test_mp4.py @@ -1,8 +1,14 @@ +import mutagen + from picard.formats import ext_to_format from .common import ( CommonTests, load_metadata, + load_raw, + save_metadata, + save_raw, + skipUnlessTestfile, ) from .coverart import CommonCoverArtTests @@ -27,11 +33,46 @@ class MP4Test(CommonTests.TagFormatsTestCase): self.assertTrue(fmt.supports_tag('discnumber')) self.assertTrue(fmt.supports_tag('lyrics:lead')) self.assertTrue(fmt.supports_tag('~length')) + for tag in self.replaygain_tags.keys(): + self.assertTrue(fmt.supports_tag(tag)) def test_format(self): metadata = load_metadata(self.filename) self.assertIn('AAC LC', metadata['~format']) + @skipUnlessTestfile + def test_replaygain_tags_case_insensitive(self): + tags = mutagen.mp4.MP4Tags() + tags['----:com.apple.iTunes:replaygain_album_gain'] = [b'-6.48 dB'] + tags['----:com.apple.iTunes:Replaygain_Album_Peak'] = [b'0.978475'] + tags['----:com.apple.iTunes:replaygain_album_range'] = [b'7.84 dB'] + tags['----:com.apple.iTunes:replaygain_track_gain'] = [b'-6.16 dB'] + tags['----:com.apple.iTunes:REPLAYGAIN_track_peak'] = [b'0.976991'] + tags['----:com.apple.iTunes:REPLAYGAIN_TRACK_RANGE'] = [b'8.22 dB'] + tags['----:com.apple.iTunes:replaygain_reference_loudness'] = [b'-18.00 LUFS'] + save_raw(self.filename, tags) + loaded_metadata = load_metadata(self.filename) + for (key, value) in self.replaygain_tags.items(): + self.assertEqual(loaded_metadata[key], value, '%s: %r != %r' % (key, loaded_metadata[key], value)) + + @skipUnlessTestfile + def test_ci_tags_preserve_case(self): + # Ensure values are not duplicated on repeated save and are saved + # case preserving. + tags = mutagen.mp4.MP4Tags() + tags['----:com.apple.iTunes:Replaygain_Album_Peak'] = [b'-6.48 dB'] + save_raw(self.filename, tags) + loaded_metadata = load_metadata(self.filename) + loaded_metadata['replaygain_album_peak'] = '1.0' + save_metadata(self.filename, loaded_metadata) + raw_metadata = load_raw(self.filename) + self.assertIn('----:com.apple.iTunes:Replaygain_Album_Peak', raw_metadata) + self.assertEqual( + raw_metadata['----:com.apple.iTunes:Replaygain_Album_Peak'][0].decode('utf-8'), + loaded_metadata['replaygain_album_peak']) + self.assertEqual(1, len(raw_metadata['----:com.apple.iTunes:Replaygain_Album_Peak'])) + self.assertNotIn('----:com.apple.iTunes:REPLAYGAIN_ALBUM_PEAK', raw_metadata) + class Mp4CoverArtTest(CommonCoverArtTests.CoverArtTestCase): testfile = 'test.m4a' diff --git a/test/formats/test_mutagenext.py b/test/formats/test_mutagenext.py new file mode 100644 index 000000000..73f966579 --- /dev/null +++ b/test/formats/test_mutagenext.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +from test.picardtestcase import PicardTestCase + +from picard.formats import mutagenext + + +class MutagenExtTest(PicardTestCase): + + def test_delall_ci(self): + tags = { + 'TAGNAME:ABC': 'a', + 'tagname:abc': 'a', + 'TagName:Abc': 'a', + 'OtherTag': 'a' + } + mutagenext.delall_ci(tags, 'tagname:Abc') + self.assertEqual({'OtherTag': 'a'}, tags) diff --git a/test/formats/test_vorbis.py b/test/formats/test_vorbis.py index be0b4c209..a98dccb0b 100644 --- a/test/formats/test_vorbis.py +++ b/test/formats/test_vorbis.py @@ -11,6 +11,7 @@ from picard.coverart.image import CoverArtImage from picard.formats import vorbis from .common import ( + REPLAYGAIN_TAGS, TAGS, CommonTests, load_metadata, @@ -56,6 +57,15 @@ class CommonVorbisTests: for key in INVALID_KEYS + ['']: self.assertFalse(supports_tag(key), '%r should be unsupported' % key) + @skipUnlessTestfile + def test_r128_replaygain_tags(self): + # Vorbis files other then Opus must not support the r128_* tags + tags = { + 'r128_album_gain': '-2857', + 'r128_track_gain': '-2857', + } + self._test_unsupported_tags(tags) + class FLACTest(CommonVorbisTests.VorbisTestCase): testfile = 'test.flac' @@ -103,6 +113,20 @@ class OggOpusTest(CommonVorbisTests.VorbisTestCase): '~channels': '2', } + @skipUnlessTestfile + def test_replaygain_tags(self): + # The normal replaygain tags are not supported by Opus + tags = REPLAYGAIN_TAGS.copy() + self._test_unsupported_tags(tags) + + @skipUnlessTestfile + def test_r128_replaygain_tags(self): + tags = { + 'r128_album_gain': '-2857', + 'r128_track_gain': '-2857', + } + self._test_supported_tags(tags) + class VorbisUtilTest(PicardTestCase): def test_sanitize_key(self):