1# -*- coding: utf-8 -*- 2# 3# Picard, the next-generation MusicBrainz tagger 4# 5# Copyright (C) 2007 Lukáš Lalinský 6# Copyright (C) 2012-2013, 2017 Wieland Hoffmann 7# Copyright (C) 2013 Michael Wiencek 8# Copyright (C) 2016-2017 Sambhav Kothari 9# Copyright (C) 2018 Laurent Monin 10# Copyright (C) 2018-2020 Philipp Wolfer 11# 12# This program is free software; you can redistribute it and/or 13# modify it under the terms of the GNU General Public License 14# as published by the Free Software Foundation; either version 2 15# of the License, or (at your option) any later version. 16# 17# This program is distributed in the hope that it will be useful, 18# but WITHOUT ANY WARRANTY; without even the implied warranty of 19# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 20# GNU General Public License for more details. 21# 22# You should have received a copy of the GNU General Public License 23# along with this program; if not, write to the Free Software 24# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 25 26from collections.abc import MutableMapping 27 28import mutagen 29 30from picard import log 31from picard.config import get_config 32from picard.file import File 33from picard.formats.id3 import NonCompatID3File 34from picard.metadata import Metadata 35 36 37try: 38 import mutagen.wave 39 from mutagen._iff import assert_valid_chunk_id 40 from mutagen._riff import RiffFile 41 from mutagen._util import loadfile 42 43 # See https://exiftool.org/TagNames/RIFF.html 44 TRANSLATE_RIFF_INFO = { 45 # Minimal, as e.g. supported by Windows Explorer, 46 # Audacity and foobar2000 47 'IART': 'artist', 48 'ICMT': 'comment', 49 'ICOP': 'copyright', 50 'ICRD': 'date', 51 'IGNR': 'genre', 52 'INAM': 'title', 53 'IPRD': 'album', 54 'ITRK': 'tracknumber', 55 56 # Extended, not well supported by other tools 57 'ICNT': 'releasecountry', 58 'IENC': 'encodedby', 59 'IENG': 'engineer', 60 'ILNG': 'language', 61 'IMED': 'media', 62 'IMUS': 'composer', 63 'IPRO': 'producer', 64 'IWRI': 'writer', 65 } 66 67 R_TRANSLATE_RIFF_INFO = dict([(v, k) for k, v in TRANSLATE_RIFF_INFO.items()]) 68 69 def translate_tag_to_riff_name(name): 70 if name.startswith('comment:'): 71 name = 'comment' 72 return R_TRANSLATE_RIFF_INFO.get(name, None) 73 74 class RiffListInfo(MutableMapping): 75 """Allows loading / saving RIFF INFO tags from / to RIFF files. 76 """ 77 78 def __init__(self, encoding='windows-1252'): 79 self.encoding = encoding 80 self.__tags = {} 81 self.__deleted_tags = set() 82 83 @loadfile() 84 def load(self, filething): 85 """Load the INFO tags from the file.""" 86 riff_file = RiffFile(filething.fileobj) 87 info = self.__find_info_chunk(riff_file.root) 88 if info: 89 for tag in info.subchunks(): 90 self.__tags[tag.id] = self.__decode_data(tag.read()) 91 92 @loadfile(writable=True) 93 def save(self, filething): 94 """Save the INFO tags to the file.""" 95 riff_file = RiffFile(filething.fileobj) 96 info = self.__find_info_chunk(riff_file.root) 97 if not info: 98 info = riff_file.insert_chunk('LIST', b'INFO') 99 for name, value in self.__tags.items(): 100 self.__save_tag_data(info, name, value) 101 for name in self.__deleted_tags: 102 self.__delete_tag(info, name) 103 104 @loadfile(writable=True) 105 def delete(self, filething): 106 """Deletes the INFO chunk completely from the file.""" 107 riff_file = RiffFile(filething.fileobj) 108 info = self.__find_info_chunk(riff_file.root) 109 if info: 110 info.delete() 111 112 @staticmethod 113 def __find_info_chunk(parent): 114 for chunk in parent.subchunks(): 115 if chunk.id == 'LIST' and chunk.name == 'INFO': 116 return chunk 117 return None 118 119 @staticmethod 120 def __find_subchunk(parent, name): 121 for chunk in parent.subchunks(): 122 if chunk.id == name: 123 return chunk 124 return None 125 126 def __save_tag_data(self, info, name, value): 127 data = self.__encode_data(value) 128 chunk = self.__find_subchunk(info, name) 129 if chunk: 130 chunk.resize(len(data)) 131 chunk.write(data) 132 return chunk 133 else: 134 return info.insert_chunk(name, data) 135 136 def __delete_tag(self, info, name): 137 chunk = self.__find_subchunk(info, name) 138 if chunk: 139 chunk.delete() 140 141 @staticmethod 142 def __decode_data(value): 143 try: # Always try first to decode as Unicode 144 value = value.decode('utf-8') 145 except UnicodeDecodeError: # Fall back to Windows-1252 encoding 146 value = value.decode('windows-1252', errors='replace') 147 return value.rstrip('\0') 148 149 def __encode_data(self, value): 150 return value.encode(self.encoding, errors='replace') + b'\x00' 151 152 def __contains__(self, name): 153 return self.__tags.__contains__(name) 154 155 def __getitem__(self, key): 156 return self.__tags.get(key) 157 158 def __setitem__(self, key, value): 159 assert_valid_chunk_id(key) 160 self.__tags[key] = value 161 self.__deleted_tags.discard(key) 162 163 def __delitem__(self, key): 164 if key in self.__tags: 165 del self.__tags[key] 166 self.__deleted_tags.add(key) 167 168 def __iter__(self): 169 return iter(self.__tags) 170 171 def __len__(self): 172 return len(self.__tags) 173 174 def __repr__(self): 175 return repr(self.__tags) 176 177 def __str__(self): 178 return str(self.__tags) 179 180 class WAVFile(NonCompatID3File): 181 EXTENSIONS = [".wav"] 182 NAME = "Microsoft WAVE" 183 _File = mutagen.wave.WAVE 184 185 def _info(self, metadata, file): 186 super()._info(metadata, file) 187 metadata['~format'] = self.NAME 188 config = get_config() 189 info = RiffListInfo(encoding=config.setting['wave_riff_info_encoding']) 190 info.load(file.filename) 191 for tag, value in info.items(): 192 if tag in TRANSLATE_RIFF_INFO: 193 name = TRANSLATE_RIFF_INFO[tag] 194 if name not in metadata: 195 metadata[name] = value 196 197 def _save(self, filename, metadata): 198 super()._save(filename, metadata) 199 200 # Save RIFF LIST INFO 201 config = get_config() 202 if config.setting['write_wave_riff_info']: 203 info = RiffListInfo(encoding=config.setting['wave_riff_info_encoding']) 204 if config.setting['clear_existing_tags']: 205 info.delete(filename) 206 for name, values in metadata.rawitems(): 207 name = translate_tag_to_riff_name(name) 208 if name: 209 value = ", ".join(values) 210 info[name] = value 211 for name in metadata.deleted_tags: 212 name = translate_tag_to_riff_name(name) 213 if name: 214 del info[name] 215 info.save(filename) 216 elif config.setting['remove_wave_riff_info']: 217 info = RiffListInfo(encoding=config.setting['wave_riff_info_encoding']) 218 info.delete(filename) 219 220except ImportError: 221 import wave 222 223 class WAVFile(File): 224 EXTENSIONS = [".wav"] 225 NAME = "Microsoft WAVE" 226 _File = None 227 228 def _load(self, filename): 229 log.debug("Loading file %r", filename) 230 f = wave.open(filename, "rb") 231 metadata = Metadata() 232 metadata['~channels'] = f.getnchannels() 233 metadata['~bits_per_sample'] = f.getsampwidth() * 8 234 metadata['~sample_rate'] = f.getframerate() 235 metadata.length = 1000 * f.getnframes() // f.getframerate() 236 metadata['~format'] = self.NAME 237 self._add_path_to_metadata(metadata) 238 return metadata 239 240 def _save(self, filename, metadata): 241 log.debug("Saving file %r", filename) 242 243 @classmethod 244 def supports_tag(cls, name): 245 return False 246