1# -*- coding: utf-8 -*- 2# 3# Picard, the next-generation MusicBrainz tagger 4# 5# Copyright (C) 2006-2008, 2012 Lukáš Lalinský 6# Copyright (C) 2008 Hendrik van Antwerpen 7# Copyright (C) 2008-2010, 2014-2015, 2018-2020 Philipp Wolfer 8# Copyright (C) 2012-2013 Michael Wiencek 9# Copyright (C) 2012-2014 Wieland Hoffmann 10# Copyright (C) 2013 Calvin Walton 11# Copyright (C) 2013-2014, 2017-2019 Laurent Monin 12# Copyright (C) 2016-2018 Sambhav Kothari 13# Copyright (C) 2017 Ville Skyttä 14# 15# This program is free software; you can redistribute it and/or 16# modify it under the terms of the GNU General Public License 17# as published by the Free Software Foundation; either version 2 18# of the License, or (at your option) any later version. 19# 20# This program is distributed in the hope that it will be useful, 21# but WITHOUT ANY WARRANTY; without even the implied warranty of 22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 23# GNU General Public License for more details. 24# 25# You should have received a copy of the GNU General Public License 26# along with this program; if not, write to the Free Software 27# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 28 29 30import base64 31import re 32 33import mutagen.flac 34import mutagen.ogg 35import mutagen.oggflac 36import mutagen.oggopus 37import mutagen.oggspeex 38import mutagen.oggtheora 39import mutagen.oggvorbis 40 41from picard import log 42from picard.config import get_config 43from picard.coverart.image import ( 44 CoverArtImageError, 45 TagCoverArtImage, 46) 47from picard.file import File 48from picard.formats.id3 import ( 49 image_type_as_id3_num, 50 types_from_id3, 51) 52from picard.formats.util import guess_format 53from picard.metadata import Metadata 54from picard.util import ( 55 encode_filename, 56 sanitize_date, 57) 58 59 60FLAC_MAX_BLOCK_SIZE = 2 ** 24 - 1 # FLAC block size is limited to a 24 bit integer 61INVALID_CHARS = re.compile('([^\x20-\x7d]|=)') 62 63 64def sanitize_key(key): 65 """ 66 Remove characters from key which are invalid for a Vorbis comment field name. 67 See https://www.xiph.org/vorbis/doc/v-comment.html#vectorformat 68 """ 69 return INVALID_CHARS.sub('', key) 70 71 72def is_valid_key(key): 73 """ 74 Return true if a string is a valid Vorbis comment key. 75 Valid characters for Vorbis comment field names are 76 ASCII 0x20 through 0x7D, 0x3D ('=') excluded. 77 """ 78 return INVALID_CHARS.search(key) is None 79 80 81def flac_sort_pics_after_tags(metadata_blocks): 82 """ 83 Reorder the metadata_blocks so that all picture blocks are located after 84 the first Vorbis comment block. 85 86 Windows fails to read FLAC tags if the picture blocks are located before 87 the Vorbis comments. Reordering the blocks fixes this. 88 """ 89 # First remember all picture blocks that are located before the tag block. 90 tagindex = 0 91 picblocks = [] 92 for block in metadata_blocks: 93 if block.code == mutagen.flac.VCFLACDict.code: 94 tagindex = metadata_blocks.index(block) 95 break 96 elif block.code == mutagen.flac.Picture.code: 97 picblocks.append(block) 98 else: 99 return # No tags found, nothing to sort 100 101 # Now move those picture block after the tag block, maintaining their order. 102 for pic in picblocks: 103 metadata_blocks.remove(pic) 104 metadata_blocks.insert(tagindex, pic) 105 106 107class VCommentFile(File): 108 109 """Generic VComment-based file.""" 110 _File = None 111 112 __translate = { 113 "movement": "movementnumber", 114 "movementname": "movement", 115 "musicbrainz_releasetrackid": "musicbrainz_trackid", 116 "musicbrainz_trackid": "musicbrainz_recordingid", 117 "waveformatextensible_channel_mask": "~waveformatextensible_channel_mask", 118 } 119 __rtranslate = dict([(v, k) for k, v in __translate.items()]) 120 121 def _load(self, filename): 122 log.debug("Loading file %r", filename) 123 config = get_config() 124 file = self._File(encode_filename(filename)) 125 file.tags = file.tags or {} 126 metadata = Metadata() 127 for origname, values in file.tags.items(): 128 for value in values: 129 name = origname 130 if name == "date" or name == "originaldate": 131 # YYYY-00-00 => YYYY 132 value = sanitize_date(value) 133 elif name == 'performer' or name == 'comment': 134 # transform "performer=Joe Barr (Piano)" to "performer:Piano=Joe Barr" 135 name += ':' 136 if value.endswith(')'): 137 start = len(value) - 2 138 count = 1 139 while count > 0 and start > 0: 140 if value[start] == ')': 141 count += 1 142 elif value[start] == '(': 143 count -= 1 144 start -= 1 145 if start > 0: 146 name += value[start + 2:-1] 147 value = value[:start] 148 elif name.startswith('rating'): 149 try: 150 name, email = name.split(':', 1) 151 except ValueError: 152 email = '' 153 if email != sanitize_key(config.setting['rating_user_email']): 154 continue 155 name = '~rating' 156 try: 157 value = str(round((float(value) * (config.setting['rating_steps'] - 1)))) 158 except ValueError: 159 log.warning('Invalid rating value in %r: %s', filename, value) 160 elif name == "fingerprint" and value.startswith("MusicMagic Fingerprint"): 161 name = "musicip_fingerprint" 162 value = value[22:] 163 elif name == "tracktotal": 164 if "totaltracks" in file.tags: 165 continue 166 name = "totaltracks" 167 elif name == "disctotal": 168 if "totaldiscs" in file.tags: 169 continue 170 name = "totaldiscs" 171 elif name == "metadata_block_picture": 172 try: 173 image = mutagen.flac.Picture(base64.standard_b64decode(value)) 174 coverartimage = TagCoverArtImage( 175 file=filename, 176 tag=name, 177 types=types_from_id3(image.type), 178 comment=image.desc, 179 support_types=True, 180 data=image.data, 181 ) 182 except (CoverArtImageError, TypeError, ValueError, mutagen.flac.error) as e: 183 log.error('Cannot load image from %r: %s' % (filename, e)) 184 else: 185 metadata.images.append(coverartimage) 186 continue 187 elif name in self.__translate: 188 name = self.__translate[name] 189 metadata.add(name, value) 190 if self._File == mutagen.flac.FLAC: 191 for image in file.pictures: 192 try: 193 coverartimage = TagCoverArtImage( 194 file=filename, 195 tag='FLAC/PICTURE', 196 types=types_from_id3(image.type), 197 comment=image.desc, 198 support_types=True, 199 data=image.data, 200 ) 201 except CoverArtImageError as e: 202 log.error('Cannot load image from %r: %s' % (filename, e)) 203 else: 204 metadata.images.append(coverartimage) 205 206 # Read the unofficial COVERART tags, for backward compatibility only 207 if "metadata_block_picture" not in file.tags: 208 try: 209 for data in file["COVERART"]: 210 try: 211 coverartimage = TagCoverArtImage( 212 file=filename, 213 tag='COVERART', 214 data=base64.standard_b64decode(data) 215 ) 216 except (CoverArtImageError, TypeError, ValueError) as e: 217 log.error('Cannot load image from %r: %s' % (filename, e)) 218 else: 219 metadata.images.append(coverartimage) 220 except KeyError: 221 pass 222 self._info(metadata, file) 223 return metadata 224 225 def _save(self, filename, metadata): 226 """Save metadata to the file.""" 227 log.debug("Saving file %r", filename) 228 config = get_config() 229 is_flac = self._File == mutagen.flac.FLAC 230 file = self._File(encode_filename(filename)) 231 if file.tags is None: 232 file.add_tags() 233 if config.setting["clear_existing_tags"]: 234 channel_mask = file.tags.get('waveformatextensible_channel_mask', None) 235 file.tags.clear() 236 if channel_mask: 237 file.tags['waveformatextensible_channel_mask'] = channel_mask 238 images_to_save = list(metadata.images.to_be_saved_to_tags()) 239 if is_flac and (config.setting["clear_existing_tags"] or images_to_save): 240 file.clear_pictures() 241 tags = {} 242 for name, value in metadata.items(): 243 if name == '~rating': 244 # Save rating according to http://code.google.com/p/quodlibet/wiki/Specs_VorbisComments 245 user_email = sanitize_key(config.setting['rating_user_email']) 246 if user_email: 247 name = 'rating:%s' % user_email 248 else: 249 name = 'rating' 250 value = str(float(value) / (config.setting['rating_steps'] - 1)) 251 # don't save private tags 252 elif name.startswith("~") or not self.supports_tag(name): 253 continue 254 elif name.startswith('lyrics:'): 255 name = 'lyrics' 256 elif name == "date" or name == "originaldate": 257 # YYYY-00-00 => YYYY 258 value = sanitize_date(value) 259 elif name.startswith('performer:') or name.startswith('comment:'): 260 # transform "performer:Piano=Joe Barr" to "performer=Joe Barr (Piano)" 261 name, desc = name.split(':', 1) 262 if desc: 263 value += ' (%s)' % desc 264 elif name == "musicip_fingerprint": 265 name = "fingerprint" 266 value = "MusicMagic Fingerprint%s" % value 267 elif name in self.__rtranslate: 268 name = self.__rtranslate[name] 269 tags.setdefault(name.upper(), []).append(value) 270 271 if "totaltracks" in metadata: 272 tags.setdefault("TRACKTOTAL", []).append(metadata["totaltracks"]) 273 if "totaldiscs" in metadata: 274 tags.setdefault("DISCTOTAL", []).append(metadata["totaldiscs"]) 275 276 for image in images_to_save: 277 picture = mutagen.flac.Picture() 278 picture.data = image.data 279 picture.mime = image.mimetype 280 picture.desc = image.comment 281 picture.width = image.width 282 picture.height = image.height 283 picture.type = image_type_as_id3_num(image.maintype) 284 if is_flac: 285 # See https://xiph.org/flac/format.html#metadata_block_picture 286 expected_block_size = (8 * 4 + len(picture.data) 287 + len(picture.mime) 288 + len(picture.desc.encode('UTF-8'))) 289 if expected_block_size > FLAC_MAX_BLOCK_SIZE: 290 log.error('Failed saving image to %r: Image size of %d bytes exceeds maximum FLAC block size of %d bytes', 291 filename, expected_block_size, FLAC_MAX_BLOCK_SIZE) 292 continue 293 file.add_picture(picture) 294 else: 295 tags.setdefault("METADATA_BLOCK_PICTURE", []).append( 296 base64.b64encode(picture.write()).decode('ascii')) 297 298 file.tags.update(tags) 299 300 self._remove_deleted_tags(metadata, file.tags) 301 302 if is_flac: 303 flac_sort_pics_after_tags(file.metadata_blocks) 304 305 kwargs = {} 306 if is_flac and config.setting["remove_id3_from_flac"]: 307 kwargs["deleteid3"] = True 308 try: 309 file.save(**kwargs) 310 except TypeError: 311 file.save() 312 313 def _remove_deleted_tags(self, metadata, tags): 314 """Remove the tags from the file that were deleted in the UI""" 315 for tag in metadata.deleted_tags: 316 real_name = self._get_tag_name(tag) 317 if real_name and real_name in tags: 318 if real_name in ('performer', 'comment'): 319 parts = tag.split(':', 1) 320 if len(parts) == 2: 321 tag_type_regex = re.compile(r"\(%s\)$" % re.escape(parts[1])) 322 else: 323 tag_type_regex = re.compile(r"[^)]$") 324 existing_tags = tags.get(real_name) 325 for item in existing_tags: 326 if re.search(tag_type_regex, item): 327 existing_tags.remove(item) 328 tags[real_name] = existing_tags 329 else: 330 if tag in ('totaldiscs', 'totaltracks') and tag in tags: 331 # both tag and real_name are to be deleted in this case 332 del tags[tag] 333 del tags[real_name] 334 335 def _get_tag_name(self, name): 336 if name == '~rating': 337 config = get_config() 338 if config.setting['rating_user_email']: 339 return 'rating:%s' % config.setting['rating_user_email'] 340 else: 341 return 'rating' 342 elif name.startswith("~"): 343 return None 344 elif name.startswith('lyrics:'): 345 return 'lyrics' 346 elif name.startswith('performer:') or name.startswith('comment:'): 347 return name.split(':', 1)[0] 348 elif name == 'musicip_fingerprint': 349 return 'fingerprint' 350 elif name == 'totaltracks': 351 return 'tracktotal' 352 elif name == 'totaldiscs': 353 return 'disctotal' 354 elif name in self.__rtranslate: 355 return self.__rtranslate[name] 356 else: 357 return name 358 359 @classmethod 360 def supports_tag(cls, name): 361 unsupported_tags = ['r128_album_gain', 'r128_track_gain'] 362 return (bool(name) and name not in unsupported_tags 363 and (is_valid_key(name) 364 or name.startswith('comment:') 365 or name.startswith('lyrics:') 366 or name.startswith('performer:'))) 367 368 369class FLACFile(VCommentFile): 370 371 """FLAC file.""" 372 EXTENSIONS = [".flac"] 373 NAME = "FLAC" 374 _File = mutagen.flac.FLAC 375 376 377class OggFLACFile(VCommentFile): 378 379 """FLAC file.""" 380 EXTENSIONS = [".oggflac"] 381 NAME = "Ogg FLAC" 382 _File = mutagen.oggflac.OggFLAC 383 384 385class OggSpeexFile(VCommentFile): 386 387 """Ogg Speex file.""" 388 EXTENSIONS = [".spx"] 389 NAME = "Speex" 390 _File = mutagen.oggspeex.OggSpeex 391 392 393class OggTheoraFile(VCommentFile): 394 395 """Ogg Theora file.""" 396 EXTENSIONS = [".oggtheora"] 397 NAME = "Ogg Theora" 398 _File = mutagen.oggtheora.OggTheora 399 400 def _info(self, metadata, file): 401 super()._info(metadata, file) 402 metadata['~video'] = '1' 403 404 405class OggVorbisFile(VCommentFile): 406 407 """Ogg Vorbis file.""" 408 EXTENSIONS = [] 409 NAME = "Ogg Vorbis" 410 _File = mutagen.oggvorbis.OggVorbis 411 412 413class OggOpusFile(VCommentFile): 414 415 """Ogg Opus file.""" 416 EXTENSIONS = [".opus"] 417 NAME = "Ogg Opus" 418 _File = mutagen.oggopus.OggOpus 419 420 @classmethod 421 def supports_tag(cls, name): 422 if name.startswith('r128_'): 423 return True 424 return VCommentFile.supports_tag(name) 425 426 427def OggAudioFile(filename): 428 """Generic Ogg audio file.""" 429 options = [OggFLACFile, OggOpusFile, OggSpeexFile, OggVorbisFile] 430 return guess_format(filename, options) 431 432 433OggAudioFile.EXTENSIONS = [".oga"] 434OggAudioFile.NAME = "Ogg Audio" 435OggAudioFile.supports_tag = VCommentFile.supports_tag 436 437 438def OggVideoFile(filename): 439 """Generic Ogg video file.""" 440 options = [OggTheoraFile] 441 return guess_format(filename, options) 442 443 444OggVideoFile.EXTENSIONS = [".ogv"] 445OggVideoFile.NAME = "Ogg Video" 446OggVideoFile.supports_tag = VCommentFile.supports_tag 447 448 449def OggContainerFile(filename): 450 """Generic Ogg file.""" 451 options = [ 452 OggFLACFile, 453 OggOpusFile, 454 OggSpeexFile, 455 OggTheoraFile, 456 OggVorbisFile 457 ] 458 return guess_format(filename, options) 459 460 461OggContainerFile.EXTENSIONS = [".ogg"] 462OggContainerFile.NAME = "Ogg" 463OggContainerFile.supports_tag = VCommentFile.supports_tag 464