eyeD3 0.9.8a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- eyed3/__about__.py +27 -0
- eyed3/__init__.py +38 -0
- eyed3/__regarding__.py +48 -0
- eyed3/core.py +457 -0
- eyed3/id3/__init__.py +544 -0
- eyed3/id3/apple.py +58 -0
- eyed3/id3/frames.py +2261 -0
- eyed3/id3/headers.py +696 -0
- eyed3/id3/tag.py +2047 -0
- eyed3/main.py +305 -0
- eyed3/mimetype.py +107 -0
- eyed3/mp3/__init__.py +188 -0
- eyed3/mp3/headers.py +866 -0
- eyed3/plugins/__init__.py +200 -0
- eyed3/plugins/art.py +266 -0
- eyed3/plugins/classic.py +1173 -0
- eyed3/plugins/extract.py +61 -0
- eyed3/plugins/fixup.py +631 -0
- eyed3/plugins/genres.py +48 -0
- eyed3/plugins/itunes.py +64 -0
- eyed3/plugins/jsontag.py +133 -0
- eyed3/plugins/lameinfo.py +86 -0
- eyed3/plugins/lastfm.py +50 -0
- eyed3/plugins/mimetype.py +93 -0
- eyed3/plugins/nfo.py +123 -0
- eyed3/plugins/pymod.py +72 -0
- eyed3/plugins/stats.py +479 -0
- eyed3/plugins/xep_118.py +45 -0
- eyed3/plugins/yamltag.py +25 -0
- eyed3/utils/__init__.py +443 -0
- eyed3/utils/art.py +79 -0
- eyed3/utils/binfuncs.py +153 -0
- eyed3/utils/console.py +553 -0
- eyed3/utils/log.py +59 -0
- eyed3/utils/prompt.py +90 -0
- eyed3-0.9.8a1.dist-info/METADATA +163 -0
- eyed3-0.9.8a1.dist-info/RECORD +42 -0
- eyed3-0.9.8a1.dist-info/WHEEL +5 -0
- eyed3-0.9.8a1.dist-info/entry_points.txt +2 -0
- eyed3-0.9.8a1.dist-info/licenses/AUTHORS.rst +39 -0
- eyed3-0.9.8a1.dist-info/licenses/LICENSE +675 -0
- eyed3-0.9.8a1.dist-info/top_level.txt +1 -0
eyed3/id3/frames.py
ADDED
@@ -0,0 +1,2261 @@
|
|
1
|
+
import dataclasses
|
2
|
+
from io import BytesIO
|
3
|
+
from collections import namedtuple
|
4
|
+
|
5
|
+
from .. import core
|
6
|
+
from ..utils import requireUnicode, requireBytes
|
7
|
+
from ..utils.binfuncs import (
|
8
|
+
bin2bytes, bin2dec, bytes2bin, dec2bin, bytes2dec, dec2bytes,
|
9
|
+
signedInt162bytes, bytes2signedInt16,
|
10
|
+
)
|
11
|
+
from .. import Error
|
12
|
+
from . import ID3_V2, ID3_V2_2, ID3_V2_3, ID3_V2_4
|
13
|
+
from . import (LATIN1_ENCODING, UTF_8_ENCODING, UTF_16BE_ENCODING,
|
14
|
+
UTF_16_ENCODING, DEFAULT_LANG)
|
15
|
+
from .headers import FrameHeader
|
16
|
+
from ..utils import b
|
17
|
+
from ..utils.log import getLogger
|
18
|
+
|
19
|
+
log = getLogger(__name__)
|
20
|
+
ISO_8859_1 = "iso-8859-1"
|
21
|
+
|
22
|
+
|
23
|
+
class FrameException(Error):
|
24
|
+
pass
|
25
|
+
|
26
|
+
|
27
|
+
TITLE_FID = b"TIT2" # noqa
|
28
|
+
SUBTITLE_FID = b"TIT3" # noqa
|
29
|
+
ARTIST_FID = b"TPE1" # noqa
|
30
|
+
ALBUM_ARTIST_FID = b"TPE2" # noqa
|
31
|
+
ORIG_ARTIST_FID = b"TOPE" # noqa
|
32
|
+
COMPOSER_FID = b"TCOM" # noqa
|
33
|
+
ALBUM_FID = b"TALB" # noqa
|
34
|
+
TRACKNUM_FID = b"TRCK" # noqa
|
35
|
+
GENRE_FID = b"TCON" # noqa
|
36
|
+
COMMENT_FID = b"COMM" # noqa
|
37
|
+
USERTEXT_FID = b"TXXX" # noqa
|
38
|
+
OBJECT_FID = b"GEOB" # noqa
|
39
|
+
UNIQUE_FILE_ID_FID = b"UFID" # noqa
|
40
|
+
LYRICS_FID = b"USLT" # noqa
|
41
|
+
DISCNUM_FID = b"TPOS" # noqa
|
42
|
+
IMAGE_FID = b"APIC" # noqa
|
43
|
+
USERURL_FID = b"WXXX" # noqa
|
44
|
+
PLAYCOUNT_FID = b"PCNT" # noqa
|
45
|
+
BPM_FID = b"TBPM" # noqa
|
46
|
+
PUBLISHER_FID = b"TPUB" # noqa
|
47
|
+
CDID_FID = b"MCDI" # noqa
|
48
|
+
PRIVATE_FID = b"PRIV" # noqa
|
49
|
+
TOS_FID = b"USER" # noqa
|
50
|
+
POPULARITY_FID = b"POPM" # noqa
|
51
|
+
ENCODED_BY_FID = b"TENC" # noqa
|
52
|
+
COPYRIGHT_FID = b"TCOP" # noqa
|
53
|
+
|
54
|
+
URL_COMMERCIAL_FID = b"WCOM" # noqa
|
55
|
+
URL_COPYRIGHT_FID = b"WCOP" # noqa
|
56
|
+
URL_AUDIOFILE_FID = b"WOAF" # noqa
|
57
|
+
URL_ARTIST_FID = b"WOAR" # noqa
|
58
|
+
URL_AUDIOSRC_FID = b"WOAS" # noqa
|
59
|
+
URL_INET_RADIO_FID = b"WORS" # noqa
|
60
|
+
URL_PAYMENT_FID = b"WPAY" # noqa
|
61
|
+
URL_PUBLISHER_FID = b"WPUB" # noqa
|
62
|
+
URL_FIDS = [URL_COMMERCIAL_FID, URL_COPYRIGHT_FID, # noqa
|
63
|
+
URL_AUDIOFILE_FID, URL_ARTIST_FID, URL_AUDIOSRC_FID,
|
64
|
+
URL_INET_RADIO_FID, URL_PAYMENT_FID,
|
65
|
+
URL_PUBLISHER_FID]
|
66
|
+
|
67
|
+
TOC_FID = b"CTOC" # noqa
|
68
|
+
CHAPTER_FID = b"CHAP" # noqa
|
69
|
+
|
70
|
+
DEPRECATED_DATE_FIDS = [b"TDAT", b"TYER", b"TIME", b"TORY", b"TRDA",
|
71
|
+
# Nonstandard v2.3 only
|
72
|
+
b"XDOR",
|
73
|
+
]
|
74
|
+
DATE_FIDS = [b"TDEN", b"TDOR", b"TDRC", b"TDRL", b"TDTG"]
|
75
|
+
|
76
|
+
|
77
|
+
class Frame(object):
|
78
|
+
_render_strict = True
|
79
|
+
|
80
|
+
@requireBytes(1)
|
81
|
+
def __init__(self, id):
|
82
|
+
self.id = id
|
83
|
+
self.header = None
|
84
|
+
|
85
|
+
self.decompressed_size = 0
|
86
|
+
self.group_id = None
|
87
|
+
self.encrypt_method = None
|
88
|
+
self.data = None
|
89
|
+
self.data_len = 0
|
90
|
+
self._encoding = None
|
91
|
+
self._unknown = False
|
92
|
+
|
93
|
+
@property
|
94
|
+
def header(self):
|
95
|
+
return self._header
|
96
|
+
|
97
|
+
@header.setter
|
98
|
+
def header(self, h):
|
99
|
+
self._header = h
|
100
|
+
|
101
|
+
@requireBytes(1)
|
102
|
+
def parse(self, data, frame_header):
|
103
|
+
self.id = frame_header.id
|
104
|
+
self.header = frame_header
|
105
|
+
self.data = self._disassembleFrame(data)
|
106
|
+
|
107
|
+
def render(self):
|
108
|
+
return self._assembleFrame(self.data)
|
109
|
+
|
110
|
+
def __lt__(self, other):
|
111
|
+
return self.id < other.id
|
112
|
+
|
113
|
+
@staticmethod
|
114
|
+
def decompress(data):
|
115
|
+
import zlib
|
116
|
+
log.debug("before decompression: %d bytes" % len(data))
|
117
|
+
data = zlib.decompress(data, 15)
|
118
|
+
log.debug("after decompression: %d bytes" % len(data))
|
119
|
+
return data
|
120
|
+
|
121
|
+
@staticmethod
|
122
|
+
def compress(data):
|
123
|
+
import zlib
|
124
|
+
log.debug("before compression: %d bytes" % len(data))
|
125
|
+
data = zlib.compress(data)
|
126
|
+
log.debug("after compression: %d bytes" % len(data))
|
127
|
+
return data
|
128
|
+
|
129
|
+
@staticmethod
|
130
|
+
def decrypt(data):
|
131
|
+
log.warning("Frame decryption not yet supported, leaving data as is.")
|
132
|
+
return data
|
133
|
+
|
134
|
+
@staticmethod
|
135
|
+
def encrypt(data):
|
136
|
+
log.warning("Frame encryption not yet supported, leaving data as is.")
|
137
|
+
return data
|
138
|
+
|
139
|
+
@requireBytes(1)
|
140
|
+
def _disassembleFrame(self, data):
|
141
|
+
header = self.header
|
142
|
+
# Format flags in the frame header may add extra data to the
|
143
|
+
# beginning of this data.
|
144
|
+
if header.minor_version <= 3:
|
145
|
+
# 2.3: compression(4), encryption(1), group(1)
|
146
|
+
if header.compressed:
|
147
|
+
self.decompressed_size = bin2dec(bytes2bin(data[:4]))
|
148
|
+
data = data[4:]
|
149
|
+
log.debug("Decompressed Size: %d" % self.decompressed_size)
|
150
|
+
if header.encrypted:
|
151
|
+
self.encrypt_method = bin2dec(bytes2bin(data[0:1]))
|
152
|
+
data = data[1:]
|
153
|
+
log.debug("Encryption Method: %d" % self.encrypt_method)
|
154
|
+
if header.grouped:
|
155
|
+
self.group_id = bin2dec(bytes2bin(data[0:1]))
|
156
|
+
data = data[1:]
|
157
|
+
log.debug("Group ID: %d" % self.group_id)
|
158
|
+
else:
|
159
|
+
# 2.4: group(1), encrypted(1), data_length_indicator (4,7)
|
160
|
+
if header.grouped:
|
161
|
+
self.group_id = bin2dec(bytes2bin(data[0:1]))
|
162
|
+
log.debug("Group ID: %d" % self.group_id)
|
163
|
+
data = data[1:]
|
164
|
+
if header.encrypted:
|
165
|
+
self.encrypt_method = bin2dec(bytes2bin(data[0:1]))
|
166
|
+
data = data[1:]
|
167
|
+
log.debug("Encryption Method: %d" % self.encrypt_method)
|
168
|
+
if header.data_length_indicator:
|
169
|
+
self.data_len = bin2dec(bytes2bin(data[:4], 7))
|
170
|
+
data = data[4:]
|
171
|
+
log.debug("Data Length: %d" % self.data_len)
|
172
|
+
if header.compressed:
|
173
|
+
self.decompressed_size = self.data_len
|
174
|
+
log.debug("Decompressed Size: %d" % self.decompressed_size)
|
175
|
+
|
176
|
+
if header.minor_version == 4 and header.unsync:
|
177
|
+
data = deunsyncData(data)
|
178
|
+
if header.encrypted:
|
179
|
+
data = self.decrypt(data)
|
180
|
+
if header.compressed:
|
181
|
+
data = self.decompress(data)
|
182
|
+
|
183
|
+
return data
|
184
|
+
|
185
|
+
@requireBytes(1)
|
186
|
+
def _assembleFrame(self, data):
|
187
|
+
header = self.header
|
188
|
+
|
189
|
+
# eyeD3 never writes unsync'd frames
|
190
|
+
header.unsync = False
|
191
|
+
|
192
|
+
format_data = b""
|
193
|
+
if header.minor_version == 3:
|
194
|
+
if header.compressed:
|
195
|
+
format_data += bin2bytes(dec2bin(len(data), 32))
|
196
|
+
if header.encrypted:
|
197
|
+
format_data += bin2bytes(dec2bin(self.encrypt_method, 8))
|
198
|
+
if header.grouped:
|
199
|
+
format_data += bin2bytes(dec2bin(self.group_id, 8))
|
200
|
+
else:
|
201
|
+
if header.grouped:
|
202
|
+
format_data += bin2bytes(dec2bin(self.group_id, 8))
|
203
|
+
if header.encrypted:
|
204
|
+
format_data += bin2bytes(dec2bin(self.encrypt_method, 8))
|
205
|
+
if header.compressed or header.data_length_indicator:
|
206
|
+
header.data_length_indicator = 1
|
207
|
+
format_data += bin2bytes(dec2bin(len(data), 32))
|
208
|
+
|
209
|
+
if header.compressed:
|
210
|
+
data = self.compress(data)
|
211
|
+
|
212
|
+
if header.encrypted:
|
213
|
+
data = self.encrypt(data)
|
214
|
+
|
215
|
+
self.data = format_data + data
|
216
|
+
return header.render(len(self.data)) + self.data
|
217
|
+
|
218
|
+
@property
|
219
|
+
def text_delim(self):
|
220
|
+
if self.encoding is None:
|
221
|
+
raise ValueError("Encoding required")
|
222
|
+
return b"\x00\x00" if self.encoding in (UTF_16_ENCODING,
|
223
|
+
UTF_16BE_ENCODING) else b"\x00"
|
224
|
+
|
225
|
+
def _initEncoding(self):
|
226
|
+
if not self.header.version or len(self.header.version) != 3:
|
227
|
+
raise ValueError("Invalid header version")
|
228
|
+
curr_enc = self.encoding
|
229
|
+
|
230
|
+
if self.encoding is not None:
|
231
|
+
# Make sure the encoding is valid for this version
|
232
|
+
if self.header.version[:2] < (2, 4):
|
233
|
+
if self.header.version[0] == 1:
|
234
|
+
self.encoding = LATIN1_ENCODING
|
235
|
+
else:
|
236
|
+
if self.encoding > UTF_16_ENCODING:
|
237
|
+
# v2.3 cannot do utf16 BE or utf8
|
238
|
+
self.encoding = UTF_16_ENCODING
|
239
|
+
else:
|
240
|
+
if self.header.version[:2] < (2, 4):
|
241
|
+
if self.header.version[0] == 2:
|
242
|
+
self.encoding = UTF_16_ENCODING
|
243
|
+
else:
|
244
|
+
self.encoding = LATIN1_ENCODING
|
245
|
+
else:
|
246
|
+
self.encoding = UTF_8_ENCODING
|
247
|
+
|
248
|
+
log.debug(f"_initEncoding: was={curr_enc} now={self.encoding}")
|
249
|
+
|
250
|
+
@property
|
251
|
+
def encoding(self):
|
252
|
+
return self._encoding
|
253
|
+
|
254
|
+
@encoding.setter
|
255
|
+
def encoding(self, enc):
|
256
|
+
if not isinstance(enc, bytes):
|
257
|
+
raise TypeError("encoding argument must be a byte string.")
|
258
|
+
elif not LATIN1_ENCODING <= enc <= UTF_8_ENCODING:
|
259
|
+
log.warning("Unknown encoding value {}".format(enc))
|
260
|
+
enc = LATIN1_ENCODING
|
261
|
+
self._encoding = enc
|
262
|
+
|
263
|
+
@property
|
264
|
+
def strict_rendering(self):
|
265
|
+
return self._render_strict
|
266
|
+
|
267
|
+
@property
|
268
|
+
def unknown(self):
|
269
|
+
return self._unknown
|
270
|
+
|
271
|
+
|
272
|
+
class TextFrame(Frame):
|
273
|
+
"""Text frames.
|
274
|
+
Data string format: encoding (one byte) + text
|
275
|
+
"""
|
276
|
+
@requireUnicode("text")
|
277
|
+
def __init__(self, id, text=None):
|
278
|
+
super(TextFrame, self).__init__(id)
|
279
|
+
if not TextFrame.isValidFrameId(id):
|
280
|
+
raise ValueError("Invalid frame ID for TextFrame")
|
281
|
+
self.text = text or ""
|
282
|
+
|
283
|
+
@property
|
284
|
+
def text(self):
|
285
|
+
return self._text
|
286
|
+
|
287
|
+
@text.setter
|
288
|
+
@requireUnicode(1)
|
289
|
+
def text(self, txt):
|
290
|
+
self._text = txt
|
291
|
+
|
292
|
+
def parse(self, data, frame_header):
|
293
|
+
super().parse(data, frame_header)
|
294
|
+
|
295
|
+
try:
|
296
|
+
self.encoding = self.data[0:1]
|
297
|
+
text_data = self.data[1:]
|
298
|
+
except ValueError as err:
|
299
|
+
log.warning("TextFrame[{fid}] - {err}; using latin1"
|
300
|
+
.format(err=err, fid=self.id))
|
301
|
+
self.encoding = LATIN1_ENCODING
|
302
|
+
text_data = self.data[:]
|
303
|
+
|
304
|
+
try:
|
305
|
+
self.text = decodeUnicode(text_data, self.encoding)
|
306
|
+
except UnicodeDecodeError as err:
|
307
|
+
log.warning(f"Error decoding text frame {self.id}: {err}")
|
308
|
+
self.text = ""
|
309
|
+
log.debug("TextFrame text: %s" % self.text)
|
310
|
+
|
311
|
+
def render(self):
|
312
|
+
self._initEncoding()
|
313
|
+
self.data = (self.encoding +
|
314
|
+
self.text.encode(id3EncodingToString(self.encoding)))
|
315
|
+
if type(self.data) is not bytes:
|
316
|
+
raise ValueError("Frame date not bytes type")
|
317
|
+
return super().render()
|
318
|
+
|
319
|
+
@staticmethod
|
320
|
+
def isValidFrameId(fid: bytes) -> bool:
|
321
|
+
return (fid[0:1] == b'T' or
|
322
|
+
fid in [b"XSOA", b"XSOP", b"XSOT", b"XDOR", b"WFED", b"GRP1"])
|
323
|
+
|
324
|
+
|
325
|
+
class UserTextFrame(TextFrame):
|
326
|
+
@requireUnicode("description", "text")
|
327
|
+
def __init__(self, id=USERTEXT_FID, description="", text=""):
|
328
|
+
super(UserTextFrame, self).__init__(id, text=text)
|
329
|
+
self.description = description
|
330
|
+
|
331
|
+
@property
|
332
|
+
def description(self):
|
333
|
+
return self._description
|
334
|
+
|
335
|
+
@description.setter
|
336
|
+
@requireUnicode(1)
|
337
|
+
def description(self, txt):
|
338
|
+
self._description = txt
|
339
|
+
|
340
|
+
def parse(self, data, frame_header):
|
341
|
+
"""Data string format:
|
342
|
+
encoding (one byte) + description + b"\x00" + text """
|
343
|
+
# Calling Frame, not TextFrame implementation here since TextFrame
|
344
|
+
# does not know about description
|
345
|
+
Frame.parse(self, data, frame_header)
|
346
|
+
|
347
|
+
try:
|
348
|
+
self.encoding = self.data[0:1]
|
349
|
+
(d, t) = splitUnicode(self.data[1:], self.encoding)
|
350
|
+
except ValueError as err:
|
351
|
+
log.warning("UserTextFrame[{fid}] - {err}; using latin1"
|
352
|
+
.format(err=err, fid=self.id))
|
353
|
+
self.encoding = LATIN1_ENCODING
|
354
|
+
(d, t) = splitUnicode(self.data[:], self.encoding)
|
355
|
+
|
356
|
+
self.description = decodeUnicode(d, self.encoding)
|
357
|
+
log.debug("UserTextFrame description: %s" % self.description)
|
358
|
+
self.text = decodeUnicode(t, self.encoding)
|
359
|
+
log.debug("UserTextFrame text: %s" % self.text)
|
360
|
+
|
361
|
+
def render(self):
|
362
|
+
self._initEncoding()
|
363
|
+
data = (self.encoding +
|
364
|
+
self.description.encode(id3EncodingToString(self.encoding)) +
|
365
|
+
self.text_delim +
|
366
|
+
self.text.encode(id3EncodingToString(self.encoding)))
|
367
|
+
self.data = data
|
368
|
+
# Calling Frame, not the base
|
369
|
+
return Frame.render(self)
|
370
|
+
|
371
|
+
|
372
|
+
class DateFrame(TextFrame):
|
373
|
+
def __init__(self, id, date=""):
|
374
|
+
if id not in DATE_FIDS and id not in DEPRECATED_DATE_FIDS:
|
375
|
+
raise ValueError(f"Invalid date frame ID: {id}")
|
376
|
+
super().__init__(id, text=str(date))
|
377
|
+
self.date = self.text
|
378
|
+
self.encoding = LATIN1_ENCODING
|
379
|
+
|
380
|
+
def parse(self, data, frame_header):
|
381
|
+
super().parse(data, frame_header)
|
382
|
+
try:
|
383
|
+
if self.text:
|
384
|
+
_ = core.Date.parse(self.text) # noqa
|
385
|
+
except ValueError:
|
386
|
+
# Date is invalid, log it and reset.
|
387
|
+
core.parseError(FrameException(f"Invalid date: {self.text}"))
|
388
|
+
self.text = ""
|
389
|
+
|
390
|
+
@property
|
391
|
+
def date(self):
|
392
|
+
return core.Date.parse(self.text.encode("latin1")) if self.text else None
|
393
|
+
|
394
|
+
@date.setter
|
395
|
+
def date(self, date):
|
396
|
+
"""Set value with a either an ISO 8601 date string or a eyed3.core.Date object."""
|
397
|
+
if not date:
|
398
|
+
self.text = ""
|
399
|
+
return
|
400
|
+
|
401
|
+
try:
|
402
|
+
if type(date) is str:
|
403
|
+
date = core.Date.parse(date)
|
404
|
+
elif type(date) is int:
|
405
|
+
# Date is year
|
406
|
+
date = core.Date(date)
|
407
|
+
elif not isinstance(date, core.Date):
|
408
|
+
raise TypeError("str, int, or eyed3.core.Date type expected")
|
409
|
+
except ValueError:
|
410
|
+
log.warning(f"Invalid date text: {date}")
|
411
|
+
self.text = ""
|
412
|
+
return
|
413
|
+
|
414
|
+
self.text = str(date)
|
415
|
+
|
416
|
+
def _initEncoding(self):
|
417
|
+
# Dates are always latin1 since they are always represented in ISO 8601
|
418
|
+
self.encoding = LATIN1_ENCODING
|
419
|
+
|
420
|
+
|
421
|
+
class UrlFrame(Frame):
|
422
|
+
|
423
|
+
def __init__(self, id, url=""):
|
424
|
+
if id not in URL_FIDS and id != USERURL_FID:
|
425
|
+
raise ValueError(f"Invalid URL frame ID: {id}")
|
426
|
+
super(UrlFrame, self).__init__(id)
|
427
|
+
|
428
|
+
self.encoding = LATIN1_ENCODING # Per the specs
|
429
|
+
self.url = url
|
430
|
+
|
431
|
+
@property
|
432
|
+
def url(self):
|
433
|
+
return self._url
|
434
|
+
|
435
|
+
@url.setter
|
436
|
+
def url(self, url):
|
437
|
+
if isinstance(url, bytes):
|
438
|
+
url = str(url, ISO_8859_1)
|
439
|
+
else:
|
440
|
+
url.encode(ISO_8859_1) # Likewise, it must encode
|
441
|
+
|
442
|
+
self._url = url
|
443
|
+
|
444
|
+
def parse(self, data, frame_header):
|
445
|
+
super().parse(data, frame_header)
|
446
|
+
|
447
|
+
try:
|
448
|
+
self.url = self.data
|
449
|
+
except UnicodeDecodeError:
|
450
|
+
log.warning("Non ascii url, clearing.")
|
451
|
+
self.url = ""
|
452
|
+
|
453
|
+
def render(self):
|
454
|
+
self.data = self.url.encode(ISO_8859_1)
|
455
|
+
return super(UrlFrame, self).render()
|
456
|
+
|
457
|
+
|
458
|
+
class UserUrlFrame(UrlFrame):
|
459
|
+
"""
|
460
|
+
Data string format:
|
461
|
+
encoding (one byte) + description + b"\x00" + url (iso-8859-1)
|
462
|
+
"""
|
463
|
+
@requireUnicode("description")
|
464
|
+
def __init__(self, id=USERURL_FID, description="", url=""):
|
465
|
+
if id != USERURL_FID:
|
466
|
+
raise ValueError(f"Unexpected frame ID: {id}")
|
467
|
+
UrlFrame.__init__(self, id, url=url)
|
468
|
+
|
469
|
+
self.description = description
|
470
|
+
|
471
|
+
@property
|
472
|
+
def description(self):
|
473
|
+
return self._description
|
474
|
+
|
475
|
+
@description.setter
|
476
|
+
@requireUnicode(1)
|
477
|
+
def description(self, desc):
|
478
|
+
self._description = desc
|
479
|
+
|
480
|
+
def parse(self, data, frame_header):
|
481
|
+
# Calling Frame and NOT UrlFrame to get the basic disassemble behavior
|
482
|
+
# UrlFrame would be confused by the encoding, desc, etc.
|
483
|
+
super().parse(data, frame_header)
|
484
|
+
self.encoding = encoding = self.data[0:1]
|
485
|
+
|
486
|
+
(d, u) = splitUnicode(self.data[1:], encoding)
|
487
|
+
self.description = decodeUnicode(d, encoding)
|
488
|
+
log.debug("UserUrlFrame description: %s" % self.description)
|
489
|
+
# The URL is ascii, ensure
|
490
|
+
try:
|
491
|
+
self.url = str(u, "ascii").encode("ascii")
|
492
|
+
except UnicodeDecodeError:
|
493
|
+
log.warning("Non ascii url, clearing.")
|
494
|
+
self.url = ""
|
495
|
+
log.debug("UserUrlFrame text: %s" % self.url)
|
496
|
+
|
497
|
+
def render(self):
|
498
|
+
self._initEncoding()
|
499
|
+
data = (self.encoding +
|
500
|
+
self.description.encode(id3EncodingToString(self.encoding)) +
|
501
|
+
self.text_delim + self.url.encode(ISO_8859_1))
|
502
|
+
self.data = data
|
503
|
+
# Calling Frame, not the base.
|
504
|
+
return Frame.render(self)
|
505
|
+
|
506
|
+
|
507
|
+
class UnknownFrame(Frame):
|
508
|
+
"""Unknown Frame."""
|
509
|
+
def __init__(self, id):
|
510
|
+
super().__init__(id)
|
511
|
+
if self.id in ID3_FRAMES or self.id in NONSTANDARD_ID3_FRAMES:
|
512
|
+
raise ValueError(f"Unknown constructed with known frame ID: {self.id}")
|
513
|
+
self._unknown = True
|
514
|
+
|
515
|
+
|
516
|
+
##
|
517
|
+
# Data string format:
|
518
|
+
# <Header for 'Attached picture', ID: "APIC">
|
519
|
+
# Text encoding $xx
|
520
|
+
# MIME type <text string> $00
|
521
|
+
# Picture type $xx
|
522
|
+
# Description <text string according to encoding> $00 (00)
|
523
|
+
# Picture data <binary data>
|
524
|
+
class ImageFrame(Frame):
|
525
|
+
OTHER = 0x00 # noqa
|
526
|
+
ICON = 0x01 # 32x32 png only. # noqa
|
527
|
+
OTHER_ICON = 0x02 # noqa
|
528
|
+
FRONT_COVER = 0x03 # noqa
|
529
|
+
BACK_COVER = 0x04 # noqa
|
530
|
+
LEAFLET = 0x05 # noqa
|
531
|
+
MEDIA = 0x06 # label side of cd, vinyl, etc. # noqa
|
532
|
+
LEAD_ARTIST = 0x07 # noqa
|
533
|
+
ARTIST = 0x08 # noqa
|
534
|
+
CONDUCTOR = 0x09 # noqa
|
535
|
+
BAND = 0x0A # noqa
|
536
|
+
COMPOSER = 0x0B # noqa
|
537
|
+
LYRICIST = 0x0C # noqa
|
538
|
+
RECORDING_LOCATION = 0x0D # noqa
|
539
|
+
DURING_RECORDING = 0x0E # noqa
|
540
|
+
DURING_PERFORMANCE = 0x0F # noqa
|
541
|
+
VIDEO = 0x10 # noqa
|
542
|
+
BRIGHT_COLORED_FISH = 0x11 # There's always room for porno. # noqa
|
543
|
+
ILLUSTRATION = 0x12 # noqa
|
544
|
+
BAND_LOGO = 0x13 # noqa
|
545
|
+
PUBLISHER_LOGO = 0x14 # noqa
|
546
|
+
MIN_TYPE = OTHER # noqa
|
547
|
+
MAX_TYPE = PUBLISHER_LOGO # noqa
|
548
|
+
|
549
|
+
URL_MIME_TYPE = b"-->" # noqa
|
550
|
+
URL_MIME_TYPE_STR = "-->" # noqa
|
551
|
+
URL_MIME_TYPE_VALUES = (URL_MIME_TYPE, URL_MIME_TYPE_STR)
|
552
|
+
|
553
|
+
@requireUnicode("description")
|
554
|
+
def __init__(self, id=IMAGE_FID, description="",
|
555
|
+
image_data=None, image_url=None,
|
556
|
+
picture_type=None, mime_type=None):
|
557
|
+
if id != IMAGE_FID:
|
558
|
+
raise ValueError(f"Unexpected frame ID: {id}")
|
559
|
+
|
560
|
+
super(ImageFrame, self).__init__(id)
|
561
|
+
self.description = description
|
562
|
+
self.image_data = image_data
|
563
|
+
self.image_url = image_url
|
564
|
+
|
565
|
+
# XXX: Add this member as `type` and deprecate picture_type??
|
566
|
+
self.picture_type = picture_type
|
567
|
+
self.mime_type = mime_type
|
568
|
+
|
569
|
+
@property
|
570
|
+
def description(self):
|
571
|
+
return self._description
|
572
|
+
|
573
|
+
@description.setter
|
574
|
+
@requireUnicode(1)
|
575
|
+
def description(self, d):
|
576
|
+
self._description = d
|
577
|
+
|
578
|
+
@property
|
579
|
+
def mime_type(self):
|
580
|
+
return str(self._mime_type, "ascii")
|
581
|
+
|
582
|
+
@mime_type.setter
|
583
|
+
def mime_type(self, m):
|
584
|
+
m = m or b''
|
585
|
+
self._mime_type = m if isinstance(m, bytes) else m.encode('ascii')
|
586
|
+
|
587
|
+
@property
|
588
|
+
def picture_type(self):
|
589
|
+
return self._pic_type
|
590
|
+
|
591
|
+
@picture_type.setter
|
592
|
+
def picture_type(self, t):
|
593
|
+
if t is not None and (t < ImageFrame.MIN_TYPE or
|
594
|
+
t > ImageFrame.MAX_TYPE):
|
595
|
+
raise ValueError("Invalid picture_type: %d" % t)
|
596
|
+
self._pic_type = t
|
597
|
+
|
598
|
+
def parse(self, data, frame_header):
|
599
|
+
super().parse(data, frame_header)
|
600
|
+
|
601
|
+
input = BytesIO(self.data)
|
602
|
+
log.debug("APIC frame data size: %d" % len(self.data))
|
603
|
+
self.encoding = encoding = input.read(1)
|
604
|
+
|
605
|
+
# Mime type
|
606
|
+
self._mime_type = b""
|
607
|
+
if frame_header.minor_version != 2:
|
608
|
+
ch = input.read(1)
|
609
|
+
while ch and ch != b"\x00":
|
610
|
+
self._mime_type += ch
|
611
|
+
ch = input.read(1)
|
612
|
+
else:
|
613
|
+
# v2.2 (OBSOLETE) special case
|
614
|
+
self._mime_type = input.read(3)
|
615
|
+
log.debug("APIC mime type: %s" % self._mime_type)
|
616
|
+
if not self._mime_type:
|
617
|
+
core.parseError(FrameException("APIC frame does not contain a mime "
|
618
|
+
"type"))
|
619
|
+
if (self._mime_type != self.URL_MIME_TYPE and
|
620
|
+
self._mime_type.find(b"/") == -1):
|
621
|
+
self._mime_type = b"image/" + self._mime_type
|
622
|
+
|
623
|
+
pt = ord(input.read(1))
|
624
|
+
log.debug("Initial APIC picture type: %d" % pt)
|
625
|
+
if pt < self.MIN_TYPE or pt > self.MAX_TYPE:
|
626
|
+
core.parseError(FrameException("Invalid APIC picture type: %d" %
|
627
|
+
pt))
|
628
|
+
self.picture_type = self.OTHER
|
629
|
+
else:
|
630
|
+
self.picture_type = pt
|
631
|
+
log.debug("APIC picture type: %d" % self.picture_type)
|
632
|
+
|
633
|
+
self.desciption = ""
|
634
|
+
|
635
|
+
# Remaining data is a NULL separated description and image data
|
636
|
+
buffer = input.read()
|
637
|
+
input.close()
|
638
|
+
|
639
|
+
(desc, img) = splitUnicode(buffer, encoding)
|
640
|
+
log.debug("description len: %d" % len(desc))
|
641
|
+
log.debug("image len: %d" % len(img))
|
642
|
+
self.description = decodeUnicode(desc, encoding)
|
643
|
+
log.debug("APIC description: %s" % self.description)
|
644
|
+
|
645
|
+
if self._mime_type.find(self.URL_MIME_TYPE) != -1:
|
646
|
+
self.image_data = None
|
647
|
+
self.image_url = img
|
648
|
+
log.debug("APIC image URL: %s" %
|
649
|
+
len(self.image_url.decode("ascii")))
|
650
|
+
else:
|
651
|
+
self.image_data = img
|
652
|
+
self.image_url = None
|
653
|
+
log.debug("APIC image data: %d bytes" % len(self.image_data))
|
654
|
+
if not self.image_data and not self.image_url:
|
655
|
+
core.parseError(FrameException("APIC frame does not contain image "
|
656
|
+
"data/url"))
|
657
|
+
|
658
|
+
def render(self):
|
659
|
+
# some code has problems with image descriptions encoded <> latin1
|
660
|
+
# namely mp3diags: work around the problem by forcing latin1 encoding
|
661
|
+
# for empty descriptions, which is by far the most common case anyway
|
662
|
+
self._initEncoding()
|
663
|
+
|
664
|
+
if not self.image_data and self.image_url:
|
665
|
+
self._mime_type = self.URL_MIME_TYPE
|
666
|
+
|
667
|
+
data = (self.encoding + self._mime_type + b"\x00" +
|
668
|
+
bin2bytes(dec2bin(self.picture_type, 8)) +
|
669
|
+
self.description.encode(id3EncodingToString(self.encoding)) +
|
670
|
+
self.text_delim)
|
671
|
+
|
672
|
+
if self.image_data:
|
673
|
+
data += self.image_data
|
674
|
+
elif self.image_url:
|
675
|
+
data += self.image_url
|
676
|
+
|
677
|
+
self.data = data
|
678
|
+
return super(ImageFrame, self).render()
|
679
|
+
|
680
|
+
@staticmethod
|
681
|
+
def picTypeToString(t):
|
682
|
+
if t == ImageFrame.OTHER:
|
683
|
+
return "OTHER"
|
684
|
+
elif t == ImageFrame.ICON:
|
685
|
+
return "ICON"
|
686
|
+
elif t == ImageFrame.OTHER_ICON:
|
687
|
+
return "OTHER_ICON"
|
688
|
+
elif t == ImageFrame.FRONT_COVER:
|
689
|
+
return "FRONT_COVER"
|
690
|
+
elif t == ImageFrame.BACK_COVER:
|
691
|
+
return "BACK_COVER"
|
692
|
+
elif t == ImageFrame.LEAFLET:
|
693
|
+
return "LEAFLET"
|
694
|
+
elif t == ImageFrame.MEDIA:
|
695
|
+
return "MEDIA"
|
696
|
+
elif t == ImageFrame.LEAD_ARTIST:
|
697
|
+
return "LEAD_ARTIST"
|
698
|
+
elif t == ImageFrame.ARTIST:
|
699
|
+
return "ARTIST"
|
700
|
+
elif t == ImageFrame.CONDUCTOR:
|
701
|
+
return "CONDUCTOR"
|
702
|
+
elif t == ImageFrame.BAND:
|
703
|
+
return "BAND"
|
704
|
+
elif t == ImageFrame.COMPOSER:
|
705
|
+
return "COMPOSER"
|
706
|
+
elif t == ImageFrame.LYRICIST:
|
707
|
+
return "LYRICIST"
|
708
|
+
elif t == ImageFrame.RECORDING_LOCATION:
|
709
|
+
return "RECORDING_LOCATION"
|
710
|
+
elif t == ImageFrame.DURING_RECORDING:
|
711
|
+
return "DURING_RECORDING"
|
712
|
+
elif t == ImageFrame.DURING_PERFORMANCE:
|
713
|
+
return "DURING_PERFORMANCE"
|
714
|
+
elif t == ImageFrame.VIDEO:
|
715
|
+
return "VIDEO"
|
716
|
+
elif t == ImageFrame.BRIGHT_COLORED_FISH:
|
717
|
+
return "BRIGHT_COLORED_FISH"
|
718
|
+
elif t == ImageFrame.ILLUSTRATION:
|
719
|
+
return "ILLUSTRATION"
|
720
|
+
elif t == ImageFrame.BAND_LOGO:
|
721
|
+
return "BAND_LOGO"
|
722
|
+
elif t == ImageFrame.PUBLISHER_LOGO:
|
723
|
+
return "PUBLISHER_LOGO"
|
724
|
+
else:
|
725
|
+
raise ValueError("Invalid APIC picture type: %d" % t)
|
726
|
+
|
727
|
+
@staticmethod
|
728
|
+
def stringToPicType(s):
|
729
|
+
if s == "OTHER":
|
730
|
+
return ImageFrame.OTHER
|
731
|
+
elif s == "ICON":
|
732
|
+
return ImageFrame.ICON
|
733
|
+
elif s == "OTHER_ICON":
|
734
|
+
return ImageFrame.OTHER_ICON
|
735
|
+
elif s == "FRONT_COVER":
|
736
|
+
return ImageFrame.FRONT_COVER
|
737
|
+
elif s == "BACK_COVER":
|
738
|
+
return ImageFrame.BACK_COVER
|
739
|
+
elif s == "LEAFLET":
|
740
|
+
return ImageFrame.LEAFLET
|
741
|
+
elif s == "MEDIA":
|
742
|
+
return ImageFrame.MEDIA
|
743
|
+
elif s == "LEAD_ARTIST":
|
744
|
+
return ImageFrame.LEAD_ARTIST
|
745
|
+
elif s == "ARTIST":
|
746
|
+
return ImageFrame.ARTIST
|
747
|
+
elif s == "CONDUCTOR":
|
748
|
+
return ImageFrame.CONDUCTOR
|
749
|
+
elif s == "BAND":
|
750
|
+
return ImageFrame.BAND
|
751
|
+
elif s == "COMPOSER":
|
752
|
+
return ImageFrame.COMPOSER
|
753
|
+
elif s == "LYRICIST":
|
754
|
+
return ImageFrame.LYRICIST
|
755
|
+
elif s == "RECORDING_LOCATION":
|
756
|
+
return ImageFrame.RECORDING_LOCATION
|
757
|
+
elif s == "DURING_RECORDING":
|
758
|
+
return ImageFrame.DURING_RECORDING
|
759
|
+
elif s == "DURING_PERFORMANCE":
|
760
|
+
return ImageFrame.DURING_PERFORMANCE
|
761
|
+
elif s == "VIDEO":
|
762
|
+
return ImageFrame.VIDEO
|
763
|
+
elif s == "BRIGHT_COLORED_FISH":
|
764
|
+
return ImageFrame.BRIGHT_COLORED_FISH
|
765
|
+
elif s == "ILLUSTRATION":
|
766
|
+
return ImageFrame.ILLUSTRATION
|
767
|
+
elif s == "BAND_LOGO":
|
768
|
+
return ImageFrame.BAND_LOGO
|
769
|
+
elif s == "PUBLISHER_LOGO":
|
770
|
+
return ImageFrame.PUBLISHER_LOGO
|
771
|
+
else:
|
772
|
+
raise ValueError("Invalid APIC picture type: %s" % s)
|
773
|
+
|
774
|
+
def makeFileName(self, name=None):
|
775
|
+
name = ImageFrame.picTypeToString(self.picture_type) if not name \
|
776
|
+
else name
|
777
|
+
ext = self.mime_type.split("/")[1]
|
778
|
+
if ext == "jpeg":
|
779
|
+
ext = "jpg"
|
780
|
+
return ".".join([name, ext])
|
781
|
+
|
782
|
+
|
783
|
+
class ObjectFrame(Frame):
|
784
|
+
@requireUnicode("description", "filename")
|
785
|
+
def __init__(self, fid=OBJECT_FID, description="", filename="",
|
786
|
+
object_data=None, mime_type=None):
|
787
|
+
super().__init__(fid)
|
788
|
+
self.description = description
|
789
|
+
self.filename = filename
|
790
|
+
self.mime_type = mime_type
|
791
|
+
self.object_data = object_data
|
792
|
+
|
793
|
+
@property
|
794
|
+
def description(self):
|
795
|
+
return self._description
|
796
|
+
|
797
|
+
@description.setter
|
798
|
+
@requireUnicode(1)
|
799
|
+
def description(self, txt):
|
800
|
+
self._description = txt
|
801
|
+
|
802
|
+
@property
|
803
|
+
def mime_type(self):
|
804
|
+
return str(self._mime_type, "ascii")
|
805
|
+
|
806
|
+
@mime_type.setter
|
807
|
+
def mime_type(self, m):
|
808
|
+
m = m or b''
|
809
|
+
self._mime_type = m if isinstance(m, bytes) else m.encode('ascii')
|
810
|
+
|
811
|
+
@property
|
812
|
+
def filename(self):
|
813
|
+
return self._filename
|
814
|
+
|
815
|
+
@filename.setter
|
816
|
+
@requireUnicode(1)
|
817
|
+
def filename(self, txt):
|
818
|
+
self._filename = txt
|
819
|
+
|
820
|
+
def parse(self, data, frame_header):
|
821
|
+
"""Parse the frame from ``data`` bytes using details from
|
822
|
+
``frame_header``.
|
823
|
+
|
824
|
+
Data string format:
|
825
|
+
<Header for 'General encapsulated object', ID: "GEOB">
|
826
|
+
Text encoding $xx
|
827
|
+
MIME type <text string> $00
|
828
|
+
Filename <text string according to encoding> $00 (00)
|
829
|
+
Content description <text string according to encoding> $00 (00)
|
830
|
+
Encapsulated object <binary data>
|
831
|
+
"""
|
832
|
+
super().parse(data, frame_header)
|
833
|
+
|
834
|
+
input = BytesIO(self.data)
|
835
|
+
log.debug("GEOB frame data size: " + str(len(self.data)))
|
836
|
+
self.encoding = encoding = input.read(1)
|
837
|
+
|
838
|
+
# Mime type
|
839
|
+
self._mime_type = b""
|
840
|
+
if self.header.minor_version != 2:
|
841
|
+
ch = input.read(1)
|
842
|
+
while ch not in (b'', b'\0'):
|
843
|
+
self._mime_type += ch
|
844
|
+
ch = input.read(1)
|
845
|
+
else:
|
846
|
+
# v2.2 (OBSOLETE) special case
|
847
|
+
self._mime_type = input.read(3)
|
848
|
+
log.debug("GEOB mime type: %s" % self._mime_type)
|
849
|
+
if not self._mime_type:
|
850
|
+
core.parseError(FrameException("GEOB frame does not contain a "
|
851
|
+
"mime type"))
|
852
|
+
if self._mime_type.find(b"/") == -1:
|
853
|
+
core.parseError(FrameException("GEOB frame does not contain a "
|
854
|
+
"valid mime type"))
|
855
|
+
|
856
|
+
self.filename = ""
|
857
|
+
self.description = ""
|
858
|
+
|
859
|
+
# Remaining data is a NULL separated filename, description and object
|
860
|
+
# data
|
861
|
+
buffer = input.read()
|
862
|
+
input.close()
|
863
|
+
|
864
|
+
(filename, buffer) = splitUnicode(buffer, encoding)
|
865
|
+
(desc, obj) = splitUnicode(buffer, encoding)
|
866
|
+
self.filename = decodeUnicode(filename, encoding)
|
867
|
+
log.debug("GEOB filename: " + self.filename)
|
868
|
+
self.description = decodeUnicode(desc, encoding)
|
869
|
+
log.debug("GEOB description: " + self.description)
|
870
|
+
|
871
|
+
self.object_data = obj
|
872
|
+
log.debug("GEOB data: %d bytes " % len(self.object_data))
|
873
|
+
if not self.object_data:
|
874
|
+
core.parseError(FrameException("GEOB frame does not contain any "
|
875
|
+
"data"))
|
876
|
+
|
877
|
+
def render(self):
|
878
|
+
self._initEncoding()
|
879
|
+
data = (self.encoding + self._mime_type + b"\x00" +
|
880
|
+
self.filename.encode(id3EncodingToString(self.encoding)) +
|
881
|
+
self.text_delim +
|
882
|
+
self.description.encode(id3EncodingToString(self.encoding)) +
|
883
|
+
self.text_delim +
|
884
|
+
(self.object_data or b""))
|
885
|
+
self.data = data
|
886
|
+
return super(ObjectFrame, self).render()
|
887
|
+
|
888
|
+
|
889
|
+
class PrivateFrame(Frame):
|
890
|
+
"""PRIV"""
|
891
|
+
owner_id: bytes
|
892
|
+
owner_data: bytes
|
893
|
+
|
894
|
+
def __init__(self, id=PRIVATE_FID, owner_id=b"", owner_data=b""):
|
895
|
+
super().__init__(id)
|
896
|
+
if id != PRIVATE_FID:
|
897
|
+
raise ValueError(f"Unexpected frame ID: {id}")
|
898
|
+
for arg in (owner_id, owner_data):
|
899
|
+
if type(arg) is not bytes:
|
900
|
+
raise ValueError("PRIV owner fields require bytes type")
|
901
|
+
|
902
|
+
self.owner_id = owner_id
|
903
|
+
self.owner_data = owner_data
|
904
|
+
|
905
|
+
def parse(self, data, frame_header):
|
906
|
+
super().parse(data, frame_header)
|
907
|
+
try:
|
908
|
+
self.owner_id, self.owner_data = self.data.split(b"\x00", 1)
|
909
|
+
except ValueError:
|
910
|
+
# If data doesn't contain required \x00
|
911
|
+
# all data is taken to be owner_id
|
912
|
+
self.owner_id = self.data
|
913
|
+
|
914
|
+
def render(self):
|
915
|
+
self.data = self.owner_id + b"\x00" + self.owner_data
|
916
|
+
return super(PrivateFrame, self).render()
|
917
|
+
|
918
|
+
|
919
|
+
class MusicCDIdFrame(Frame):
|
920
|
+
|
921
|
+
def __init__(self, id=CDID_FID, toc=b""):
|
922
|
+
super(MusicCDIdFrame, self).__init__(id)
|
923
|
+
if id != CDID_FID:
|
924
|
+
raise ValueError(f"Unexpected frame ID: {id}")
|
925
|
+
self.toc = toc
|
926
|
+
|
927
|
+
@property
|
928
|
+
def toc(self):
|
929
|
+
return self.data
|
930
|
+
|
931
|
+
@toc.setter
|
932
|
+
def toc(self, toc):
|
933
|
+
self.data = toc
|
934
|
+
|
935
|
+
def parse(self, data, frame_header):
|
936
|
+
super().parse(data, frame_header)
|
937
|
+
self.toc = self.data
|
938
|
+
|
939
|
+
|
940
|
+
class PlayCountFrame(Frame):
|
941
|
+
def __init__(self, id=PLAYCOUNT_FID, count=0):
|
942
|
+
super(PlayCountFrame, self).__init__(id)
|
943
|
+
if self.id != PLAYCOUNT_FID:
|
944
|
+
raise ValueError(f"Unexpected frame ID: {self.id}")
|
945
|
+
|
946
|
+
if count is None or count < 0:
|
947
|
+
raise ValueError("Invalid count value: %s" % str(count))
|
948
|
+
self.count = count
|
949
|
+
|
950
|
+
def parse(self, data, frame_header):
|
951
|
+
super().parse(data, frame_header)
|
952
|
+
# data of less then 4 bytes is handled with with 'sz' arg
|
953
|
+
if len(self.data) < 4:
|
954
|
+
log.warning("Fixing invalid PCNT frame: less than 32 bits")
|
955
|
+
|
956
|
+
self.count = bytes2dec(self.data)
|
957
|
+
|
958
|
+
def render(self):
|
959
|
+
self.data = dec2bytes(self.count, 32)
|
960
|
+
return super(PlayCountFrame, self).render()
|
961
|
+
|
962
|
+
|
963
|
+
class PopularityFrame(Frame):
|
964
|
+
"""Frame type for 'POPM' frames; popularity.
|
965
|
+
Frame format:
|
966
|
+
<Header for 'Popularimeter', ID: "POPM">
|
967
|
+
Email to user <text string> $00
|
968
|
+
Rating $xx
|
969
|
+
Counter $xx xx xx xx (xx ...)
|
970
|
+
"""
|
971
|
+
def __init__(self, id=POPULARITY_FID, email=b"", rating=0, count=0):
|
972
|
+
super(PopularityFrame, self).__init__(id)
|
973
|
+
if self.id != POPULARITY_FID:
|
974
|
+
raise ValueError(f"Unexpected frame ID: {self.id}")
|
975
|
+
|
976
|
+
self.email = email
|
977
|
+
self.rating = rating
|
978
|
+
if count is None or count < 0:
|
979
|
+
raise ValueError("Invalid count value: %s" % str(count))
|
980
|
+
self.count = count
|
981
|
+
|
982
|
+
@property
|
983
|
+
def rating(self):
|
984
|
+
return self._rating
|
985
|
+
|
986
|
+
@rating.setter
|
987
|
+
def rating(self, rating):
|
988
|
+
if rating < 0 or rating > 255:
|
989
|
+
raise ValueError("Popularity rating must be >= 0 and <=255")
|
990
|
+
self._rating = rating
|
991
|
+
|
992
|
+
@property
|
993
|
+
def email(self):
|
994
|
+
return self._email
|
995
|
+
|
996
|
+
@email.setter
|
997
|
+
def email(self, email):
|
998
|
+
# XXX: becoming a pattern?
|
999
|
+
if isinstance(email, str):
|
1000
|
+
self._email = email.encode("ascii")
|
1001
|
+
elif isinstance(email, bytes):
|
1002
|
+
_ = email.decode("ascii") # noqa
|
1003
|
+
self._email = email
|
1004
|
+
else:
|
1005
|
+
raise TypeError("bytes, str, unicode email required")
|
1006
|
+
|
1007
|
+
@property
|
1008
|
+
def count(self):
|
1009
|
+
return self._count
|
1010
|
+
|
1011
|
+
@count.setter
|
1012
|
+
def count(self, count):
|
1013
|
+
if count < 0:
|
1014
|
+
raise ValueError("Popularity count must be > 0")
|
1015
|
+
self._count = count
|
1016
|
+
|
1017
|
+
def parse(self, data, frame_header):
|
1018
|
+
super().parse(data, frame_header)
|
1019
|
+
data = self.data
|
1020
|
+
|
1021
|
+
null_byte = data.find(b'\x00')
|
1022
|
+
try:
|
1023
|
+
self.email = data[:null_byte]
|
1024
|
+
except UnicodeDecodeError:
|
1025
|
+
core.parseError(FrameException("Invalid (non-ascii) POPM email "
|
1026
|
+
"address. Setting to 'BOGUS'"))
|
1027
|
+
self.email = b"BOGUS"
|
1028
|
+
data = data[null_byte + 1:]
|
1029
|
+
|
1030
|
+
self.rating = bytes2dec(data[0:1])
|
1031
|
+
|
1032
|
+
data = data[1:]
|
1033
|
+
if len(self.data) < 4:
|
1034
|
+
core.parseError(FrameException(
|
1035
|
+
"Invalid POPM play count: less than 32 bits."))
|
1036
|
+
self.count = bytes2dec(data)
|
1037
|
+
|
1038
|
+
def render(self):
|
1039
|
+
data = (self.email or b"") + b'\x00'
|
1040
|
+
data += dec2bytes(self.rating)
|
1041
|
+
data += dec2bytes(self.count, 32)
|
1042
|
+
|
1043
|
+
self.data = data
|
1044
|
+
return super(PopularityFrame, self).render()
|
1045
|
+
|
1046
|
+
|
1047
|
+
class UniqueFileIDFrame(Frame):
|
1048
|
+
def __init__(self, id=UNIQUE_FILE_ID_FID, owner_id=b"", uniq_id=b""):
|
1049
|
+
super().__init__(id)
|
1050
|
+
if self.id != UNIQUE_FILE_ID_FID:
|
1051
|
+
raise ValueError(f"Unexpected frame ID: {self.id}")
|
1052
|
+
self.owner_id = owner_id
|
1053
|
+
self.uniq_id = uniq_id
|
1054
|
+
|
1055
|
+
@property
|
1056
|
+
def owner_id(self):
|
1057
|
+
return self._owner_id
|
1058
|
+
|
1059
|
+
@owner_id.setter
|
1060
|
+
def owner_id(self, oid):
|
1061
|
+
self._owner_id = b(oid) if oid else b""
|
1062
|
+
|
1063
|
+
@property
|
1064
|
+
def uniq_id(self):
|
1065
|
+
return self._uniq_id
|
1066
|
+
|
1067
|
+
@uniq_id.setter
|
1068
|
+
def uniq_id(self, uid):
|
1069
|
+
self._uniq_id = b(uid) if uid else b""
|
1070
|
+
|
1071
|
+
def parse(self, data, frame_header):
|
1072
|
+
"""
|
1073
|
+
Data format
|
1074
|
+
Owner identifier <text string> $00
|
1075
|
+
Identifier up to 64 bytes binary data>
|
1076
|
+
"""
|
1077
|
+
super().parse(data, frame_header)
|
1078
|
+
split_data = self.data.split(b'\x00', 1)
|
1079
|
+
if len(split_data) == 2:
|
1080
|
+
(self.owner_id, self.uniq_id) = split_data
|
1081
|
+
else:
|
1082
|
+
self.owner_id, self.uniq_id = b"", b"".join(split_data[0:1])
|
1083
|
+
log.debug("UFID owner_id: %s" % self.owner_id)
|
1084
|
+
log.debug("UFID id: %s" % self.uniq_id)
|
1085
|
+
if not self.owner_id:
|
1086
|
+
dummy_owner_id = "http://www.id3.org/dummy/ufid.html"
|
1087
|
+
self.owner_id = dummy_owner_id
|
1088
|
+
core.parseError(FrameException("Invalid UFID, owner_id is empty. "
|
1089
|
+
"Setting to '%s'" % dummy_owner_id))
|
1090
|
+
elif 0 <= len(self.uniq_id) > 64:
|
1091
|
+
core.parseError(FrameException("Invalid UFID, ID is empty or too "
|
1092
|
+
"long: %s" % self.uniq_id))
|
1093
|
+
|
1094
|
+
def render(self):
|
1095
|
+
self.data = self.owner_id + b"\x00" + self.uniq_id
|
1096
|
+
return super().render()
|
1097
|
+
|
1098
|
+
|
1099
|
+
class LanguageCodeMixin(object):
|
1100
|
+
@property
|
1101
|
+
def lang(self):
|
1102
|
+
if self._lang is None:
|
1103
|
+
raise ValueError("lang must be set")
|
1104
|
+
return self._lang
|
1105
|
+
|
1106
|
+
@lang.setter
|
1107
|
+
@requireBytes(1)
|
1108
|
+
def lang(self, lang):
|
1109
|
+
if not lang:
|
1110
|
+
self._lang = b""
|
1111
|
+
return
|
1112
|
+
|
1113
|
+
lang = lang.strip(b"\00")
|
1114
|
+
lang = lang[:3] if lang else DEFAULT_LANG
|
1115
|
+
try:
|
1116
|
+
if lang != DEFAULT_LANG:
|
1117
|
+
lang.decode("ascii")
|
1118
|
+
except UnicodeDecodeError:
|
1119
|
+
lang = DEFAULT_LANG
|
1120
|
+
if len(lang) > 3:
|
1121
|
+
raise ValueError(f"invalid lang: {lang}")
|
1122
|
+
self._lang = lang
|
1123
|
+
|
1124
|
+
def _renderLang(self):
|
1125
|
+
lang = self.lang
|
1126
|
+
if len(lang) < 3:
|
1127
|
+
lang = lang + (b"\x00" * (3 - len(lang)))
|
1128
|
+
return lang
|
1129
|
+
|
1130
|
+
|
1131
|
+
class DescriptionLangTextFrame(Frame, LanguageCodeMixin):
|
1132
|
+
@requireBytes(1, 3)
|
1133
|
+
@requireUnicode(2, 4)
|
1134
|
+
def __init__(self, id, description, lang, text):
|
1135
|
+
super().__init__(id)
|
1136
|
+
self.lang = lang
|
1137
|
+
self.description = description
|
1138
|
+
self.text = text
|
1139
|
+
|
1140
|
+
@property
|
1141
|
+
def description(self):
|
1142
|
+
return self._description
|
1143
|
+
|
1144
|
+
@description.setter
|
1145
|
+
@requireUnicode(1)
|
1146
|
+
def description(self, description):
|
1147
|
+
self._description = description
|
1148
|
+
|
1149
|
+
@property
|
1150
|
+
def text(self):
|
1151
|
+
return self._text
|
1152
|
+
|
1153
|
+
@text.setter
|
1154
|
+
@requireUnicode(1)
|
1155
|
+
def text(self, text):
|
1156
|
+
self._text = text
|
1157
|
+
|
1158
|
+
def parse(self, data, frame_header):
|
1159
|
+
super().parse(data, frame_header)
|
1160
|
+
|
1161
|
+
self.encoding = self.data[0:1]
|
1162
|
+
self.lang = self.data[1:4]
|
1163
|
+
log.debug("%s lang: %s" % (self.id, self.lang))
|
1164
|
+
|
1165
|
+
try:
|
1166
|
+
(d, t) = splitUnicode(self.data[4:], self.encoding)
|
1167
|
+
self.description = decodeUnicode(d, self.encoding)
|
1168
|
+
log.debug("%s description: %s" % (self.id, self.description))
|
1169
|
+
self.text = decodeUnicode(t, self.encoding)
|
1170
|
+
log.debug("%s text: %s" % (self.id, self.text))
|
1171
|
+
except ValueError:
|
1172
|
+
log.warning("Invalid %s frame; no description/text" % self.id)
|
1173
|
+
self.description = ""
|
1174
|
+
self.text = ""
|
1175
|
+
|
1176
|
+
def render(self):
|
1177
|
+
lang = self._renderLang()
|
1178
|
+
|
1179
|
+
self._initEncoding()
|
1180
|
+
data = (self.encoding + lang +
|
1181
|
+
self.description.encode(id3EncodingToString(self.encoding)) +
|
1182
|
+
self.text_delim +
|
1183
|
+
self.text.encode(id3EncodingToString(self.encoding)))
|
1184
|
+
self.data = data
|
1185
|
+
return super(DescriptionLangTextFrame, self).render()
|
1186
|
+
|
1187
|
+
|
1188
|
+
class CommentFrame(DescriptionLangTextFrame):
|
1189
|
+
def __init__(self, id=COMMENT_FID, description="", lang=DEFAULT_LANG,
|
1190
|
+
text=""):
|
1191
|
+
super(CommentFrame, self).__init__(id, description, lang, text)
|
1192
|
+
if id != COMMENT_FID:
|
1193
|
+
raise ValueError(f"Unexpected frame ID: {id}")
|
1194
|
+
|
1195
|
+
|
1196
|
+
class LyricsFrame(DescriptionLangTextFrame):
|
1197
|
+
def __init__(self, id=LYRICS_FID, description="", lang=DEFAULT_LANG,
|
1198
|
+
text=""):
|
1199
|
+
super(LyricsFrame, self).__init__(id, description, lang, text)
|
1200
|
+
if self.id != LYRICS_FID:
|
1201
|
+
raise ValueError(f"Unexpected frame ID: {self.id}")
|
1202
|
+
|
1203
|
+
|
1204
|
+
class TermsOfUseFrame(Frame, LanguageCodeMixin):
|
1205
|
+
@requireUnicode("text")
|
1206
|
+
def __init__(self, id=b"USER", text="", lang=DEFAULT_LANG):
|
1207
|
+
super(TermsOfUseFrame, self).__init__(id)
|
1208
|
+
self.lang = lang
|
1209
|
+
self.text = text
|
1210
|
+
|
1211
|
+
@property
|
1212
|
+
def text(self):
|
1213
|
+
return self._text
|
1214
|
+
|
1215
|
+
@text.setter
|
1216
|
+
@requireUnicode(1)
|
1217
|
+
def text(self, text):
|
1218
|
+
self._text = text
|
1219
|
+
|
1220
|
+
def parse(self, data, frame_header):
|
1221
|
+
super().parse(data, frame_header)
|
1222
|
+
|
1223
|
+
self.encoding = encoding = self.data[0:1]
|
1224
|
+
self.lang = self.data[1:4]
|
1225
|
+
log.debug("%s lang: %s" % (self.id, self.lang))
|
1226
|
+
self.text = decodeUnicode(self.data[4:], encoding)
|
1227
|
+
log.debug("%s text: %s" % (self.id, self.text))
|
1228
|
+
|
1229
|
+
def render(self):
|
1230
|
+
lang = self._renderLang()
|
1231
|
+
self._initEncoding()
|
1232
|
+
self.data = (self.encoding + lang +
|
1233
|
+
self.text.encode(id3EncodingToString(self.encoding)))
|
1234
|
+
return super(TermsOfUseFrame, self).render()
|
1235
|
+
|
1236
|
+
|
1237
|
+
class TocFrame(Frame):
|
1238
|
+
"""Table of content frame. There may be more than one, but only one may
|
1239
|
+
have the top-level flag set.
|
1240
|
+
|
1241
|
+
Data format:
|
1242
|
+
Element ID: <string>\x00
|
1243
|
+
TOC flags: %000000ab
|
1244
|
+
Entry count: %xx
|
1245
|
+
Child elem IDs: <string>\x00 (... num entry count)
|
1246
|
+
Description: TIT2 frame (optional)
|
1247
|
+
"""
|
1248
|
+
TOP_LEVEL_FLAG_BIT = 6
|
1249
|
+
ORDERED_FLAG_BIT = 7
|
1250
|
+
|
1251
|
+
@requireBytes(1, 2)
|
1252
|
+
def __init__(self, id=TOC_FID, element_id=None, toplevel=True, ordered=True,
|
1253
|
+
child_ids=None, description=None):
|
1254
|
+
if id != TOC_FID:
|
1255
|
+
raise ValueError(f"Unexpected frame ID: {id}")
|
1256
|
+
super().__init__(id)
|
1257
|
+
|
1258
|
+
self.element_id = element_id
|
1259
|
+
self.toplevel = toplevel
|
1260
|
+
self.ordered = ordered
|
1261
|
+
self.child_ids = child_ids or []
|
1262
|
+
self.description = description
|
1263
|
+
|
1264
|
+
def parse(self, data, frame_header):
|
1265
|
+
super().parse(data, frame_header)
|
1266
|
+
|
1267
|
+
data = self.data
|
1268
|
+
log.debug("CTOC frame data size: %d" % len(data))
|
1269
|
+
|
1270
|
+
null_byte = data.find(b'\x00')
|
1271
|
+
self.element_id = data[0:null_byte]
|
1272
|
+
data = data[null_byte + 1:]
|
1273
|
+
|
1274
|
+
flag_bits = bytes2bin(data[0:1])
|
1275
|
+
self.toplevel = bool(flag_bits[self.TOP_LEVEL_FLAG_BIT])
|
1276
|
+
self.ordered = bool(flag_bits[self.ORDERED_FLAG_BIT])
|
1277
|
+
entry_count = bytes2dec(data[1:2])
|
1278
|
+
data = data[2:]
|
1279
|
+
|
1280
|
+
self.child_ids = []
|
1281
|
+
for _ in range(entry_count):
|
1282
|
+
null_byte = data.find(b'\x00')
|
1283
|
+
self.child_ids.append(data[:null_byte])
|
1284
|
+
data = data[null_byte + 1:]
|
1285
|
+
|
1286
|
+
# Any data remaining must be a TIT2 frame
|
1287
|
+
self.description = None
|
1288
|
+
if data and data[:4] != b"TIT2":
|
1289
|
+
log.warning("Invalid toc data, TIT2 frame expected")
|
1290
|
+
return
|
1291
|
+
elif data:
|
1292
|
+
data = BytesIO(data)
|
1293
|
+
frame_header = FrameHeader.parse(data, self.header.version)
|
1294
|
+
data = data.read()
|
1295
|
+
description_frame = TextFrame(TITLE_FID)
|
1296
|
+
description_frame.parse(data, frame_header)
|
1297
|
+
|
1298
|
+
self.description = description_frame.text
|
1299
|
+
|
1300
|
+
def render(self):
|
1301
|
+
flags = [0] * 8
|
1302
|
+
if self.toplevel:
|
1303
|
+
flags[self.TOP_LEVEL_FLAG_BIT] = 1
|
1304
|
+
if self.ordered:
|
1305
|
+
flags[self.ORDERED_FLAG_BIT] = 1
|
1306
|
+
|
1307
|
+
data = (self.element_id + b'\x00' +
|
1308
|
+
bin2bytes(flags) + dec2bytes(len(self.child_ids)))
|
1309
|
+
|
1310
|
+
for cid in self.child_ids:
|
1311
|
+
data += cid + b'\x00'
|
1312
|
+
|
1313
|
+
if self.description is not None:
|
1314
|
+
desc_frame = TextFrame(TITLE_FID, self.description)
|
1315
|
+
desc_frame.header = FrameHeader(TITLE_FID, self.header.version)
|
1316
|
+
data += desc_frame.render()
|
1317
|
+
|
1318
|
+
self.data = data
|
1319
|
+
return super().render()
|
1320
|
+
|
1321
|
+
|
1322
|
+
class RelVolAdjFrameV24(Frame):
|
1323
|
+
CHANNEL_TYPE_OTHER = 0
|
1324
|
+
CHANNEL_TYPE_MASTER = 1
|
1325
|
+
CHANNEL_TYPE_FRONT_RIGHT = 2
|
1326
|
+
CHANNEL_TYPE_FRONT_LEFT = 3
|
1327
|
+
CHANNEL_TYPE_BACK_RIGHT = 4
|
1328
|
+
CHANNEL_TYPE_BACK_LEFT = 5
|
1329
|
+
CHANNEL_TYPE_FRONT_CENTER = 6
|
1330
|
+
CHANNEL_TYPE_BACK_CENTER = 7
|
1331
|
+
CHANNEL_TYPE_BASS = 8
|
1332
|
+
_render_strict = False
|
1333
|
+
|
1334
|
+
@property
|
1335
|
+
def identifier(self):
|
1336
|
+
return str(self._identifier, "latin1")
|
1337
|
+
|
1338
|
+
@identifier.setter
|
1339
|
+
def identifier(self, ident):
|
1340
|
+
if type(ident) is not bytes:
|
1341
|
+
ident = ident.encode("latin1")
|
1342
|
+
self._identifier = ident
|
1343
|
+
|
1344
|
+
@property
|
1345
|
+
def channel_type(self):
|
1346
|
+
return self._channel_type
|
1347
|
+
|
1348
|
+
@channel_type.setter
|
1349
|
+
def channel_type(self, t):
|
1350
|
+
if 0 <= t <= 8:
|
1351
|
+
self._channel_type = t
|
1352
|
+
else:
|
1353
|
+
raise ValueError(f"Invalid type {t}")
|
1354
|
+
|
1355
|
+
@property
|
1356
|
+
def adjustment(self):
|
1357
|
+
return (self._adjustment or 0) / 512
|
1358
|
+
|
1359
|
+
@adjustment.setter
|
1360
|
+
def adjustment(self, adj):
|
1361
|
+
self._adjustment = adj * 512
|
1362
|
+
|
1363
|
+
@property
|
1364
|
+
def peak(self):
|
1365
|
+
return self._peak
|
1366
|
+
|
1367
|
+
@peak.setter
|
1368
|
+
def peak(self, v):
|
1369
|
+
self._peak = v
|
1370
|
+
|
1371
|
+
def __init__(self, fid=b"RVA2", identifier=None, channel_type=None, adjustment=None, peak=None):
|
1372
|
+
if fid != b"RVA2":
|
1373
|
+
raise ValueError(f"Unexpected frame ID: {fid}")
|
1374
|
+
super().__init__(fid)
|
1375
|
+
|
1376
|
+
self.identifier = identifier or ""
|
1377
|
+
self.channel_type = channel_type or self.CHANNEL_TYPE_OTHER
|
1378
|
+
self.adjustment = adjustment or 0
|
1379
|
+
self.peak = peak or 0
|
1380
|
+
|
1381
|
+
def parse(self, data, frame_header):
|
1382
|
+
super().parse(data, frame_header)
|
1383
|
+
if self.header.version != ID3_V2_4:
|
1384
|
+
raise FrameException(f"Invalid frame version: {self.header.version}")
|
1385
|
+
elif not data:
|
1386
|
+
raise FrameException("Invalid frame data: empty")
|
1387
|
+
|
1388
|
+
data = self.data
|
1389
|
+
|
1390
|
+
self.identifier, data = data.split(b"\x00", maxsplit=1)
|
1391
|
+
if not data:
|
1392
|
+
raise FrameException("Invalid frame data: no channel type")
|
1393
|
+
self.channel_type = data[0]
|
1394
|
+
self._adjustment = bytes2signedInt16(data[1:3])
|
1395
|
+
if len(data) > 3:
|
1396
|
+
bits_per_peak = data[3]
|
1397
|
+
if bits_per_peak:
|
1398
|
+
self._peak = bytes2dec(data[4:4 + (bits_per_peak // 8)])
|
1399
|
+
|
1400
|
+
log.debug(f"Parsed RVA2: identifier={self.identifier} channel_type={self.channel_type} "
|
1401
|
+
f"adjustment={self.adjustment} _adjustment={self._adjustment} peak={self.peak}")
|
1402
|
+
|
1403
|
+
def render(self):
|
1404
|
+
if self._channel_type is None:
|
1405
|
+
raise ValueError("No channel type")
|
1406
|
+
if self.header is None:
|
1407
|
+
self.header = FrameHeader(self.id, ID3_V2_4)
|
1408
|
+
if self.header.version != ID3_V2_4:
|
1409
|
+
raise ValueError("Value is not 2.4")
|
1410
|
+
|
1411
|
+
self.data =\
|
1412
|
+
self._identifier + b"\x00" +\
|
1413
|
+
dec2bytes(self._channel_type) +\
|
1414
|
+
signedInt162bytes(self._adjustment or 0)
|
1415
|
+
|
1416
|
+
if self._peak:
|
1417
|
+
peak_data = b""
|
1418
|
+
num_pk_bits = len(dec2bin(self._peak))
|
1419
|
+
for sz in (8, 16, 32):
|
1420
|
+
if num_pk_bits > sz:
|
1421
|
+
continue
|
1422
|
+
peak_data += dec2bytes(sz, 8) + dec2bytes(self._peak, sz)
|
1423
|
+
break
|
1424
|
+
|
1425
|
+
if not peak_data:
|
1426
|
+
raise ValueError(f"Peak value out of range: {self._peak}")
|
1427
|
+
self.data += peak_data
|
1428
|
+
|
1429
|
+
return super().render()
|
1430
|
+
|
1431
|
+
|
1432
|
+
class RelVolAdjFrameV23(Frame):
|
1433
|
+
FRONT_CHANNEL_RIGHT_BIT = 0
|
1434
|
+
FRONT_CHANNEL_LEFT_BIT = 1
|
1435
|
+
BACK_CHANNEL_RIGHT_BIT = 2
|
1436
|
+
BACK_CHANNEL_LEFT_BIT = 3
|
1437
|
+
FRONT_CENTER_CHANNEL_BIT = 4
|
1438
|
+
BASS_CHANNEL_BIT = 5
|
1439
|
+
|
1440
|
+
CHANNEL_DEFN = [("front_right", FRONT_CHANNEL_RIGHT_BIT),
|
1441
|
+
("front_left", FRONT_CHANNEL_LEFT_BIT),
|
1442
|
+
("front_right_peak", None),
|
1443
|
+
("front_left_peak", None),
|
1444
|
+
("back_right", BACK_CHANNEL_RIGHT_BIT),
|
1445
|
+
("back_left", BACK_CHANNEL_LEFT_BIT),
|
1446
|
+
("back_right_peak", None),
|
1447
|
+
("back_left_peak", None),
|
1448
|
+
("front_center", FRONT_CENTER_CHANNEL_BIT),
|
1449
|
+
("front_center_peak", None),
|
1450
|
+
("bass", BASS_CHANNEL_BIT),
|
1451
|
+
("bass_peak", None),
|
1452
|
+
]
|
1453
|
+
|
1454
|
+
@dataclasses.dataclass
|
1455
|
+
class VolumeAdjustments:
|
1456
|
+
master: int = 0
|
1457
|
+
master_peak: int = 0
|
1458
|
+
|
1459
|
+
front_right: int = 0
|
1460
|
+
front_left: int = 0
|
1461
|
+
front_right_peak: int = 0
|
1462
|
+
front_left_peak: int = 0
|
1463
|
+
|
1464
|
+
back_right: int = 0
|
1465
|
+
back_left: int = 0
|
1466
|
+
back_right_peak: int = 0
|
1467
|
+
back_left_peak: int = 0
|
1468
|
+
|
1469
|
+
front_center: int = 0
|
1470
|
+
front_center_peak: int = 0
|
1471
|
+
|
1472
|
+
back_center: int = 0
|
1473
|
+
back_center_peak: int = 0
|
1474
|
+
|
1475
|
+
bass: int = 0
|
1476
|
+
bass_peak: int = 0
|
1477
|
+
|
1478
|
+
other: int = 0
|
1479
|
+
other_peak: int = 0
|
1480
|
+
|
1481
|
+
_channel_map = {
|
1482
|
+
RelVolAdjFrameV24.CHANNEL_TYPE_MASTER: "master",
|
1483
|
+
RelVolAdjFrameV24.CHANNEL_TYPE_OTHER: "other",
|
1484
|
+
RelVolAdjFrameV24.CHANNEL_TYPE_FRONT_RIGHT: "front_right",
|
1485
|
+
RelVolAdjFrameV24.CHANNEL_TYPE_FRONT_LEFT: "front_left",
|
1486
|
+
RelVolAdjFrameV24.CHANNEL_TYPE_BACK_RIGHT: "back_right",
|
1487
|
+
RelVolAdjFrameV24.CHANNEL_TYPE_BACK_LEFT: "back_left",
|
1488
|
+
RelVolAdjFrameV24.CHANNEL_TYPE_FRONT_CENTER: "front_center",
|
1489
|
+
RelVolAdjFrameV24.CHANNEL_TYPE_BACK_CENTER: "back_center",
|
1490
|
+
RelVolAdjFrameV24.CHANNEL_TYPE_BASS: "bass",
|
1491
|
+
}
|
1492
|
+
|
1493
|
+
@property
|
1494
|
+
def has_master_channel(self) -> bool:
|
1495
|
+
return bool(self.master or self.master_peak)
|
1496
|
+
|
1497
|
+
@property
|
1498
|
+
def has_front_channel(self) -> bool:
|
1499
|
+
return bool(
|
1500
|
+
self.front_right or self.front_left or self.front_right_peak or self.front_left_peak
|
1501
|
+
)
|
1502
|
+
|
1503
|
+
@property
|
1504
|
+
def has_back_channel(self) -> bool:
|
1505
|
+
return bool(
|
1506
|
+
self.back_right or self.back_left or self.back_right_peak or self.back_left_peak
|
1507
|
+
)
|
1508
|
+
|
1509
|
+
@property
|
1510
|
+
def has_front_center_channel(self) -> bool:
|
1511
|
+
return bool(self.front_center or self.front_center_peak)
|
1512
|
+
|
1513
|
+
@property
|
1514
|
+
def has_back_center_channel(self) -> bool:
|
1515
|
+
return bool(self.back_center or self.back_center_peak)
|
1516
|
+
|
1517
|
+
@property
|
1518
|
+
def has_bass_channel(self) -> bool:
|
1519
|
+
return bool(self.bass or self.bass_peak)
|
1520
|
+
|
1521
|
+
@property
|
1522
|
+
def has_other_channel(self) -> bool:
|
1523
|
+
return bool(self.other or self.other_peak)
|
1524
|
+
|
1525
|
+
def boundsCheck(self):
|
1526
|
+
invalids = []
|
1527
|
+
for name, value in dataclasses.asdict(self).items():
|
1528
|
+
|
1529
|
+
if value > 65536 or value < -65536:
|
1530
|
+
invalids.append(name)
|
1531
|
+
if invalids:
|
1532
|
+
raise ValueError(f"Invalid RVAD channel values: {','.join(invalids)}")
|
1533
|
+
|
1534
|
+
def setChannelAdj(self, chan_type, value):
|
1535
|
+
setattr(self, self._channel_map[chan_type], value)
|
1536
|
+
|
1537
|
+
def setChannelPeak(self, chan_type, value):
|
1538
|
+
setattr(self, f"{self._channel_map[chan_type]}_peak", value)
|
1539
|
+
|
1540
|
+
def __init__(self, fid=b"RVAD"):
|
1541
|
+
if fid != b"RVAD":
|
1542
|
+
raise ValueError(f"Unexpected frame ID: {fid}")
|
1543
|
+
super().__init__(fid)
|
1544
|
+
self.adjustments = None
|
1545
|
+
|
1546
|
+
def toV24(self) -> list:
|
1547
|
+
"""Return a list of RVA2 frames"""
|
1548
|
+
converted = []
|
1549
|
+
|
1550
|
+
def append(ch_type, ch_adj, ch_peak):
|
1551
|
+
if not ch_adj and not ch_peak:
|
1552
|
+
return
|
1553
|
+
converted.append(
|
1554
|
+
RelVolAdjFrameV24(channel_type=ch_type, adjustment=ch_adj / 512, peak=ch_peak)
|
1555
|
+
)
|
1556
|
+
|
1557
|
+
for channel in ["front_right", "front_left", "back_right", "back_left",
|
1558
|
+
"front_center", "bass"]:
|
1559
|
+
chtype = getattr(RelVolAdjFrameV24, f"CHANNEL_TYPE_{channel.upper()}")
|
1560
|
+
adj = getattr(self.adjustments, channel)
|
1561
|
+
pk = getattr(self.adjustments, f"{channel}_peak")
|
1562
|
+
|
1563
|
+
append(chtype, adj, pk)
|
1564
|
+
|
1565
|
+
return converted
|
1566
|
+
|
1567
|
+
def parse(self, data, frame_header):
|
1568
|
+
super().parse(data, frame_header)
|
1569
|
+
if self.header.version not in (ID3_V2_3, ID3_V2_2):
|
1570
|
+
raise FrameException("Invalid v2.4 frame: RVAD")
|
1571
|
+
data = self.data
|
1572
|
+
|
1573
|
+
inc_dec_bit_list = bytes2bin(bytes([data[0]]))
|
1574
|
+
inc_dec_bit_list.reverse()
|
1575
|
+
bytes_per_vol = data[1] // 8
|
1576
|
+
if bytes_per_vol > 2:
|
1577
|
+
raise FrameException("RVAD volume adj out of bounds")
|
1578
|
+
|
1579
|
+
self.adjustments = self.VolumeAdjustments()
|
1580
|
+
offset = 2
|
1581
|
+
for adj_name, inc_dec_bit in self.CHANNEL_DEFN:
|
1582
|
+
if offset >= len(data):
|
1583
|
+
break
|
1584
|
+
|
1585
|
+
adj_val = bytes2dec(data[offset:offset + bytes_per_vol])
|
1586
|
+
offset += bytes_per_vol
|
1587
|
+
|
1588
|
+
if (inc_dec_bit is not None
|
1589
|
+
and adj_val
|
1590
|
+
and inc_dec_bit_list[inc_dec_bit] == 0):
|
1591
|
+
# Decrement
|
1592
|
+
adj_val = -adj_val
|
1593
|
+
|
1594
|
+
setattr(self.adjustments, adj_name, adj_val)
|
1595
|
+
|
1596
|
+
try:
|
1597
|
+
log.debug(f"Parsed RVAD frames adjustments: {self.adjustments}")
|
1598
|
+
self.adjustments.boundsCheck()
|
1599
|
+
except ValueError: # pragma: nocover
|
1600
|
+
self.adjustments = None
|
1601
|
+
raise
|
1602
|
+
|
1603
|
+
def render(self):
|
1604
|
+
data = b""
|
1605
|
+
inc_dec_bits = [0] * 8
|
1606
|
+
|
1607
|
+
if self.header is None:
|
1608
|
+
self.header = FrameHeader(self.id, ID3_V2_3)
|
1609
|
+
if self.header.version != ID3_V2_3:
|
1610
|
+
raise ValueError("Version is not 2.3")
|
1611
|
+
|
1612
|
+
self.adjustments.boundsCheck() # May raise ValueError
|
1613
|
+
|
1614
|
+
# Only the front channel is required
|
1615
|
+
inc_dec_bits[self.FRONT_CHANNEL_RIGHT_BIT] = 1 if self.adjustments.front_right > 0 else 0
|
1616
|
+
inc_dec_bits[self.FRONT_CHANNEL_LEFT_BIT] = 1 if self.adjustments.front_left > 0 else 0
|
1617
|
+
data += dec2bytes(abs(self.adjustments.front_right), p=16)
|
1618
|
+
data += dec2bytes(abs(self.adjustments.front_left), p=16)
|
1619
|
+
data += dec2bytes(abs(self.adjustments.front_right_peak), p=16)
|
1620
|
+
data += dec2bytes(abs(self.adjustments.front_left_peak), p=16)
|
1621
|
+
|
1622
|
+
# Back channel
|
1623
|
+
if True in (self.adjustments.has_bass_channel, self.adjustments.has_front_center_channel,
|
1624
|
+
self.adjustments.has_back_channel):
|
1625
|
+
inc_dec_bits[self.BACK_CHANNEL_RIGHT_BIT] = 1 if self.adjustments.back_right > 0 else 0
|
1626
|
+
inc_dec_bits[self.BACK_CHANNEL_LEFT_BIT] = 1 if self.adjustments.back_left > 0 else 0
|
1627
|
+
data += dec2bytes(abs(self.adjustments.back_right), p=16)
|
1628
|
+
data += dec2bytes(abs(self.adjustments.back_left), p=16)
|
1629
|
+
data += dec2bytes(abs(self.adjustments.back_right_peak), p=16)
|
1630
|
+
data += dec2bytes(abs(self.adjustments.back_left_peak), p=16)
|
1631
|
+
|
1632
|
+
# Center (front) channel
|
1633
|
+
if True in (self.adjustments.has_bass_channel, self.adjustments.has_front_center_channel):
|
1634
|
+
inc_dec_bits[self.FRONT_CENTER_CHANNEL_BIT] = 1 if self.adjustments.front_center > 0 \
|
1635
|
+
else 0
|
1636
|
+
data += dec2bytes(abs(self.adjustments.front_center), p=16)
|
1637
|
+
data += dec2bytes(abs(self.adjustments.front_center_peak), p=16)
|
1638
|
+
|
1639
|
+
# Bass channel
|
1640
|
+
if self.adjustments.has_bass_channel:
|
1641
|
+
inc_dec_bits[self.BASS_CHANNEL_BIT] = 1 if self.adjustments.bass > 0 else 0
|
1642
|
+
data += dec2bytes(abs(self.adjustments.bass), p=16)
|
1643
|
+
data += dec2bytes(abs(self.adjustments.bass_peak), p=16)
|
1644
|
+
|
1645
|
+
self.data = bin2bytes(reversed(inc_dec_bits)) + b"\x10" + data
|
1646
|
+
return super().render()
|
1647
|
+
|
1648
|
+
|
1649
|
+
StartEndTuple = namedtuple("StartEndTuple", ["start", "end"])
|
1650
|
+
"""A 2-tuple, with names 'start' and 'end'."""
|
1651
|
+
|
1652
|
+
|
1653
|
+
class ChapterFrame(Frame):
|
1654
|
+
"""Frame type for chapter/section of the audio file.
|
1655
|
+
<ID3v2.3 or ID3v2.4 frame header, ID: "CHAP"> (10 bytes)
|
1656
|
+
Element ID <text string> $00
|
1657
|
+
Start time $xx xx xx xx
|
1658
|
+
End time $xx xx xx xx
|
1659
|
+
Start offset $xx xx xx xx
|
1660
|
+
End offset $xx xx xx xx
|
1661
|
+
<Optional embedded sub-frames>
|
1662
|
+
"""
|
1663
|
+
|
1664
|
+
NO_OFFSET = 4294967295
|
1665
|
+
"""No offset value, aka '0xff0xff0xff0xff'"""
|
1666
|
+
|
1667
|
+
def __init__(self, id=CHAPTER_FID, element_id=None, times=None,
|
1668
|
+
offsets=None, sub_frames=None):
|
1669
|
+
if id != CHAPTER_FID:
|
1670
|
+
raise ValueError(f"Unexpected frame ID: {id}")
|
1671
|
+
super(ChapterFrame, self).__init__(id)
|
1672
|
+
self.element_id = element_id
|
1673
|
+
self.times = times or StartEndTuple(None, None)
|
1674
|
+
self.offsets = offsets or StartEndTuple(None, None)
|
1675
|
+
self.sub_frames = sub_frames or FrameSet()
|
1676
|
+
|
1677
|
+
def parse(self, data, frame_header):
|
1678
|
+
from .headers import TagHeader, ExtendedTagHeader
|
1679
|
+
|
1680
|
+
super().parse(data, frame_header)
|
1681
|
+
|
1682
|
+
data = self.data
|
1683
|
+
log.debug("CTOC frame data size: %d" % len(data))
|
1684
|
+
|
1685
|
+
null_byte = data.find(b'\x00')
|
1686
|
+
self.element_id = data[0:null_byte]
|
1687
|
+
data = data[null_byte + 1:]
|
1688
|
+
|
1689
|
+
start = bytes2dec(data[:4])
|
1690
|
+
data = data[4:]
|
1691
|
+
end = bytes2dec(data[:4])
|
1692
|
+
data = data[4:]
|
1693
|
+
self.times = StartEndTuple(start, end)
|
1694
|
+
|
1695
|
+
start = bytes2dec(data[:4])
|
1696
|
+
data = data[4:]
|
1697
|
+
end = bytes2dec(data[:4])
|
1698
|
+
data = data[4:]
|
1699
|
+
self.offsets = StartEndTuple(start if start != self.NO_OFFSET else None,
|
1700
|
+
end if end != self.NO_OFFSET else None)
|
1701
|
+
|
1702
|
+
if data:
|
1703
|
+
dummy_tag_header = TagHeader(self.header.version)
|
1704
|
+
dummy_tag_header.tag_size = len(data)
|
1705
|
+
_ = self.sub_frames.parse(BytesIO(data), dummy_tag_header, # noqa
|
1706
|
+
ExtendedTagHeader())
|
1707
|
+
else:
|
1708
|
+
self.sub_frames = FrameSet()
|
1709
|
+
|
1710
|
+
def render(self):
|
1711
|
+
data = self.element_id + b'\x00'
|
1712
|
+
|
1713
|
+
for n in self.times + self.offsets:
|
1714
|
+
if n is not None:
|
1715
|
+
data += dec2bytes(n, 32)
|
1716
|
+
else:
|
1717
|
+
data += b'\xff\xff\xff\xff'
|
1718
|
+
|
1719
|
+
for f in self.sub_frames.getAllFrames():
|
1720
|
+
f.header = FrameHeader(f.id, self.header.version)
|
1721
|
+
data += f.render()
|
1722
|
+
|
1723
|
+
self.data = data
|
1724
|
+
return super(ChapterFrame, self).render()
|
1725
|
+
|
1726
|
+
@property
|
1727
|
+
def title(self):
|
1728
|
+
if TITLE_FID in self.sub_frames:
|
1729
|
+
return self.sub_frames[TITLE_FID][0].text
|
1730
|
+
return None
|
1731
|
+
|
1732
|
+
@title.setter
|
1733
|
+
def title(self, title):
|
1734
|
+
self.sub_frames.setTextFrame(TITLE_FID, title)
|
1735
|
+
|
1736
|
+
@property
|
1737
|
+
def subtitle(self):
|
1738
|
+
if SUBTITLE_FID in self.sub_frames:
|
1739
|
+
return self.sub_frames[SUBTITLE_FID][0].text
|
1740
|
+
return None
|
1741
|
+
|
1742
|
+
@subtitle.setter
|
1743
|
+
def subtitle(self, subtitle):
|
1744
|
+
self.sub_frames.setTextFrame(SUBTITLE_FID, subtitle)
|
1745
|
+
|
1746
|
+
@property
|
1747
|
+
def user_url(self):
|
1748
|
+
if USERURL_FID in self.sub_frames:
|
1749
|
+
frame = self.sub_frames[USERURL_FID][0]
|
1750
|
+
# Not returning frame description, it is always the same since it
|
1751
|
+
# allows only 1 URL.
|
1752
|
+
return frame.url
|
1753
|
+
return None
|
1754
|
+
|
1755
|
+
@user_url.setter
|
1756
|
+
def user_url(self, url):
|
1757
|
+
DESCRIPTION = "chapter url"
|
1758
|
+
|
1759
|
+
if url is None:
|
1760
|
+
del self.sub_frames[USERURL_FID]
|
1761
|
+
else:
|
1762
|
+
if USERURL_FID in self.sub_frames:
|
1763
|
+
for frame in self.sub_frames[USERURL_FID]:
|
1764
|
+
if frame.description == DESCRIPTION:
|
1765
|
+
frame.url = url
|
1766
|
+
return
|
1767
|
+
|
1768
|
+
self.sub_frames[USERURL_FID] = UserUrlFrame(USERURL_FID,
|
1769
|
+
DESCRIPTION, url)
|
1770
|
+
|
1771
|
+
|
1772
|
+
# XXX: This data structure pretty much sucks, or it is beautiful anarchy
|
1773
|
+
class FrameSet(dict):
|
1774
|
+
def __init__(self):
|
1775
|
+
dict.__init__(self)
|
1776
|
+
self._unknown_frame_ids = set()
|
1777
|
+
|
1778
|
+
def parse(self, f, tag_header, extended_header):
|
1779
|
+
"""Read frames starting from the current read position of the file
|
1780
|
+
object. Returns the amount of padding which occurs after the tag, but
|
1781
|
+
before the audio content. A return value of 0 does not mean error."""
|
1782
|
+
self.clear()
|
1783
|
+
self._unknown_frame_ids.clear()
|
1784
|
+
|
1785
|
+
padding_size = 0
|
1786
|
+
size_left = tag_header.tag_size - extended_header.size
|
1787
|
+
consumed_size = 0
|
1788
|
+
|
1789
|
+
# Handle a tag-level unsync. Some frames may have their own unsync bit
|
1790
|
+
# set instead.
|
1791
|
+
tag_data = f.read(size_left)
|
1792
|
+
|
1793
|
+
# If the tag is 2.3 and the tag header unsync bit is set then all the
|
1794
|
+
# frame data is deunsync'd at once, otherwise it will happen on a per-frame basis.
|
1795
|
+
if tag_header.unsync and tag_header.version <= ID3_V2_3:
|
1796
|
+
log.debug("De-unsynching %d bytes at once (<= 2.3 tag)" %
|
1797
|
+
len(tag_data))
|
1798
|
+
og_size = len(tag_data)
|
1799
|
+
tag_data = deunsyncData(tag_data)
|
1800
|
+
size_left = len(tag_data)
|
1801
|
+
log.debug("De-unsynch'd %d bytes at once (<= 2.3 tag) to %d bytes" %
|
1802
|
+
(og_size, size_left))
|
1803
|
+
|
1804
|
+
# Adding bytes to simulate the tag header(s) in the buffer. This keeps
|
1805
|
+
# f.tell() values matching the file offsets for logging.
|
1806
|
+
prepadding = b'\x00' * 10 # Tag header
|
1807
|
+
prepadding += b'\x00' * extended_header.size
|
1808
|
+
tag_buffer = BytesIO(prepadding + tag_data)
|
1809
|
+
tag_buffer.seek(len(prepadding))
|
1810
|
+
|
1811
|
+
frame_count = 0
|
1812
|
+
while size_left > 0:
|
1813
|
+
log.debug("size_left: " + str(size_left))
|
1814
|
+
if size_left < (10 + 1): # The size of the smallest frame.
|
1815
|
+
log.debug("FrameSet: Implied padding (size_left<minFrameSize)")
|
1816
|
+
padding_size = size_left
|
1817
|
+
break
|
1818
|
+
|
1819
|
+
log.debug("+++++++++++++++++++++++++++++++++++++++++++++++++")
|
1820
|
+
log.debug("FrameSet: Reading Frame #" + str(frame_count + 1))
|
1821
|
+
frame_header = FrameHeader.parse(tag_buffer, tag_header.version)
|
1822
|
+
if not frame_header:
|
1823
|
+
log.debug("No frame found, implied padding of %d bytes" %
|
1824
|
+
size_left)
|
1825
|
+
padding_size = size_left
|
1826
|
+
break
|
1827
|
+
|
1828
|
+
# Frame data.
|
1829
|
+
if frame_header.data_size:
|
1830
|
+
log.debug("FrameSet: Reading %d (0x%X) bytes of data from byte "
|
1831
|
+
"pos %d (0x%X)" % (frame_header.data_size,
|
1832
|
+
frame_header.data_size,
|
1833
|
+
tag_buffer.tell(),
|
1834
|
+
tag_buffer.tell()))
|
1835
|
+
data = tag_buffer.read(frame_header.data_size)
|
1836
|
+
|
1837
|
+
log.debug("FrameSet: %d bytes of data read" % len(data))
|
1838
|
+
consumed_size += (frame_header.size +
|
1839
|
+
frame_header.data_size)
|
1840
|
+
try:
|
1841
|
+
frame = createFrame(tag_header, frame_header, data)
|
1842
|
+
except FrameException as frame_ex:
|
1843
|
+
log.warning(f"Frame error: {frame_ex}")
|
1844
|
+
else:
|
1845
|
+
self[frame.id] = frame
|
1846
|
+
frame_count += 1
|
1847
|
+
if frame.unknown:
|
1848
|
+
self._unknown_frame_ids.add(frame.id)
|
1849
|
+
|
1850
|
+
# Each frame contains data_size + headerSize bytes.
|
1851
|
+
size_left -= (frame_header.size +
|
1852
|
+
frame_header.data_size)
|
1853
|
+
|
1854
|
+
return padding_size
|
1855
|
+
|
1856
|
+
@requireBytes(1)
|
1857
|
+
def __getitem__(self, fid):
|
1858
|
+
if fid in self:
|
1859
|
+
return dict.__getitem__(self, fid)
|
1860
|
+
else:
|
1861
|
+
return None
|
1862
|
+
|
1863
|
+
@requireBytes(1)
|
1864
|
+
def __setitem__(self, fid, frame):
|
1865
|
+
if fid != frame.id:
|
1866
|
+
raise ValueError(f"Frame ID mismatch: {fid} != {frame.id}")
|
1867
|
+
|
1868
|
+
if fid in self:
|
1869
|
+
self[fid].append(frame)
|
1870
|
+
else:
|
1871
|
+
dict.__setitem__(self, fid, [frame])
|
1872
|
+
|
1873
|
+
@property
|
1874
|
+
def unknown_frame_ids(self):
|
1875
|
+
return self._unknown_frame_ids
|
1876
|
+
|
1877
|
+
def getAllFrames(self):
|
1878
|
+
"""Return all the frames in the set as a list. The list is sorted
|
1879
|
+
in an arbitrary but consistent order."""
|
1880
|
+
frames = []
|
1881
|
+
for flist in list(self.values()):
|
1882
|
+
frames += flist
|
1883
|
+
frames.sort()
|
1884
|
+
return frames
|
1885
|
+
|
1886
|
+
@requireBytes(1)
|
1887
|
+
@requireUnicode(2)
|
1888
|
+
def setTextFrame(self, fid, text):
|
1889
|
+
"""Set a text frame value.
|
1890
|
+
Text frame IDs must be unique. If a frame with
|
1891
|
+
the same Id is already in the list it's value is changed, otherwise
|
1892
|
+
the frame is added.
|
1893
|
+
"""
|
1894
|
+
if fid not in ID3_FRAMES and fid not in NONSTANDARD_ID3_FRAMES:
|
1895
|
+
raise ValueError(f"Invalid frame ID: {fid}")
|
1896
|
+
|
1897
|
+
if fid in self:
|
1898
|
+
self[fid][0].text = text
|
1899
|
+
else:
|
1900
|
+
if fid in (DATE_FIDS + DEPRECATED_DATE_FIDS):
|
1901
|
+
self[fid] = DateFrame(fid, date=text)
|
1902
|
+
else:
|
1903
|
+
self[fid] = TextFrame(fid, text=text)
|
1904
|
+
|
1905
|
+
@requireBytes(1)
|
1906
|
+
def __contains__(self, fid):
|
1907
|
+
return dict.__contains__(self, fid)
|
1908
|
+
|
1909
|
+
|
1910
|
+
def deunsyncData(data):
|
1911
|
+
output = []
|
1912
|
+
safe = True
|
1913
|
+
for val in [bytes([b]) for b in data]:
|
1914
|
+
if safe:
|
1915
|
+
output.append(val)
|
1916
|
+
safe = (val != b'\xff')
|
1917
|
+
else:
|
1918
|
+
if val != b'\x00':
|
1919
|
+
output.append(val)
|
1920
|
+
safe = True
|
1921
|
+
return b''.join(output)
|
1922
|
+
|
1923
|
+
|
1924
|
+
# Create and return the appropriate frame.
|
1925
|
+
def createFrame(tag_header, frame_header, data):
|
1926
|
+
fid = frame_header.id
|
1927
|
+
if fid in ID3_FRAMES:
|
1928
|
+
(desc, ver, FrameClass) = ID3_FRAMES[fid]
|
1929
|
+
elif fid in NONSTANDARD_ID3_FRAMES:
|
1930
|
+
log.verbose("Non standard frame '%s' encountered" % fid)
|
1931
|
+
(desc, ver, FrameClass) = NONSTANDARD_ID3_FRAMES[fid]
|
1932
|
+
else:
|
1933
|
+
log.warning(f"Unknown ID3 frame ID: {fid}")
|
1934
|
+
(desc, ver, FrameClass) = ("Unknown", None, UnknownFrame)
|
1935
|
+
log.debug(f"createFrame (desc:{desc}) - {ver} - {FrameClass}")
|
1936
|
+
|
1937
|
+
# FrameClass may still be None if the frame is standard but does not
|
1938
|
+
# yet have a concrete type.
|
1939
|
+
if not FrameClass:
|
1940
|
+
log.warning(f"Frame '{fid.decode('ascii')}' is not yet supported, using raw Frame to parse")
|
1941
|
+
FrameClass = Frame
|
1942
|
+
|
1943
|
+
log.debug(f"createFrame '{fid}' with class '{FrameClass}'")
|
1944
|
+
if tag_header.version[:2] == ID3_V2_4 and tag_header.unsync:
|
1945
|
+
frame_header.unsync = True
|
1946
|
+
|
1947
|
+
frame = FrameClass(fid)
|
1948
|
+
frame.parse(data, frame_header)
|
1949
|
+
return frame
|
1950
|
+
|
1951
|
+
|
1952
|
+
def decodeUnicode(bites, encoding):
|
1953
|
+
for obj, obj_name in ((bites, "bites"), (encoding, "encoding")):
|
1954
|
+
if not isinstance(obj, bytes):
|
1955
|
+
raise TypeError("%s argument must be a byte string." % obj_name)
|
1956
|
+
|
1957
|
+
codec = id3EncodingToString(encoding)
|
1958
|
+
log.debug("Unicode encoding: %s" % codec)
|
1959
|
+
if (codec.startswith("utf_16") and
|
1960
|
+
len(bites) % 2 != 0 and bites[-1:] == b"\x00"):
|
1961
|
+
# Catch and fix bad utf16 data, it is everywhere.
|
1962
|
+
log.warning("Fixing utf16 data with extra zero bytes")
|
1963
|
+
bites = bites[:-1]
|
1964
|
+
return str(bites, codec).rstrip("\x00")
|
1965
|
+
|
1966
|
+
|
1967
|
+
def splitUnicode(data, encoding):
|
1968
|
+
try:
|
1969
|
+
if encoding == LATIN1_ENCODING or encoding == UTF_8_ENCODING:
|
1970
|
+
(d, t) = data.split(b"\x00", 1)
|
1971
|
+
elif encoding == UTF_16_ENCODING or encoding == UTF_16BE_ENCODING:
|
1972
|
+
# Two null bytes split, but since each utf16 char is also two
|
1973
|
+
# bytes we need to ensure we found a proper boundary.
|
1974
|
+
(d, t) = data.split(b"\x00\x00", 1)
|
1975
|
+
if (len(d) % 2) != 0:
|
1976
|
+
(d, t) = data.split(b"\x00\x00\x00", 1)
|
1977
|
+
d += b"\x00"
|
1978
|
+
else:
|
1979
|
+
raise NotImplementedError(f"Unknown ID3 encoding: {encoding}")
|
1980
|
+
except ValueError as ex:
|
1981
|
+
log.warning(f"Invalid 2-tuple ID3 frame data: {ex}")
|
1982
|
+
d, t = data, b""
|
1983
|
+
|
1984
|
+
return d, t
|
1985
|
+
|
1986
|
+
|
1987
|
+
def id3EncodingToString(encoding):
|
1988
|
+
if not isinstance(encoding, bytes):
|
1989
|
+
raise TypeError("encoding argument must be a byte string.")
|
1990
|
+
|
1991
|
+
if encoding == LATIN1_ENCODING:
|
1992
|
+
return "latin_1"
|
1993
|
+
elif encoding == UTF_8_ENCODING:
|
1994
|
+
return "utf_8"
|
1995
|
+
elif encoding == UTF_16_ENCODING:
|
1996
|
+
return "utf_16"
|
1997
|
+
elif encoding == UTF_16BE_ENCODING:
|
1998
|
+
return "utf_16_be"
|
1999
|
+
else:
|
2000
|
+
raise ValueError("Encoding unknown: %s" % encoding)
|
2001
|
+
|
2002
|
+
|
2003
|
+
def stringToEncoding(s):
|
2004
|
+
s = s.replace('-', '_')
|
2005
|
+
if s in ("latin_1", "latin1"):
|
2006
|
+
return LATIN1_ENCODING
|
2007
|
+
elif s in ("utf_8", "utf8"):
|
2008
|
+
return UTF_8_ENCODING
|
2009
|
+
elif s in ("utf_16", "utf16"):
|
2010
|
+
return UTF_16_ENCODING
|
2011
|
+
elif s in ("utf_16_be", "utf16_be"):
|
2012
|
+
return UTF_16BE_ENCODING
|
2013
|
+
else:
|
2014
|
+
raise ValueError("Encoding unknown: %s" % s)
|
2015
|
+
|
2016
|
+
|
2017
|
+
# { frame-id : (frame-description, valid-id3-version, frame-class) }
|
2018
|
+
ID3_FRAMES = {b"AENC": ("Audio encryption",
|
2019
|
+
ID3_V2,
|
2020
|
+
None),
|
2021
|
+
b"APIC": ("Attached picture",
|
2022
|
+
ID3_V2,
|
2023
|
+
ImageFrame),
|
2024
|
+
b"ASPI": ("Audio seek point index",
|
2025
|
+
ID3_V2_4,
|
2026
|
+
None),
|
2027
|
+
|
2028
|
+
b"COMM": ("Comments", ID3_V2, CommentFrame),
|
2029
|
+
b"COMR": ("Commercial frame", ID3_V2, None),
|
2030
|
+
|
2031
|
+
b"CTOC": ("Table of contents", ID3_V2, TocFrame),
|
2032
|
+
b"CHAP": ("Chapter", ID3_V2, ChapterFrame),
|
2033
|
+
|
2034
|
+
b"ENCR": ("Encryption method registration", ID3_V2, None),
|
2035
|
+
b"EQUA": ("Equalisation", ID3_V2_3, None),
|
2036
|
+
b"EQU2": ("Equalisation (2)", ID3_V2_4, None),
|
2037
|
+
b"ETCO": ("Event timing codes", ID3_V2, None),
|
2038
|
+
|
2039
|
+
b"GEOB": ("General encapsulated object", ID3_V2, ObjectFrame),
|
2040
|
+
b"GRID": ("Group identification registration", ID3_V2, None),
|
2041
|
+
|
2042
|
+
b"IPLS": ("Involved people list", ID3_V2_3, None),
|
2043
|
+
|
2044
|
+
b"LINK": ("Linked information", ID3_V2, None),
|
2045
|
+
|
2046
|
+
b"MCDI": ("Music CD identifier", ID3_V2, MusicCDIdFrame),
|
2047
|
+
b"MLLT": ("MPEG location lookup table", ID3_V2, None),
|
2048
|
+
|
2049
|
+
b"OWNE": ("Ownership frame", ID3_V2, None),
|
2050
|
+
|
2051
|
+
b"PRIV": ("Private frame", ID3_V2, PrivateFrame),
|
2052
|
+
b"PCNT": ("Play counter", ID3_V2, PlayCountFrame),
|
2053
|
+
b"POPM": ("Popularimeter", ID3_V2, PopularityFrame),
|
2054
|
+
b"POSS": ("Position synchronisation frame", ID3_V2, None),
|
2055
|
+
|
2056
|
+
b"RBUF": ("Recommended buffer size", ID3_V2, None),
|
2057
|
+
b"RVAD": ("Relative volume adjustment", ID3_V2_3, RelVolAdjFrameV23),
|
2058
|
+
b"RVA2": ("Relative volume adjustment (2)", ID3_V2_4, RelVolAdjFrameV24),
|
2059
|
+
b"RVRB": ("Reverb", ID3_V2, None),
|
2060
|
+
|
2061
|
+
b"SEEK": ("Seek frame", ID3_V2_4, None),
|
2062
|
+
b"SIGN": ("Signature frame", ID3_V2_4, None),
|
2063
|
+
b"SYLT": ("Synchronised lyric/text", ID3_V2, None),
|
2064
|
+
b"SYTC": ("Synchronised tempo codes", ID3_V2, None),
|
2065
|
+
|
2066
|
+
b"TALB": ("Album/Movie/Show title", ID3_V2, TextFrame),
|
2067
|
+
b"TBPM": ("BPM (beats per minute)", ID3_V2, TextFrame),
|
2068
|
+
b"TCOM": ("Composer", ID3_V2, TextFrame),
|
2069
|
+
b"TCON": ("Content type", ID3_V2, TextFrame),
|
2070
|
+
b"TCOP": ("Copyright message", ID3_V2, TextFrame),
|
2071
|
+
b"TDAT": ("Date", ID3_V2_3, DateFrame),
|
2072
|
+
b"TDEN": ("Encoding time", ID3_V2_4, DateFrame),
|
2073
|
+
b"TDLY": ("Playlist delay", ID3_V2, TextFrame),
|
2074
|
+
b"TDOR": ("Original release time", ID3_V2_4, DateFrame),
|
2075
|
+
b"TDRC": ("Recording time", ID3_V2_4, DateFrame),
|
2076
|
+
b"TDRL": ("Release time", ID3_V2_4, DateFrame),
|
2077
|
+
b"TDTG": ("Tagging time", ID3_V2_4, DateFrame),
|
2078
|
+
b"TENC": ("Encoded by", ID3_V2, TextFrame),
|
2079
|
+
b"TEXT": ("Lyricist/Text writer", ID3_V2, TextFrame),
|
2080
|
+
b"TFLT": ("File type", ID3_V2, TextFrame),
|
2081
|
+
b"TIME": ("Time", ID3_V2_3, DateFrame),
|
2082
|
+
b"TIPL": ("Involved people list", ID3_V2_4, TextFrame),
|
2083
|
+
b"TIT1": ("Content group description", ID3_V2, TextFrame),
|
2084
|
+
b"TIT2": ("Title/songname/content description", ID3_V2,
|
2085
|
+
TextFrame),
|
2086
|
+
b"TIT3": ("Subtitle/Description refinement", ID3_V2, TextFrame),
|
2087
|
+
b"TKEY": ("Initial key", ID3_V2, TextFrame),
|
2088
|
+
b"TLAN": ("Language(s)", ID3_V2, TextFrame),
|
2089
|
+
b"TLEN": ("Length", ID3_V2, TextFrame),
|
2090
|
+
b"TMCL": ("Musician credits list", ID3_V2_4, TextFrame),
|
2091
|
+
b"TMED": ("Media type", ID3_V2, TextFrame),
|
2092
|
+
b"TMOO": ("Mood", ID3_V2_4, TextFrame),
|
2093
|
+
b"TOAL": ("Original album/movie/show title", ID3_V2, TextFrame),
|
2094
|
+
b"TOFN": ("Original filename", ID3_V2, TextFrame),
|
2095
|
+
b"TOLY": ("Original lyricist(s)/text writer(s)", ID3_V2,
|
2096
|
+
TextFrame),
|
2097
|
+
b"TOPE": ("Original artist(s)/performer(s)", ID3_V2, TextFrame),
|
2098
|
+
b"TORY": ("Original release year", ID3_V2_3, DateFrame),
|
2099
|
+
b"TOWN": ("File owner/licensee", ID3_V2, TextFrame),
|
2100
|
+
b"TPE1": ("Lead performer(s)/Soloist(s)", ID3_V2, TextFrame),
|
2101
|
+
b"TPE2": ("Band/orchestra/accompaniment", ID3_V2, TextFrame),
|
2102
|
+
b"TPE3": ("Conductor/performer refinement", ID3_V2, TextFrame),
|
2103
|
+
b"TPE4": ("Interpreted, remixed, or otherwise modified by",
|
2104
|
+
ID3_V2, TextFrame),
|
2105
|
+
b"TPOS": ("Part of a set", ID3_V2, TextFrame),
|
2106
|
+
b"TPRO": ("Produced notice", ID3_V2_4, TextFrame),
|
2107
|
+
b"TPUB": ("Publisher", ID3_V2, TextFrame),
|
2108
|
+
b"TRCK": ("Track number/Position in set", ID3_V2, TextFrame),
|
2109
|
+
b"TRDA": ("Recording dates", ID3_V2_3, DateFrame),
|
2110
|
+
b"TRSN": ("Internet radio station name", ID3_V2, TextFrame),
|
2111
|
+
b"TRSO": ("Internet radio station owner", ID3_V2, TextFrame),
|
2112
|
+
b"TSOA": ("Album sort order", ID3_V2_4, TextFrame),
|
2113
|
+
b"TSOP": ("Performer sort order", ID3_V2_4, TextFrame),
|
2114
|
+
b"TSOT": ("Title sort order", ID3_V2_4, TextFrame),
|
2115
|
+
b"TSIZ": ("Size", ID3_V2_3, TextFrame),
|
2116
|
+
b"TSRC": ("ISRC (international standard recording code)", ID3_V2,
|
2117
|
+
TextFrame),
|
2118
|
+
b"TSSE": ("Software/Hardware and settings used for encoding",
|
2119
|
+
ID3_V2, TextFrame),
|
2120
|
+
b"TSST": ("Set subtitle", ID3_V2_4, TextFrame),
|
2121
|
+
b"TYER": ("Year", ID3_V2_3, DateFrame),
|
2122
|
+
b"TXXX": ("User defined text information frame", ID3_V2,
|
2123
|
+
UserTextFrame),
|
2124
|
+
|
2125
|
+
b"UFID": ("Unique file identifier", ID3_V2, UniqueFileIDFrame),
|
2126
|
+
b"USER": ("Terms of use", ID3_V2, TermsOfUseFrame),
|
2127
|
+
b"USLT": ("Unsynchronised lyric/text transcription", ID3_V2,
|
2128
|
+
LyricsFrame),
|
2129
|
+
|
2130
|
+
b"WCOM": ("Commercial information", ID3_V2, UrlFrame),
|
2131
|
+
b"WCOP": ("Copyright/Legal information", ID3_V2, UrlFrame),
|
2132
|
+
b"WOAF": ("Official audio file webpage", ID3_V2, UrlFrame),
|
2133
|
+
b"WOAR": ("Official artist/performer webpage", ID3_V2, UrlFrame),
|
2134
|
+
b"WOAS": ("Official audio source webpage", ID3_V2, UrlFrame),
|
2135
|
+
b"WORS": ("Official Internet radio station homepage", ID3_V2,
|
2136
|
+
UrlFrame),
|
2137
|
+
b"WPAY": ("Payment", ID3_V2, UrlFrame),
|
2138
|
+
b"WPUB": ("Publishers official webpage", ID3_V2, UrlFrame),
|
2139
|
+
b"WXXX": ("User defined URL link frame", ID3_V2, UserUrlFrame),
|
2140
|
+
}
|
2141
|
+
|
2142
|
+
|
2143
|
+
def map2_2FrameId(orig_id):
|
2144
|
+
if orig_id not in TAGS2_2_TO_TAGS_2_3_AND_4:
|
2145
|
+
return orig_id
|
2146
|
+
return TAGS2_2_TO_TAGS_2_3_AND_4[orig_id]
|
2147
|
+
|
2148
|
+
|
2149
|
+
# mapping of 2.2 frames to 2.3/2.4
|
2150
|
+
TAGS2_2_TO_TAGS_2_3_AND_4 = {
|
2151
|
+
b"TT1": b"TIT1", # CONTENTGROUP content group description
|
2152
|
+
b"TT2": b"TIT2", # TITLE title/songname/content description
|
2153
|
+
b"TT3": b"TIT3", # SUBTITLE subtitle/description refinement
|
2154
|
+
b"TP1": b"TPE1", # ARTIST lead performer(s)/soloist(s)
|
2155
|
+
b"TP2": b"TPE2", # BAND band/orchestra/accompaniment
|
2156
|
+
b"TP3": b"TPE3", # CONDUCTOR conductor/performer refinement
|
2157
|
+
b"TP4": b"TPE4", # MIXARTIST interpreted, remixed, modified by
|
2158
|
+
b"TCM": b"TCOM", # COMPOSER composer
|
2159
|
+
b"TXT": b"TEXT", # LYRICIST lyricist/text writer
|
2160
|
+
b"TLA": b"TLAN", # LANGUAGE language(s)
|
2161
|
+
b"TCO": b"TCON", # CONTENTTYPE content type
|
2162
|
+
b"TAL": b"TALB", # ALBUM album/movie/show title
|
2163
|
+
b"TRK": b"TRCK", # TRACKNUM track number/position in set
|
2164
|
+
b"TPA": b"TPOS", # PARTINSET part of set
|
2165
|
+
b"TRC": b"TSRC", # ISRC international standard recording code
|
2166
|
+
b"TDA": b"TDAT", # DATE date
|
2167
|
+
b"TYE": b"TYER", # YEAR year
|
2168
|
+
b"TIM": b"TIME", # TIME time
|
2169
|
+
b"TRD": b"TRDA", # RECORDINGDATES recording dates
|
2170
|
+
b"TOR": b"TORY", # ORIGYEAR original release year
|
2171
|
+
b"TBP": b"TBPM", # BPM beats per minute
|
2172
|
+
b"TMT": b"TMED", # MEDIATYPE media type
|
2173
|
+
b"TFT": b"TFLT", # FILETYPE file type
|
2174
|
+
b"TCR": b"TCOP", # COPYRIGHT copyright message
|
2175
|
+
b"TPB": b"TPUB", # PUBLISHER publisher
|
2176
|
+
b"TEN": b"TENC", # ENCODEDBY encoded by
|
2177
|
+
b"TSS": b"TSSE", # ENCODERSETTINGS software/hardware+settings for encoding
|
2178
|
+
b"TLE": b"TLEN", # SONGLEN length (ms)
|
2179
|
+
b"TSI": b"TSIZ", # SIZE size (bytes)
|
2180
|
+
b"TDY": b"TDLY", # PLAYLISTDELAY playlist delay
|
2181
|
+
b"TKE": b"TKEY", # INITIALKEY initial key
|
2182
|
+
b"TOT": b"TOAL", # ORIGALBUM original album/movie/show title
|
2183
|
+
b"TOF": b"TOFN", # ORIGFILENAME original filename
|
2184
|
+
b"TOA": b"TOPE", # ORIGARTIST original artist(s)/performer(s)
|
2185
|
+
b"TOL": b"TOLY", # ORIGLYRICIST original lyricist(s)/text writer(s)
|
2186
|
+
b"TXX": b"TXXX", # USERTEXT user defined text information frame
|
2187
|
+
b"WAF": b"WOAF", # WWWAUDIOFILE official audio file webpage
|
2188
|
+
b"WAR": b"WOAR", # WWWARTIST official artist/performer webpage
|
2189
|
+
b"WAS": b"WOAS", # WWWAUDIOSOURCE official audion source webpage
|
2190
|
+
b"WCM": b"WCOM", # WWWCOMMERCIALINFO commercial information
|
2191
|
+
b"WCP": b"WCOP", # WWWCOPYRIGHT copyright/legal information
|
2192
|
+
b"WPB": b"WPUB", # WWWPUBLISHER publishers official webpage
|
2193
|
+
b"WXX": b"WXXX", # WWWUSER user defined URL link frame
|
2194
|
+
b"IPL": b"IPLS", # INVOLVEDPEOPLE involved people list
|
2195
|
+
b"ULT": b"USLT", # UNSYNCEDLYRICS unsynchronised lyrics/text transcription
|
2196
|
+
b"COM": b"COMM", # COMMENT comments
|
2197
|
+
b"UFI": b"UFID", # UNIQUEFILEID unique file identifier
|
2198
|
+
b"MCI": b"MCDI", # CDID music CD identifier
|
2199
|
+
b"ETC": b"ETCO", # EVENTTIMING event timing codes
|
2200
|
+
b"MLL": b"MLLT", # MPEGLOOKUP MPEG location lookup table
|
2201
|
+
b"STC": b"SYTC", # SYNCEDTEMPO synchronised tempo codes
|
2202
|
+
b"SLT": b"SYLT", # SYNCEDLYRICS synchronised lyrics/text
|
2203
|
+
b"RVA": b"RVAD", # VOLUMEADJ relative volume adjustment
|
2204
|
+
b"EQU": b"EQUA", # EQUALIZATION equalization
|
2205
|
+
b"REV": b"RVRB", # REVERB reverb
|
2206
|
+
b"PIC": b"APIC", # PICTURE attached picture
|
2207
|
+
b"GEO": b"GEOB", # GENERALOBJECT general encapsulated object
|
2208
|
+
b"CNT": b"PCNT", # PLAYCOUNTER play counter
|
2209
|
+
b"POP": b"POPM", # POPULARIMETER popularimeter
|
2210
|
+
b"BUF": b"RBUF", # BUFFERSIZE recommended buffer size
|
2211
|
+
b"CRA": b"AENC", # AUDIOCRYPTO audio encryption
|
2212
|
+
b"LNK": b"LINK", # LINKEDINFO linked information
|
2213
|
+
# Extension workarounds i.e., ignore them
|
2214
|
+
b"TCP": b"TCMP", # iTunes "extension" for compilation marking
|
2215
|
+
b"TST": b"TSOT", # iTunes "extension" for title sort
|
2216
|
+
b"TSP": b"TSOP", # iTunes "extension" for artist sort
|
2217
|
+
b"TSA": b"TSOA", # iTunes "extension" for album sort
|
2218
|
+
b"TS2": b"TSO2", # iTunes "extension" for album artist sort
|
2219
|
+
b"TSC": b"TSOC", # iTunes "extension" for composer sort
|
2220
|
+
b"TDR": b"TDRL", # iTunes "extension" for release date
|
2221
|
+
b"TDS": b"TDES", # iTunes "extension" for podcast description
|
2222
|
+
b"TID": b"TGID", # iTunes "extension" for podcast identifier
|
2223
|
+
b"WFD": b"WFED", # iTunes "extension" for podcast feed URL
|
2224
|
+
b"CM1": b"CM1 ", # Seems to be some script kiddie tagging the tag.
|
2225
|
+
# For example, [rH] join #rH on efnet [rH]
|
2226
|
+
b"PCS": b"PCST", # iTunes extension for podcast marking.
|
2227
|
+
}
|
2228
|
+
|
2229
|
+
from . import apple # noqa
|
2230
|
+
NONSTANDARD_ID3_FRAMES = {
|
2231
|
+
b"NCON": ("Undefined MusicMatch extension", ID3_V2, Frame),
|
2232
|
+
b"TCMP": ("iTunes compilation flag extension", ID3_V2, TextFrame),
|
2233
|
+
b"XSOA": ("Album sort-order string extension for v2.3",
|
2234
|
+
ID3_V2_3, TextFrame),
|
2235
|
+
b"XSOP": ("Performer sort-order string extension for v2.3",
|
2236
|
+
ID3_V2_3, TextFrame),
|
2237
|
+
b"XSOT": ("Title sort-order string extension for v2.3",
|
2238
|
+
ID3_V2_3, TextFrame),
|
2239
|
+
b"XDOR": ("MusicBrainz release date (full) extension for v2.3",
|
2240
|
+
ID3_V2_3, DateFrame),
|
2241
|
+
|
2242
|
+
b"TSO2": ("Album artist sort-order used in iTunes and Picard",
|
2243
|
+
ID3_V2, TextFrame),
|
2244
|
+
b"TSOC": ("Composer sort-order used in iTunes and Picard",
|
2245
|
+
ID3_V2, TextFrame),
|
2246
|
+
|
2247
|
+
b"PCST": ("iTunes extension; marks the file as a podcast",
|
2248
|
+
ID3_V2, apple.PCST),
|
2249
|
+
b"TKWD": ("iTunes extension; podcast keywords?",
|
2250
|
+
ID3_V2, apple.TKWD),
|
2251
|
+
b"TDES": ("iTunes extension; podcast description?",
|
2252
|
+
ID3_V2, apple.TDES),
|
2253
|
+
b"TGID": ("iTunes extension; podcast ?????",
|
2254
|
+
ID3_V2, apple.TGID),
|
2255
|
+
b"WFED": ("iTunes extension; podcast feed URL?",
|
2256
|
+
ID3_V2, apple.WFED),
|
2257
|
+
b"TCAT": ("iTunes extension; podcast category.",
|
2258
|
+
ID3_V2, TextFrame),
|
2259
|
+
b"GRP1": ("iTunes extension; grouping.",
|
2260
|
+
ID3_V2, apple.GRP1),
|
2261
|
+
}
|