1# -*- coding: utf-8 -*- 2# 3# Picard, the next-generation MusicBrainz tagger 4# 5# Copyright (C) 2006-2009, 2011 Lukáš Lalinský 6# Copyright (C) 2009-2011, 2018-2020 Philipp Wolfer 7# Copyright (C) 2011-2014 Wieland Hoffmann 8# Copyright (C) 2012-2013 Michael Wiencek 9# Copyright (C) 2013 Calvin Walton 10# Copyright (C) 2013-2015, 2018-2019 Laurent Monin 11# Copyright (C) 2016-2018 Sambhav Kothari 12# Copyright (C) 2017 Ville Skyttä 13# 14# This program is free software; you can redistribute it and/or 15# modify it under the terms of the GNU General Public License 16# as published by the Free Software Foundation; either version 2 17# of the License, or (at your option) any later version. 18# 19# This program is distributed in the hope that it will be useful, 20# but WITHOUT ANY WARRANTY; without even the implied warranty of 21# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 22# GNU General Public License for more details. 23# 24# You should have received a copy of the GNU General Public License 25# along with this program; if not, write to the Free Software 26# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 27 28 29from __future__ import absolute_import 30 31from os.path import isfile 32import re 33 34import mutagen.apev2 35import mutagen.monkeysaudio 36import mutagen.musepack 37import mutagen.optimfrog 38import mutagen.wavpack 39 40from picard import log 41from picard.config import get_config 42from picard.coverart.image import ( 43 CoverArtImageError, 44 TagCoverArtImage, 45) 46from picard.file import File 47from picard.metadata import Metadata 48from picard.util import ( 49 encode_filename, 50 sanitize_date, 51) 52 53from .mutagenext import ( 54 aac, 55 tak, 56) 57 58 59INVALID_CHARS = re.compile('[^\x20-\x7e]') 60DISALLOWED_KEYS = ['ID3', 'TAG', 'OggS', 'MP+'] 61UNSUPPORTED_TAGS = [ 62 'gapless', 63 'musicip_fingerprint', 64 'podcast', 65 'podcasturl', 66 'show', 67 'showsort', 68 'r128_album_gain', 69 'r128_track_gain', 70] 71 72 73def is_valid_key(key): 74 """ 75 Return true if a string is a valid APE tag key. 76 APE tag item keys can have a length of 2 (including) up to 255 (including) 77 characters in the range from 0x20 (Space) until 0x7E (Tilde). 78 Not allowed are the following keys: ID3, TAG, OggS and MP+. 79 80 See http://wiki.hydrogenaud.io/index.php?title=APE_key 81 """ 82 return (key and 2 <= len(key) <= 255 83 and key not in DISALLOWED_KEYS 84 and INVALID_CHARS.search(key) is None) 85 86 87class APEv2File(File): 88 89 """Generic APEv2-based file.""" 90 _File = None 91 92 __translate = { 93 "albumartist": "Album Artist", 94 "remixer": "MixArtist", 95 "director": "Director", 96 "website": "Weblink", 97 "discsubtitle": "DiscSubtitle", 98 "bpm": "BPM", 99 "isrc": "ISRC", 100 "catalognumber": "CatalogNumber", 101 "barcode": "Barcode", 102 "encodedby": "EncodedBy", 103 "language": "Language", 104 "movementnumber": "MOVEMENT", 105 "movement": "MOVEMENTNAME", 106 "movementtotal": "MOVEMENTTOTAL", 107 "showmovement": "SHOWMOVEMENT", 108 "releasestatus": "MUSICBRAINZ_ALBUMSTATUS", 109 "releasetype": "MUSICBRAINZ_ALBUMTYPE", 110 "musicbrainz_recordingid": "musicbrainz_trackid", 111 "musicbrainz_trackid": "musicbrainz_releasetrackid", 112 "originalartist": "Original Artist", 113 "replaygain_album_gain": "REPLAYGAIN_ALBUM_GAIN", 114 "replaygain_album_peak": "REPLAYGAIN_ALBUM_PEAK", 115 "replaygain_album_range": "REPLAYGAIN_ALBUM_RANGE", 116 "replaygain_track_gain": "REPLAYGAIN_TRACK_GAIN", 117 "replaygain_track_peak": "REPLAYGAIN_TRACK_PEAK", 118 "replaygain_track_range": "REPLAYGAIN_TRACK_RANGE", 119 "replaygain_reference_loudness": "REPLAYGAIN_REFERENCE_LOUDNESS", 120 } 121 __rtranslate = dict([(v.lower(), k) for k, v in __translate.items()]) 122 123 def __init__(self, filename): 124 super().__init__(filename) 125 self.__casemap = {} 126 127 def _load(self, filename): 128 log.debug("Loading file %r", filename) 129 self.__casemap = {} 130 file = self._File(encode_filename(filename)) 131 metadata = Metadata() 132 if file.tags: 133 for origname, values in file.tags.items(): 134 name_lower = origname.lower() 135 if (values.kind == mutagen.apev2.BINARY 136 and name_lower.startswith("cover art")): 137 if b'\0' in values.value: 138 descr, data = values.value.split(b'\0', 1) 139 try: 140 coverartimage = TagCoverArtImage( 141 file=filename, 142 tag=name_lower, 143 data=data, 144 ) 145 except CoverArtImageError as e: 146 log.error('Cannot load image from %r: %s' % 147 (filename, e)) 148 else: 149 metadata.images.append(coverartimage) 150 151 # skip EXTERNAL and BINARY values 152 if values.kind != mutagen.apev2.TEXT: 153 continue 154 for value in values: 155 name = name_lower 156 if name == "year": 157 name = "date" 158 value = sanitize_date(value) 159 elif name == "track": 160 name = "tracknumber" 161 track = value.split("/") 162 if len(track) > 1: 163 metadata["totaltracks"] = track[1] 164 value = track[0] 165 elif name == "disc": 166 name = "discnumber" 167 disc = value.split("/") 168 if len(disc) > 1: 169 metadata["totaldiscs"] = disc[1] 170 value = disc[0] 171 elif name in ('performer', 'comment'): 172 if value.endswith(')'): 173 start = value.rfind(' (') 174 if start > 0: 175 name += ':' + value[start + 2:-1] 176 value = value[:start] 177 elif name in self.__rtranslate: 178 name = self.__rtranslate[name] 179 self.__casemap[name] = origname 180 metadata.add(name, value) 181 self._info(metadata, file) 182 return metadata 183 184 def _save(self, filename, metadata): 185 """Save metadata to the file.""" 186 log.debug("Saving file %r", filename) 187 config = get_config() 188 try: 189 tags = mutagen.apev2.APEv2(encode_filename(filename)) 190 except mutagen.apev2.APENoHeaderError: 191 tags = mutagen.apev2.APEv2() 192 images_to_save = list(metadata.images.to_be_saved_to_tags()) 193 if config.setting["clear_existing_tags"]: 194 tags.clear() 195 elif images_to_save: 196 for name, value in tags.items(): 197 if (value.kind == mutagen.apev2.BINARY 198 and name.lower().startswith('cover art')): 199 del tags[name] 200 temp = {} 201 for name, value in metadata.items(): 202 if name.startswith("~") or not self.supports_tag(name): 203 continue 204 real_name = self._get_tag_name(name) 205 # tracknumber/totaltracks => Track 206 if name == 'tracknumber': 207 if 'totaltracks' in metadata: 208 value = '%s/%s' % (value, metadata['totaltracks']) 209 # discnumber/totaldiscs => Disc 210 elif name == 'discnumber': 211 if 'totaldiscs' in metadata: 212 value = '%s/%s' % (value, metadata['totaldiscs']) 213 elif name in ('totaltracks', 'totaldiscs'): 214 continue 215 # "performer:Piano=Joe Barr" => "Performer=Joe Barr (Piano)" 216 elif name.startswith('performer:') or name.startswith('comment:'): 217 name, desc = name.split(':', 1) 218 if desc: 219 value += ' (%s)' % desc 220 temp.setdefault(real_name, []).append(value) 221 for name, values in temp.items(): 222 tags[name] = values 223 for image in images_to_save: 224 cover_filename = 'Cover Art (Front)' 225 cover_filename += image.extension 226 tags['Cover Art (Front)'] = mutagen.apev2.APEValue( 227 cover_filename.encode('ascii') + b'\0' + image.data, mutagen.apev2.BINARY) 228 break 229 # can't save more than one item with the same name 230 # (mp3tags does this, but it's against the specs) 231 232 self._remove_deleted_tags(metadata, tags) 233 tags.save(encode_filename(filename)) 234 235 def _remove_deleted_tags(self, metadata, tags): 236 """Remove the tags from the file that were deleted in the UI""" 237 for tag in metadata.deleted_tags: 238 real_name = self._get_tag_name(tag) 239 if real_name in ('Lyrics', 'Comment', 'Performer'): 240 parts = tag.split(':', 1) 241 if len(parts) == 2: 242 tag_type_regex = re.compile(r"\(%s\)$" % re.escape(parts[1])) 243 else: 244 tag_type_regex = re.compile(r"[^)]$") 245 existing_tags = tags.get(real_name, []) 246 for item in existing_tags: 247 if re.search(tag_type_regex, item): 248 existing_tags.remove(item) 249 tags[real_name] = existing_tags 250 elif tag in ('totaltracks', 'totaldiscs'): 251 tagstr = real_name.lower() + 'number' 252 if tagstr in metadata: 253 tags[real_name] = metadata[tagstr] 254 else: 255 if real_name in tags: 256 del tags[real_name] 257 258 def _get_tag_name(self, name): 259 if name in self.__casemap: 260 return self.__casemap[name] 261 elif name.startswith('lyrics:'): 262 return 'Lyrics' 263 elif name == 'date': 264 return 'Year' 265 elif name in ('tracknumber', 'totaltracks'): 266 return 'Track' 267 elif name in ('discnumber', 'totaldiscs'): 268 return 'Disc' 269 elif name.startswith('performer:') or name.startswith('comment:'): 270 return name.split(':', 1)[0].title() 271 elif name in self.__translate: 272 return self.__translate[name] 273 else: 274 return name.title() 275 276 @classmethod 277 def supports_tag(cls, name): 278 return (bool(name) and name not in UNSUPPORTED_TAGS 279 and (is_valid_key(name) 280 or name.startswith('comment:') 281 or name.startswith('lyrics:') 282 or name.startswith('performer:'))) 283 284 285class MusepackFile(APEv2File): 286 287 """Musepack file.""" 288 EXTENSIONS = [".mpc", ".mp+"] 289 NAME = "Musepack" 290 _File = mutagen.musepack.Musepack 291 292 def _info(self, metadata, file): 293 super()._info(metadata, file) 294 metadata['~format'] = "Musepack, SV%d" % file.info.version 295 296 297class WavPackFile(APEv2File): 298 299 """WavPack file.""" 300 EXTENSIONS = [".wv"] 301 NAME = "WavPack" 302 _File = mutagen.wavpack.WavPack 303 304 def _save_and_rename(self, old_filename, metadata): 305 """Includes an additional check for WavPack correction files""" 306 wvc_filename = old_filename.replace(".wv", ".wvc") 307 if isfile(wvc_filename): 308 config = get_config() 309 if config.setting["rename_files"] or config.setting["move_files"]: 310 self._rename(wvc_filename, metadata) 311 return File._save_and_rename(self, old_filename, metadata) 312 313 314class OptimFROGFile(APEv2File): 315 316 """OptimFROG file.""" 317 EXTENSIONS = [".ofr", ".ofs"] 318 NAME = "OptimFROG" 319 _File = mutagen.optimfrog.OptimFROG 320 321 def _info(self, metadata, file): 322 super()._info(metadata, file) 323 # mutagen.File.filename can be either a bytes or str object 324 filename = file.filename 325 if isinstance(filename, bytes): 326 filename = filename.decode() 327 if filename.lower().endswith(".ofs"): 328 metadata['~format'] = "OptimFROG DualStream Audio" 329 else: 330 metadata['~format'] = "OptimFROG Lossless Audio" 331 332 333class MonkeysAudioFile(APEv2File): 334 335 """Monkey's Audio file.""" 336 EXTENSIONS = [".ape"] 337 NAME = "Monkey's Audio" 338 _File = mutagen.monkeysaudio.MonkeysAudio 339 340 341class TAKFile(APEv2File): 342 343 """TAK file.""" 344 EXTENSIONS = [".tak"] 345 NAME = "Tom's lossless Audio Kompressor" 346 _File = tak.TAK 347 348 349class AACFile(APEv2File): 350 EXTENSIONS = [".aac"] 351 NAME = "AAC" 352 _File = aac.AACAPEv2 353 354 def _info(self, metadata, file): 355 super()._info(metadata, file) 356 if file.tags: 357 metadata['~format'] = "%s (APEv2)" % self.NAME 358 359 def _save(self, filename, metadata): 360 config = get_config() 361 if config.setting['aac_save_ape']: 362 super()._save(filename, metadata) 363 elif config.setting['remove_ape_from_aac']: 364 try: 365 mutagen.apev2.delete(encode_filename(filename)) 366 except BaseException: 367 log.exception('Error removing APEv2 tags from %s', filename) 368 369 @classmethod 370 def supports_tag(cls, name): 371 config = get_config() 372 if config.setting['aac_save_ape']: 373 return APEv2File.supports_tag(name) 374 else: 375 return False 376