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