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