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