1#!/usr/bin/python
2
3# Audio Tools, a module and set of tools for manipulating audio data
4# Copyright (C) 2007-2014  Brian Langenberger
5
6# This program is free software; you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation; either version 2 of the License, or
9# (at your option) any later version.
10
11# This program is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15
16# You should have received a copy of the GNU General Public License
17# along with this program; if not, write to the Free Software
18# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
19
20from audiotools import MetaData
21
22
23class ID3v1Comment(MetaData):
24    """a complete ID3v1.1 tag"""
25
26    ID3v1_FIELDS = {"track_name": "__track_name__",
27                    "artist_name": "__artist_name__",
28                    "album_name": "__album_name__",
29                    "year": "__year__",
30                    "comment": "__comment__",
31                    "track_number": "__track_number__"}
32
33    FIELD_LENGTHS = {"track_name": 30,
34                     "artist_name": 30,
35                     "album_name": 30,
36                     "year": 4,
37                     "comment": 28}
38
39    def __init__(self, track_name=u"",
40                 artist_name=u"",
41                 album_name=u"",
42                 year=u"",
43                 comment=u"",
44                 track_number=0,
45                 genre=0):
46        """fields are as follows:
47
48        | field        | length |
49        |--------------+--------|
50        | track_name   |     30 |
51        | artist_name  |     30 |
52        | album_name   |     30 |
53        | year         |      4 |
54        | comment      |     28 |
55        | track_number |      1 |
56        | genre        |      1 |
57        |--------------+--------|
58
59        track_name, artist_name, album_name, year and
60        comment are unicode strings
61
62        track_number and genre are integers
63        """
64
65        if len(track_name) > 30:
66            raise ValueError("track_name cannot be longer than 30 characters")
67        if len(artist_name) > 30:
68            raise ValueError("artist_name cannot be longer than 30 characters")
69        if len(album_name) > 30:
70            raise ValueError("album_name cannot be longer than 30 characters")
71        if len(year) > 4:
72            raise ValueError("year cannot be longer than 4 characters")
73        if len(comment) > 28:
74            raise ValueError("comment cannot be longer than 28 characters")
75
76        MetaData.__setattr__(self, "__track_name__", track_name)
77        MetaData.__setattr__(self, "__artist_name__", artist_name)
78        MetaData.__setattr__(self, "__album_name__", album_name)
79        MetaData.__setattr__(self, "__year__", year)
80        MetaData.__setattr__(self, "__comment__", comment)
81        MetaData.__setattr__(self, "__track_number__", track_number)
82        MetaData.__setattr__(self, "__genre__", genre)
83
84    def __repr__(self):
85        return "ID3v1Comment(%s, %s, %s, %s, %s, %s, %s)" % \
86            (repr(self.__track_name__),
87             repr(self.__artist_name__),
88             repr(self.__album_name__),
89             repr(self.__year__),
90             repr(self.__comment__),
91             repr(self.__track_number__),
92             repr(self.__genre__))
93
94    def __getattr__(self, attr):
95        if attr == "track_number":
96            number = self.__track_number__
97            if number > 0:
98                return number
99            else:
100                return None
101        elif attr in self.ID3v1_FIELDS:
102            value = getattr(self, self.ID3v1_FIELDS[attr])
103            if len(value) > 0:
104                return value
105            else:
106                return None
107        elif attr in self.FIELDS:
108            return None
109        else:
110            return MetaData.__getattribute__(self, attr)
111
112    def __setattr__(self, attr, value):
113        if attr == "track_number":
114            MetaData.__setattr__(
115                self,
116                "__track_number__",
117                min(0 if (value is None) else int(value), 0xFF))
118        elif attr in self.FIELD_LENGTHS:
119            if value is None:
120                delattr(self, attr)
121            else:
122                # all are text fields
123                MetaData.__setattr__(
124                    self,
125                    self.ID3v1_FIELDS[attr],
126                    value[0:self.FIELD_LENGTHS[attr]])
127        elif attr in self.FIELDS:
128            # field not supported by ID3v1Comment, so ignore it
129            pass
130        else:
131            MetaData.__setattr__(self, attr, value)
132
133    def __delattr__(self, attr):
134        if attr == "track_number":
135            MetaData.__setattr__(self, "__track_number__", 0)
136        elif attr in self.FIELD_LENGTHS:
137            MetaData.__setattr__(self,
138                                 self.ID3v1_FIELDS[attr],
139                                 u"")
140        elif attr in self.FIELDS:
141            # field not supported by ID3v1Comment, so ignore it
142            pass
143        else:
144            MetaData.__delattr__(self, attr)
145
146    def raw_info(self):
147        """returns a human-readable version of this metadata
148        as a unicode string"""
149
150        from os import linesep
151
152        return linesep.join(
153            [u"ID3v1.1:"] +
154            [u"%s = %s" % (label, getattr(self, attr))
155             for (label, attr) in [(u"  track name", "track_name"),
156                                   (u" artist name", "artist_name"),
157                                   (u"  album name", "album_name"),
158                                   (u"        year", "year"),
159                                   (u"     comment", "comment"),
160                                   (u"track number", "track_number")]
161             if (getattr(self, attr) is not None)] +
162            [u"       genre = %d" % (self.__genre__)])
163
164    @classmethod
165    def parse(cls, mp3_file):
166        """given an MP3 file, returns an ID3v1Comment
167
168        raises ValueError if the comment is invalid"""
169
170        from audiotools.bitstream import parse
171
172        def decode_string(s):
173            return s.rstrip(b"\x00").decode("ascii", "replace")
174
175        mp3_file.seek(-128, 2)
176        (tag,
177         track_name,
178         artist_name,
179         album_name,
180         year,
181         comment,
182         track_number,
183         genre) = parse("3b 30b 30b 30b 4b 28b 8p 8u 8u",
184                        False,
185                        mp3_file.read(128))
186        if tag != b'TAG':
187            raise ValueError(u"invalid ID3v1 tag")
188
189        return ID3v1Comment(track_name=decode_string(track_name),
190                            artist_name=decode_string(artist_name),
191                            album_name=decode_string(album_name),
192                            year=decode_string(year),
193                            comment=decode_string(comment),
194                            track_number=track_number,
195                            genre=genre)
196
197    def build(self, mp3_file):
198        """given an MP3 file positioned at the file's end, generate a tag"""
199
200        from audiotools.bitstream import build
201
202        def encode_string(u, max_chars):
203            s = u.encode("ascii", "replace")
204            if len(s) >= max_chars:
205                return s[0:max_chars]
206            else:
207                return s + b"\x00" * (max_chars - len(s))
208
209        mp3_file.write(
210            build("3b 30b 30b 30b 4b 28b 8p 8u 8u",
211                  False,
212                  (b"TAG",
213                   encode_string(self.__track_name__, 30),
214                   encode_string(self.__artist_name__, 30),
215                   encode_string(self.__album_name__, 30),
216                   encode_string(self.__year__, 4),
217                   encode_string(self.__comment__, 28),
218                   self.__track_number__,
219                   self.__genre__)))
220
221    @classmethod
222    def supports_images(cls):
223        """returns False"""
224
225        return False
226
227    @classmethod
228    def converted(cls, metadata):
229        """converts a MetaData object to an ID3v1Comment object"""
230
231        if metadata is None:
232            return None
233        elif isinstance(metadata, ID3v1Comment):
234            # duplicate all fields as-is
235            return ID3v1Comment(track_name=metadata.__track_name__,
236                                artist_name=metadata.__artist_name__,
237                                album_name=metadata.__album_name__,
238                                year=metadata.__year__,
239                                comment=metadata.__comment__,
240                                track_number=metadata.__track_number__,
241                                genre=metadata.__genre__)
242        else:
243            # convert fields using setattr
244            id3v1 = ID3v1Comment()
245            for attr in ["track_name",
246                         "artist_name",
247                         "album_name",
248                         "year",
249                         "comment",
250                         "track_number"]:
251                setattr(id3v1, attr, getattr(metadata, attr))
252            return id3v1
253
254    def images(self):
255        """returns an empty list of Image objects"""
256
257        return []
258
259    def clean(self):
260        import sys
261
262        """returns a new ID3v1Comment object that's been cleaned of problems"""
263
264        from audiotools.text import (CLEAN_REMOVE_TRAILING_WHITESPACE,
265                                     CLEAN_REMOVE_LEADING_WHITESPACE)
266
267        fixes_performed = []
268        fields = {}
269        for (attr,
270             name) in [("track_name", u"title"),
271                       ("artist_name", u"artist"),
272                       ("album_name", u"album"),
273                       ("year", u"year"),
274                       ("comment", u"comment")]:
275            # strip out trailing NULL bytes
276            initial_value = getattr(self, attr)
277
278            if initial_value is not None:
279                fix1 = initial_value.rstrip()
280                if fix1 != initial_value:
281                    fixes_performed.append(CLEAN_REMOVE_TRAILING_WHITESPACE %
282                                           {"field": name})
283                fix2 = fix1.lstrip()
284                if fix2 != fix1:
285                    fixes_performed.append(CLEAN_REMOVE_LEADING_WHITESPACE %
286                                           {"field": name})
287
288                # restore trailing NULL bytes
289                fields[attr] = fix2
290
291        # copy non-text fields as-is
292        return (ID3v1Comment(track_number=self.__track_number__,
293                             genre=self.__genre__,
294                             **fields), fixes_performed)
295