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/tag.py
ADDED
@@ -0,0 +1,2047 @@
|
|
1
|
+
import os
|
2
|
+
import codecs
|
3
|
+
import string
|
4
|
+
import shutil
|
5
|
+
import tempfile
|
6
|
+
import textwrap
|
7
|
+
|
8
|
+
from ..utils import requireUnicode, chunkCopy, datePicker, b
|
9
|
+
from .. import core
|
10
|
+
from ..core import TXXX_ALBUM_TYPE, TXXX_ARTIST_ORIGIN, ALBUM_TYPE_IDS, ArtistOrigin
|
11
|
+
from .. import Error
|
12
|
+
from . import (ID3_ANY_VERSION, ID3_DEFAULT_VERSION, ID3_V1, ID3_V1_0, ID3_V1_1,
|
13
|
+
ID3_V2, ID3_V2_2, ID3_V2_3, ID3_V2_4, versionToString)
|
14
|
+
from . import DEFAULT_LANG
|
15
|
+
from . import Genre
|
16
|
+
from . import frames
|
17
|
+
from .headers import TagHeader, ExtendedTagHeader
|
18
|
+
|
19
|
+
from ..utils.log import getLogger
|
20
|
+
log = getLogger(__name__)
|
21
|
+
|
22
|
+
ID3_V1_COMMENT_DESC = "ID3v1.x Comment"
|
23
|
+
ID3_V1_MAX_TEXTLEN = 30
|
24
|
+
ID3_V1_STRIP_CHARS = string.whitespace.encode("latin1") + b"\x00"
|
25
|
+
DEFAULT_PADDING = 256
|
26
|
+
|
27
|
+
|
28
|
+
class TagException(Error):
|
29
|
+
pass
|
30
|
+
|
31
|
+
|
32
|
+
class Tag(core.Tag):
|
33
|
+
def __init__(self, version=ID3_DEFAULT_VERSION, **kwargs):
|
34
|
+
self.file_info = None
|
35
|
+
self.header = None
|
36
|
+
self.extended_header = None
|
37
|
+
self.frame_set = None
|
38
|
+
|
39
|
+
self._comments = None
|
40
|
+
self._images = None
|
41
|
+
self._lyrics = None
|
42
|
+
self._objects = None
|
43
|
+
self._privates = None
|
44
|
+
self._user_texts = None
|
45
|
+
self._unique_file_ids = None
|
46
|
+
self._user_urls = None
|
47
|
+
self._chapters = None
|
48
|
+
self._tocs = None
|
49
|
+
self._popularities = None
|
50
|
+
|
51
|
+
self.file_info = None
|
52
|
+
self.clear(version=version)
|
53
|
+
super().__init__(**kwargs)
|
54
|
+
|
55
|
+
def clear(self, *, version=ID3_DEFAULT_VERSION):
|
56
|
+
"""Reset all tag data."""
|
57
|
+
# ID3 tag header
|
58
|
+
self.header = TagHeader(version=version)
|
59
|
+
# Optional extended header in v2 tags.
|
60
|
+
self.extended_header = ExtendedTagHeader()
|
61
|
+
# Contains the tag's frames. ID3v1 fields are read and converted
|
62
|
+
# the the corresponding v2 frame.
|
63
|
+
self.frame_set = frames.FrameSet()
|
64
|
+
self._comments = CommentsAccessor(self.frame_set)
|
65
|
+
self._images = ImagesAccessor(self.frame_set)
|
66
|
+
self._lyrics = LyricsAccessor(self.frame_set)
|
67
|
+
self._objects = ObjectsAccessor(self.frame_set)
|
68
|
+
self._privates = PrivatesAccessor(self.frame_set)
|
69
|
+
self._user_texts = UserTextsAccessor(self.frame_set)
|
70
|
+
self._unique_file_ids = UniqueFileIdAccessor(self.frame_set)
|
71
|
+
self._user_urls = UserUrlsAccessor(self.frame_set)
|
72
|
+
self._chapters = ChaptersAccessor(self.frame_set)
|
73
|
+
self._tocs = TocAccessor(self.frame_set)
|
74
|
+
self._popularities = PopularitiesAccessor(self.frame_set)
|
75
|
+
|
76
|
+
def parse(self, fileobj, version=ID3_ANY_VERSION):
|
77
|
+
self.clear()
|
78
|
+
version = version or ID3_ANY_VERSION
|
79
|
+
|
80
|
+
close_file = False
|
81
|
+
try:
|
82
|
+
filename = fileobj.name
|
83
|
+
except AttributeError:
|
84
|
+
if type(fileobj) is str:
|
85
|
+
filename = fileobj
|
86
|
+
fileobj = open(filename, "rb")
|
87
|
+
close_file = True
|
88
|
+
else:
|
89
|
+
raise ValueError(f"Invalid type: {type(fileobj)}")
|
90
|
+
|
91
|
+
self.file_info = FileInfo(filename)
|
92
|
+
|
93
|
+
try:
|
94
|
+
tag_found = False
|
95
|
+
padding = 0
|
96
|
+
# The & is for supporting the "meta" versions, any, etc.
|
97
|
+
if version[0] & 2:
|
98
|
+
tag_found, padding = self._loadV2Tag(fileobj)
|
99
|
+
|
100
|
+
if not tag_found and version[0] & 1:
|
101
|
+
tag_found, padding = self._loadV1Tag(fileobj)
|
102
|
+
if tag_found:
|
103
|
+
self.extended_header = None
|
104
|
+
|
105
|
+
if tag_found and self.isV2():
|
106
|
+
self.file_info.tag_size = (TagHeader.SIZE +
|
107
|
+
self.header.tag_size)
|
108
|
+
if tag_found:
|
109
|
+
self.file_info.tag_padding_size = padding
|
110
|
+
|
111
|
+
finally:
|
112
|
+
if close_file:
|
113
|
+
fileobj.close()
|
114
|
+
|
115
|
+
return tag_found
|
116
|
+
|
117
|
+
def _loadV2Tag(self, fp):
|
118
|
+
"""Returns (tag_found, padding_len)"""
|
119
|
+
fp.seek(0)
|
120
|
+
|
121
|
+
# Look for a tag and if found load it.
|
122
|
+
if not self.header.parse(fp):
|
123
|
+
return False, 0
|
124
|
+
|
125
|
+
# Read the extended header if present.
|
126
|
+
if self.header.extended:
|
127
|
+
self.extended_header.parse(fp, self.header.version)
|
128
|
+
|
129
|
+
# Header is definitely there so at least one frame *must* follow.
|
130
|
+
padding = self.frame_set.parse(fp, self.header,
|
131
|
+
self.extended_header)
|
132
|
+
|
133
|
+
log.debug("Tag contains %d bytes of padding." % padding)
|
134
|
+
return True, padding
|
135
|
+
|
136
|
+
def _loadV1Tag(self, fp):
|
137
|
+
v1_enc = "latin1"
|
138
|
+
|
139
|
+
# Seek to the end of the file where all v1x tags are written.
|
140
|
+
# v1.x tags are 128 bytes min and max
|
141
|
+
fp.seek(0, 2)
|
142
|
+
if fp.tell() < 128:
|
143
|
+
return False, 0
|
144
|
+
fp.seek(-128, 2)
|
145
|
+
tag_data = fp.read(128)
|
146
|
+
|
147
|
+
if tag_data[0:3] != b"TAG":
|
148
|
+
return False, 0
|
149
|
+
|
150
|
+
log.debug("Located ID3 v1 tag")
|
151
|
+
# v1.0 is implied until a v1.1 feature is recognized.
|
152
|
+
self.version = ID3_V1_0
|
153
|
+
|
154
|
+
title = tag_data[3:33].strip(ID3_V1_STRIP_CHARS)
|
155
|
+
log.debug("Title: %s" % title)
|
156
|
+
if title:
|
157
|
+
self.title = str(title, v1_enc)
|
158
|
+
|
159
|
+
artist = tag_data[33:63].strip(ID3_V1_STRIP_CHARS)
|
160
|
+
log.debug("Artist: %s" % artist)
|
161
|
+
if artist:
|
162
|
+
self.artist = str(artist, v1_enc)
|
163
|
+
|
164
|
+
album = tag_data[63:93].strip(ID3_V1_STRIP_CHARS)
|
165
|
+
log.debug("Album: %s" % album)
|
166
|
+
if album:
|
167
|
+
self.album = str(album, v1_enc)
|
168
|
+
|
169
|
+
year = tag_data[93:97].strip(ID3_V1_STRIP_CHARS)
|
170
|
+
log.debug("Year: %s" % year)
|
171
|
+
try:
|
172
|
+
if year and int(year):
|
173
|
+
# Values here typically mean the year of release
|
174
|
+
self.release_date = int(year)
|
175
|
+
except ValueError:
|
176
|
+
# Bogus year strings.
|
177
|
+
log.warn("ID3v1.x tag contains invalid year: %s" % year)
|
178
|
+
pass
|
179
|
+
|
180
|
+
# Can't use ID3_V1_STRIP_CHARS here, since the final byte is numeric
|
181
|
+
comment = tag_data[97:127].rstrip(b"\x00")
|
182
|
+
# Track numbers stuffed in the comment field is what makes v1.1
|
183
|
+
if comment:
|
184
|
+
if (len(comment) >= 2 and
|
185
|
+
# Python the slices (the chars), so this is really
|
186
|
+
# comment[2] and comment[-1]
|
187
|
+
comment[-2:-1] == b"\x00"):
|
188
|
+
log.debug("Track Num found, setting version to v1.1")
|
189
|
+
self.version = ID3_V1_1
|
190
|
+
|
191
|
+
track = comment[-1]
|
192
|
+
self.track_num = (track, None)
|
193
|
+
log.debug("Track: " + str(track))
|
194
|
+
comment = comment[:-2].strip(ID3_V1_STRIP_CHARS)
|
195
|
+
|
196
|
+
# There may only have been a track #
|
197
|
+
if comment:
|
198
|
+
log.debug(f"Comment: {comment}")
|
199
|
+
self.comments.set(str(comment, v1_enc), ID3_V1_COMMENT_DESC)
|
200
|
+
|
201
|
+
genre = ord(tag_data[127:128])
|
202
|
+
log.debug(f"Genre ID: {genre}")
|
203
|
+
try:
|
204
|
+
self.genre = genre
|
205
|
+
except ValueError as ex:
|
206
|
+
log.warning(ex)
|
207
|
+
self.genre = None
|
208
|
+
|
209
|
+
return True, 0
|
210
|
+
|
211
|
+
@property
|
212
|
+
def version(self):
|
213
|
+
return self.header.version
|
214
|
+
|
215
|
+
@version.setter
|
216
|
+
def version(self, v):
|
217
|
+
# Tag version changes required possible frame conversion
|
218
|
+
std, non = self._checkForConversions(v)
|
219
|
+
converted = []
|
220
|
+
if non:
|
221
|
+
converted = self._convertFrames(std, non, v)
|
222
|
+
if converted:
|
223
|
+
self.frame_set.clear()
|
224
|
+
for frame in (std + converted):
|
225
|
+
self.frame_set[frame.id] = frame
|
226
|
+
|
227
|
+
self.header.version = v
|
228
|
+
|
229
|
+
def isV1(self):
|
230
|
+
"""Test ID3 major version for v1.x"""
|
231
|
+
return self.header.major_version == 1
|
232
|
+
|
233
|
+
def isV2(self):
|
234
|
+
"""Test ID3 major version for v2.x"""
|
235
|
+
return self.header.major_version == 2
|
236
|
+
|
237
|
+
@requireUnicode(2)
|
238
|
+
def setTextFrame(self, fid: bytes, txt: str):
|
239
|
+
fid = b(fid, codecs.ascii_encode)
|
240
|
+
if not frames.TextFrame.isValidFrameId(fid):
|
241
|
+
raise ValueError("Invalid frame-id for text frame")
|
242
|
+
|
243
|
+
if not txt and self.frame_set[fid]:
|
244
|
+
del self.frame_set[fid]
|
245
|
+
elif txt:
|
246
|
+
self.frame_set.setTextFrame(fid, txt)
|
247
|
+
|
248
|
+
# FIXME: is returning data not a Frame.
|
249
|
+
def getTextFrame(self, fid: bytes):
|
250
|
+
fid = b(fid, codecs.ascii_encode)
|
251
|
+
if not frames.TextFrame.isValidFrameId(fid):
|
252
|
+
raise ValueError("Invalid frame-id for text frame")
|
253
|
+
f = self.frame_set[fid]
|
254
|
+
return f[0].text if f else None
|
255
|
+
|
256
|
+
@requireUnicode(1)
|
257
|
+
def _setArtist(self, val):
|
258
|
+
self.setTextFrame(frames.ARTIST_FID, val)
|
259
|
+
|
260
|
+
def _getArtist(self):
|
261
|
+
return self.getTextFrame(frames.ARTIST_FID)
|
262
|
+
|
263
|
+
@requireUnicode(1)
|
264
|
+
def _setAlbumArtist(self, val):
|
265
|
+
self.setTextFrame(frames.ALBUM_ARTIST_FID, val)
|
266
|
+
|
267
|
+
def _getAlbumArtist(self):
|
268
|
+
return self.getTextFrame(frames.ALBUM_ARTIST_FID)
|
269
|
+
|
270
|
+
@requireUnicode(1)
|
271
|
+
def _setComposer(self, val):
|
272
|
+
self.setTextFrame(frames.COMPOSER_FID, val)
|
273
|
+
|
274
|
+
def _getComposer(self):
|
275
|
+
return self.getTextFrame(frames.COMPOSER_FID)
|
276
|
+
|
277
|
+
@property
|
278
|
+
def composer(self):
|
279
|
+
return self._getComposer()
|
280
|
+
|
281
|
+
@composer.setter
|
282
|
+
def composer(self, v):
|
283
|
+
self._setComposer(v)
|
284
|
+
|
285
|
+
@requireUnicode(1)
|
286
|
+
def _setAlbum(self, val):
|
287
|
+
self.setTextFrame(frames.ALBUM_FID, val)
|
288
|
+
|
289
|
+
def _getAlbum(self):
|
290
|
+
return self.getTextFrame(frames.ALBUM_FID)
|
291
|
+
|
292
|
+
@requireUnicode(1)
|
293
|
+
def _setTitle(self, val):
|
294
|
+
self.setTextFrame(frames.TITLE_FID, val)
|
295
|
+
|
296
|
+
def _getTitle(self):
|
297
|
+
return self.getTextFrame(frames.TITLE_FID)
|
298
|
+
|
299
|
+
def _setTrackNum(self, val):
|
300
|
+
self._setNum(frames.TRACKNUM_FID, val)
|
301
|
+
|
302
|
+
def _getTrackNum(self) -> core.CountAndTotalTuple:
|
303
|
+
return self._splitNum(frames.TRACKNUM_FID)
|
304
|
+
|
305
|
+
def _setDiscNum(self, val):
|
306
|
+
self._setNum(frames.DISCNUM_FID, val)
|
307
|
+
|
308
|
+
def _getDiscNum(self) -> core.CountAndTotalTuple:
|
309
|
+
return self._splitNum(frames.DISCNUM_FID)
|
310
|
+
|
311
|
+
def _splitNum(self, fid) -> core.CountAndTotalTuple:
|
312
|
+
f = self.frame_set[fid]
|
313
|
+
first, second = None, None
|
314
|
+
if f and f[0].text:
|
315
|
+
n = f[0].text.split('/')
|
316
|
+
try:
|
317
|
+
first = int(n[0])
|
318
|
+
second = int(n[1]) if len(n) == 2 else None
|
319
|
+
except ValueError as ex:
|
320
|
+
log.warning(str(ex))
|
321
|
+
return core.CountAndTotalTuple(first, second)
|
322
|
+
|
323
|
+
def _setNum(self, fid, val):
|
324
|
+
if type(val) is str:
|
325
|
+
val = int(val)
|
326
|
+
|
327
|
+
if type(val) is tuple:
|
328
|
+
if len(val) != 2:
|
329
|
+
raise ValueError("A 2-tuple of int values is required.")
|
330
|
+
else:
|
331
|
+
tn, tt = tuple([int(v) if v is not None else None for v in val])
|
332
|
+
elif type(val) is int:
|
333
|
+
tn, tt = val, None
|
334
|
+
elif val is None:
|
335
|
+
tn, tt = None, None
|
336
|
+
else:
|
337
|
+
raise TypeError("Invalid value, should int 2-tuple, int, or None: "
|
338
|
+
f"{val} ({val.__class__.__name__})")
|
339
|
+
|
340
|
+
n = (tn, tt)
|
341
|
+
|
342
|
+
if n[0] is None and n[1] is None:
|
343
|
+
if self.frame_set[fid]:
|
344
|
+
del self.frame_set[fid]
|
345
|
+
return
|
346
|
+
|
347
|
+
total_str = ""
|
348
|
+
if n[1] is not None:
|
349
|
+
if 0 <= n[1] <= 9:
|
350
|
+
total_str = "0" + str(n[1])
|
351
|
+
else:
|
352
|
+
total_str = str(n[1])
|
353
|
+
|
354
|
+
t = n[0] if n[0] else 0
|
355
|
+
track_str = str(t)
|
356
|
+
|
357
|
+
# Pad with zeros according to how large the total count is.
|
358
|
+
if len(track_str) == 1:
|
359
|
+
track_str = "0" + track_str
|
360
|
+
if len(track_str) < len(total_str):
|
361
|
+
track_str = ("0" * (len(total_str) - len(track_str))) + track_str
|
362
|
+
|
363
|
+
final_str = ""
|
364
|
+
if track_str and total_str:
|
365
|
+
final_str = "%s/%s" % (track_str, total_str)
|
366
|
+
elif track_str and not total_str:
|
367
|
+
final_str = track_str
|
368
|
+
|
369
|
+
self.frame_set.setTextFrame(fid, str(final_str))
|
370
|
+
|
371
|
+
@property
|
372
|
+
def comments(self):
|
373
|
+
return self._comments
|
374
|
+
|
375
|
+
def _getBpm(self):
|
376
|
+
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
|
377
|
+
|
378
|
+
bpm = None
|
379
|
+
if frames.BPM_FID in self.frame_set:
|
380
|
+
bpm_str = self.frame_set[frames.BPM_FID][0].text or "0"
|
381
|
+
try:
|
382
|
+
# Round floats since the spec says this is an integer. Python3
|
383
|
+
# changed how 'round' works, hence the using of decimal
|
384
|
+
bpm = int(Decimal(bpm_str).quantize(1, ROUND_HALF_UP))
|
385
|
+
except (InvalidOperation, ValueError) as ex:
|
386
|
+
log.warning(ex)
|
387
|
+
return bpm
|
388
|
+
|
389
|
+
def _setBpm(self, bpm):
|
390
|
+
assert bpm >= 0
|
391
|
+
self.setTextFrame(frames.BPM_FID, str(bpm))
|
392
|
+
|
393
|
+
bpm = property(_getBpm, _setBpm)
|
394
|
+
|
395
|
+
@property
|
396
|
+
def play_count(self):
|
397
|
+
if frames.PLAYCOUNT_FID in self.frame_set:
|
398
|
+
pc = self.frame_set[frames.PLAYCOUNT_FID][0]
|
399
|
+
return pc.count
|
400
|
+
else:
|
401
|
+
return None
|
402
|
+
|
403
|
+
@play_count.setter
|
404
|
+
def play_count(self, count):
|
405
|
+
if count is None:
|
406
|
+
del self.frame_set[frames.PLAYCOUNT_FID]
|
407
|
+
return
|
408
|
+
|
409
|
+
if count < 0:
|
410
|
+
raise ValueError("Invalid play count value: %d" % count)
|
411
|
+
|
412
|
+
if self.frame_set[frames.PLAYCOUNT_FID]:
|
413
|
+
pc = self.frame_set[frames.PLAYCOUNT_FID][0]
|
414
|
+
pc.count = count
|
415
|
+
else:
|
416
|
+
self.frame_set[frames.PLAYCOUNT_FID] = \
|
417
|
+
frames.PlayCountFrame(count=count)
|
418
|
+
|
419
|
+
def _getPublisher(self):
|
420
|
+
if frames.PUBLISHER_FID in self.frame_set:
|
421
|
+
pub = self.frame_set[frames.PUBLISHER_FID]
|
422
|
+
return pub[0].text
|
423
|
+
else:
|
424
|
+
return None
|
425
|
+
|
426
|
+
@requireUnicode(1)
|
427
|
+
def _setPublisher(self, p):
|
428
|
+
self.setTextFrame(frames.PUBLISHER_FID, p)
|
429
|
+
|
430
|
+
publisher = property(_getPublisher, _setPublisher)
|
431
|
+
|
432
|
+
@property
|
433
|
+
def cd_id(self):
|
434
|
+
if frames.CDID_FID in self.frame_set:
|
435
|
+
return self.frame_set[frames.CDID_FID][0].toc
|
436
|
+
else:
|
437
|
+
return None
|
438
|
+
|
439
|
+
@cd_id.setter
|
440
|
+
def cd_id(self, toc):
|
441
|
+
if len(toc) > 804:
|
442
|
+
raise ValueError("CD identifier table of contents can be no "
|
443
|
+
"greater than 804 bytes")
|
444
|
+
|
445
|
+
if self.frame_set[frames.CDID_FID]:
|
446
|
+
cdid = self.frame_set[frames.CDID_FID][0]
|
447
|
+
cdid.toc = bytes(toc)
|
448
|
+
else:
|
449
|
+
self.frame_set[frames.CDID_FID] = \
|
450
|
+
frames.MusicCDIdFrame(toc=toc)
|
451
|
+
|
452
|
+
@property
|
453
|
+
def unknown_frame_ids(self) -> set:
|
454
|
+
return self.frame_set.unknown_frame_ids
|
455
|
+
|
456
|
+
@property
|
457
|
+
def images(self):
|
458
|
+
return self._images
|
459
|
+
|
460
|
+
def _getEncodingDate(self):
|
461
|
+
return self._getDate(b"TDEN")
|
462
|
+
|
463
|
+
def _setEncodingDate(self, date):
|
464
|
+
self._setDate(b"TDEN", date)
|
465
|
+
encoding_date = property(_getEncodingDate, _setEncodingDate)
|
466
|
+
|
467
|
+
@property
|
468
|
+
def best_release_date(self):
|
469
|
+
"""This method tries its best to return a date of some sort, amongst
|
470
|
+
all the possible date frames. The order of preference for a release
|
471
|
+
date is 1) date of original release 2) date of this versions release
|
472
|
+
3) the recording date. Or None is returned."""
|
473
|
+
import warnings
|
474
|
+
warnings.warn("Use Tag.getBestDate() instead", DeprecationWarning,
|
475
|
+
stacklevel=2)
|
476
|
+
return (self.original_release_date or
|
477
|
+
self.release_date or
|
478
|
+
self.recording_date)
|
479
|
+
|
480
|
+
def getBestDate(self, prefer_recording_date=False):
|
481
|
+
"""This method returns a date of some sort, amongst all the possible
|
482
|
+
date frames. The order of preference is:
|
483
|
+
|
484
|
+
1) date of original release
|
485
|
+
2) date of this versions release
|
486
|
+
3) the recording date.
|
487
|
+
|
488
|
+
Unless ``prefer_recording_date`` is ``True`` in which case the order is
|
489
|
+
3, 1, 2.
|
490
|
+
|
491
|
+
``None`` will be returned if no dates are available."""
|
492
|
+
return datePicker(self, prefer_recording_date)
|
493
|
+
|
494
|
+
def _getReleaseDate(self):
|
495
|
+
if self.version == ID3_V2_3:
|
496
|
+
# v2.3 does NOT have a release date, only TORY, so that is what is returned
|
497
|
+
return self._getV23OriginalReleaseDate()
|
498
|
+
else:
|
499
|
+
return self._getDate(b"TDRL")
|
500
|
+
|
501
|
+
def _setReleaseDate(self, date):
|
502
|
+
if self.version == ID3_V2_3:
|
503
|
+
# v2.3 does NOT have a release date, only TORY, so that is what is set
|
504
|
+
self._setOriginalReleaseDate(date)
|
505
|
+
else:
|
506
|
+
self._setDate(b"TDRL", date)
|
507
|
+
|
508
|
+
release_date = property(_getReleaseDate, _setReleaseDate)
|
509
|
+
release_date.__doc__ = textwrap.dedent("""
|
510
|
+
The date the audio was released. This is NOT the original date the
|
511
|
+
work was released, instead it is more like the pressing or version of the
|
512
|
+
release. Original release date is usually what is intended but many programs
|
513
|
+
use this frame and/or don't distinguish between the two.
|
514
|
+
|
515
|
+
NOTE: ID3v2.3 only has original release date, so setting release_date is the same as
|
516
|
+
original_release_value; they both set TORY.
|
517
|
+
""")
|
518
|
+
|
519
|
+
def _getOrigReleaseDate(self):
|
520
|
+
if self.version == ID3_V2_3:
|
521
|
+
return self._getV23OriginalReleaseDate()
|
522
|
+
else:
|
523
|
+
return self._getDate(b"TDOR") or self._getV23OriginalReleaseDate()
|
524
|
+
_getOriginalReleaseDate = _getOrigReleaseDate
|
525
|
+
|
526
|
+
def _setOrigReleaseDate(self, date):
|
527
|
+
if self.version == ID3_V2_3:
|
528
|
+
self._setDate(b"TORY", date)
|
529
|
+
else:
|
530
|
+
self._setDate(b"TDOR", date)
|
531
|
+
_setOriginalReleaseDate = _setOrigReleaseDate
|
532
|
+
|
533
|
+
original_release_date = property(_getOrigReleaseDate, _setOrigReleaseDate)
|
534
|
+
original_release_date.__doc__ = textwrap.dedent("""
|
535
|
+
The date the work was originally released.
|
536
|
+
|
537
|
+
NOTE: ID3v2.3 only stores year. If the Date object is more precise it is store in `XDOR`, and
|
538
|
+
XDOR is preferred when acessing. The year-only date is stored in the standard `TORY` frame as
|
539
|
+
well.
|
540
|
+
""")
|
541
|
+
|
542
|
+
def _getRecordingDate(self):
|
543
|
+
if self.version == ID3_V2_3:
|
544
|
+
return self._getV23RecordingDate()
|
545
|
+
else:
|
546
|
+
return self._getDate(b"TDRC")
|
547
|
+
|
548
|
+
def _setRecordingDate(self, date):
|
549
|
+
if date in (None, ""):
|
550
|
+
for fid in (b"TDRC", b"TYER", b"TDAT", b"TIME"):
|
551
|
+
self._setDate(fid, None)
|
552
|
+
elif self.version == ID3_V2_4:
|
553
|
+
self._setDate(b"TDRC", date)
|
554
|
+
else:
|
555
|
+
if not isinstance(date, core.Date):
|
556
|
+
date = core.Date.parse(date)
|
557
|
+
self._setDate(b"TYER", str(date.year))
|
558
|
+
if None not in (date.month, date.day):
|
559
|
+
date_str = "%s%s" % (str(date.day).rjust(2, "0"),
|
560
|
+
str(date.month).rjust(2, "0"))
|
561
|
+
self._setDate(b"TDAT", date_str)
|
562
|
+
if None not in (date.hour, date.minute):
|
563
|
+
date_str = "%s%s" % (str(date.hour).rjust(2, "0"),
|
564
|
+
str(date.minute).rjust(2, "0"))
|
565
|
+
self._setDate(b"TIME", date_str)
|
566
|
+
|
567
|
+
recording_date = property(_getRecordingDate, _setRecordingDate)
|
568
|
+
"""The date of the recording. Many applications use this for release date
|
569
|
+
regardless of the fact that this value is rarely known, and release dates
|
570
|
+
are more correct."""
|
571
|
+
|
572
|
+
def _getV23RecordingDate(self):
|
573
|
+
# v2.3 TYER (yyyy), TDAT (DDMM), TIME (HHmm)
|
574
|
+
date = None
|
575
|
+
try:
|
576
|
+
date_str = b""
|
577
|
+
if b"TYER" in self.frame_set:
|
578
|
+
date_str = self.frame_set[b"TYER"][0].text.encode("latin1")
|
579
|
+
date = core.Date.parse(date_str)
|
580
|
+
if b"TDAT" in self.frame_set:
|
581
|
+
text = self.frame_set[b"TDAT"][0].text.encode("latin1")
|
582
|
+
date_str += b"-%s-%s" % (text[2:], text[:2])
|
583
|
+
date = core.Date.parse(date_str)
|
584
|
+
if b"TIME" in self.frame_set:
|
585
|
+
text = self.frame_set[b"TIME"][0].text.encode("latin1")
|
586
|
+
date_str += b"T%s:%s" % (text[:2], text[2:])
|
587
|
+
date = core.Date.parse(date_str)
|
588
|
+
except ValueError as ex:
|
589
|
+
log.warning("Invalid v2.3 TYER, TDAT, or TIME frame: %s" % ex)
|
590
|
+
|
591
|
+
return date
|
592
|
+
|
593
|
+
def _getV23OriginalReleaseDate(self):
|
594
|
+
date, date_str = None, None
|
595
|
+
try:
|
596
|
+
# XDOR is preferred since it can gave a full date, whereas TORY is year only.
|
597
|
+
for fid in (b"XDOR", b"TORY"):
|
598
|
+
if fid in self.frame_set:
|
599
|
+
date_str = self.frame_set[fid][0].text.encode("latin1")
|
600
|
+
break
|
601
|
+
if date_str:
|
602
|
+
date = core.Date.parse(date_str)
|
603
|
+
except ValueError as ex:
|
604
|
+
log.warning(f"Invalid v2.3 TORY/XDOR frame: {ex}")
|
605
|
+
|
606
|
+
return date
|
607
|
+
|
608
|
+
def _getTaggingDate(self):
|
609
|
+
return self._getDate(b"TDTG")
|
610
|
+
|
611
|
+
def _setTaggingDate(self, date):
|
612
|
+
self._setDate(b"TDTG", date)
|
613
|
+
tagging_date = property(_getTaggingDate, _setTaggingDate)
|
614
|
+
|
615
|
+
def _setDate(self, fid, date):
|
616
|
+
def removeFrame(frame_id):
|
617
|
+
try:
|
618
|
+
del self.frame_set[frame_id]
|
619
|
+
except KeyError:
|
620
|
+
pass
|
621
|
+
|
622
|
+
def setFrame(frame_id, date_val):
|
623
|
+
if frame_id in self.frame_set:
|
624
|
+
self.frame_set[frame_id][0].date = date_val
|
625
|
+
else:
|
626
|
+
self.frame_set[frame_id] = frames.DateFrame(frame_id, str(date_val))
|
627
|
+
|
628
|
+
assert fid in frames.DATE_FIDS or fid in frames.DEPRECATED_DATE_FIDS
|
629
|
+
if fid == b"XDOR":
|
630
|
+
raise ValueError("Set TORY with a full date (i.e. more than year)")
|
631
|
+
|
632
|
+
clean_fids = [fid]
|
633
|
+
if fid == b"TORY":
|
634
|
+
clean_fids.append(b"XDOR")
|
635
|
+
|
636
|
+
if date in (None, ""):
|
637
|
+
for cid in clean_fids:
|
638
|
+
removeFrame(cid)
|
639
|
+
return
|
640
|
+
|
641
|
+
# Special casing the conversion to DATE objects cuz TDAT and TIME won't
|
642
|
+
if fid not in (b"TDAT", b"TIME"):
|
643
|
+
# Convert to ISO format which is what FrameSet wants.
|
644
|
+
date_type = type(date)
|
645
|
+
if date_type is int:
|
646
|
+
# The integer year
|
647
|
+
date = core.Date(date)
|
648
|
+
elif date_type is str:
|
649
|
+
date = core.Date.parse(date)
|
650
|
+
elif not isinstance(date, core.Date):
|
651
|
+
raise TypeError(f"Invalid type: {date_type}")
|
652
|
+
|
653
|
+
if fid == b"TORY":
|
654
|
+
setFrame(fid, date.year)
|
655
|
+
if date.month:
|
656
|
+
setFrame(b"XDOR", date)
|
657
|
+
else:
|
658
|
+
removeFrame(b"XDOR")
|
659
|
+
else:
|
660
|
+
setFrame(fid, date)
|
661
|
+
|
662
|
+
def _getDate(self, fid):
|
663
|
+
if fid in (b"TORY", b"XDOR"):
|
664
|
+
return self._getV23OriginalReleaseDate()
|
665
|
+
|
666
|
+
if fid in self.frame_set:
|
667
|
+
if fid in (b"TYER", b"TDAT", b"TIME"):
|
668
|
+
if fid == b"TYER":
|
669
|
+
# Contain years only, date conversion can happen
|
670
|
+
return core.Date(int(self.frame_set[fid][0].text))
|
671
|
+
else:
|
672
|
+
return self.frame_set[fid][0].text
|
673
|
+
else:
|
674
|
+
return self.frame_set[fid][0].date
|
675
|
+
else:
|
676
|
+
return None
|
677
|
+
|
678
|
+
@property
|
679
|
+
def lyrics(self):
|
680
|
+
return self._lyrics
|
681
|
+
|
682
|
+
@property
|
683
|
+
def disc_num(self):
|
684
|
+
return self._getDiscNum()
|
685
|
+
|
686
|
+
@disc_num.setter
|
687
|
+
def disc_num(self, val):
|
688
|
+
self._setDiscNum(val)
|
689
|
+
|
690
|
+
@property
|
691
|
+
def objects(self):
|
692
|
+
return self._objects
|
693
|
+
|
694
|
+
@property
|
695
|
+
def privates(self):
|
696
|
+
return self._privates
|
697
|
+
|
698
|
+
@property
|
699
|
+
def popularities(self):
|
700
|
+
return self._popularities
|
701
|
+
|
702
|
+
def _getGenre(self, id3_std=True):
|
703
|
+
f = self.frame_set[frames.GENRE_FID]
|
704
|
+
if f and f[0].text:
|
705
|
+
try:
|
706
|
+
return Genre.parse(f[0].text, id3_std=id3_std)
|
707
|
+
except ValueError: # pragma: nocover
|
708
|
+
return None
|
709
|
+
else:
|
710
|
+
return None
|
711
|
+
|
712
|
+
def _setGenre(self, g, id3_std=True):
|
713
|
+
"""Set the genre.
|
714
|
+
Four types are accepted for the ``g`` argument.
|
715
|
+
A Genre object, an acceptable (see Genre.parse) genre string,
|
716
|
+
or an integer genre ID all will set the value. A value of None will
|
717
|
+
remove the genre."""
|
718
|
+
if g in ("", None):
|
719
|
+
if self.frame_set[frames.GENRE_FID]:
|
720
|
+
del self.frame_set[frames.GENRE_FID]
|
721
|
+
return
|
722
|
+
|
723
|
+
if isinstance(g, str):
|
724
|
+
g = Genre.parse(g, id3_std=id3_std)
|
725
|
+
elif isinstance(g, int):
|
726
|
+
g = Genre(id=g)
|
727
|
+
elif not isinstance(g, Genre):
|
728
|
+
raise TypeError(f"Invalid genre data type: {type(g)}")
|
729
|
+
|
730
|
+
assert g
|
731
|
+
self.frame_set.setTextFrame(frames.GENRE_FID, f"{g.name if g.name else g.id}")
|
732
|
+
|
733
|
+
# genre property
|
734
|
+
genre = property(_getGenre, _setGenre)
|
735
|
+
|
736
|
+
def _getNonStdGenre(self):
|
737
|
+
return self._getGenre(id3_std=False)
|
738
|
+
|
739
|
+
def _setNonStdGenre(self, val):
|
740
|
+
self._setGenre(val, id3_std=False)
|
741
|
+
|
742
|
+
# non-standard genre (unparsed, unmapped) property
|
743
|
+
non_std_genre = property(_getNonStdGenre, _setNonStdGenre)
|
744
|
+
|
745
|
+
@property
|
746
|
+
def user_text_frames(self):
|
747
|
+
return self._user_texts
|
748
|
+
|
749
|
+
def _setUrlFrame(self, fid, url):
|
750
|
+
if fid not in frames.URL_FIDS:
|
751
|
+
raise ValueError("Invalid URL frame-id")
|
752
|
+
|
753
|
+
if self.frame_set[fid]:
|
754
|
+
if not url:
|
755
|
+
del self.frame_set[fid]
|
756
|
+
else:
|
757
|
+
self.frame_set[fid][0].url = url
|
758
|
+
else:
|
759
|
+
self.frame_set[fid] = frames.UrlFrame(fid, url)
|
760
|
+
|
761
|
+
def _getUrlFrame(self, fid):
|
762
|
+
if fid not in frames.URL_FIDS:
|
763
|
+
raise ValueError("Invalid URL frame-id")
|
764
|
+
f = self.frame_set[fid]
|
765
|
+
return f[0].url if f else None
|
766
|
+
|
767
|
+
@property
|
768
|
+
def commercial_url(self):
|
769
|
+
return self._getUrlFrame(frames.URL_COMMERCIAL_FID)
|
770
|
+
|
771
|
+
@commercial_url.setter
|
772
|
+
def commercial_url(self, url):
|
773
|
+
self._setUrlFrame(frames.URL_COMMERCIAL_FID, url)
|
774
|
+
|
775
|
+
@property
|
776
|
+
def copyright_url(self):
|
777
|
+
return self._getUrlFrame(frames.URL_COPYRIGHT_FID)
|
778
|
+
|
779
|
+
@copyright_url.setter
|
780
|
+
def copyright_url(self, url):
|
781
|
+
self._setUrlFrame(frames.URL_COPYRIGHT_FID, url)
|
782
|
+
|
783
|
+
@property
|
784
|
+
def audio_file_url(self):
|
785
|
+
return self._getUrlFrame(frames.URL_AUDIOFILE_FID)
|
786
|
+
|
787
|
+
@audio_file_url.setter
|
788
|
+
def audio_file_url(self, url):
|
789
|
+
self._setUrlFrame(frames.URL_AUDIOFILE_FID, url)
|
790
|
+
|
791
|
+
@property
|
792
|
+
def audio_source_url(self):
|
793
|
+
return self._getUrlFrame(frames.URL_AUDIOSRC_FID)
|
794
|
+
|
795
|
+
@audio_source_url.setter
|
796
|
+
def audio_source_url(self, url):
|
797
|
+
self._setUrlFrame(frames.URL_AUDIOSRC_FID, url)
|
798
|
+
|
799
|
+
@property
|
800
|
+
def artist_url(self):
|
801
|
+
return self._getUrlFrame(frames.URL_ARTIST_FID)
|
802
|
+
|
803
|
+
@artist_url.setter
|
804
|
+
def artist_url(self, url):
|
805
|
+
self._setUrlFrame(frames.URL_ARTIST_FID, url)
|
806
|
+
|
807
|
+
@property
|
808
|
+
def internet_radio_url(self):
|
809
|
+
return self._getUrlFrame(frames.URL_INET_RADIO_FID)
|
810
|
+
|
811
|
+
@internet_radio_url.setter
|
812
|
+
def internet_radio_url(self, url):
|
813
|
+
self._setUrlFrame(frames.URL_INET_RADIO_FID, url)
|
814
|
+
|
815
|
+
@property
|
816
|
+
def payment_url(self):
|
817
|
+
return self._getUrlFrame(frames.URL_PAYMENT_FID)
|
818
|
+
|
819
|
+
@payment_url.setter
|
820
|
+
def payment_url(self, url):
|
821
|
+
self._setUrlFrame(frames.URL_PAYMENT_FID, url)
|
822
|
+
|
823
|
+
@property
|
824
|
+
def publisher_url(self):
|
825
|
+
return self._getUrlFrame(frames.URL_PUBLISHER_FID)
|
826
|
+
|
827
|
+
@publisher_url.setter
|
828
|
+
def publisher_url(self, url):
|
829
|
+
self._setUrlFrame(frames.URL_PUBLISHER_FID, url)
|
830
|
+
|
831
|
+
@property
|
832
|
+
def user_url_frames(self):
|
833
|
+
return self._user_urls
|
834
|
+
|
835
|
+
@property
|
836
|
+
def unique_file_ids(self):
|
837
|
+
return self._unique_file_ids
|
838
|
+
|
839
|
+
@property
|
840
|
+
def terms_of_use(self):
|
841
|
+
if self.frame_set[frames.TOS_FID]:
|
842
|
+
return self.frame_set[frames.TOS_FID][0].text
|
843
|
+
|
844
|
+
@terms_of_use.setter
|
845
|
+
def terms_of_use(self, tos):
|
846
|
+
"""Set the terms of use text.
|
847
|
+
To specify a language (other than DEFAULT_LANG) code with the text pass
|
848
|
+
a tuple:
|
849
|
+
(text, lang)
|
850
|
+
Language codes are 3 *bytes* of ascii data.
|
851
|
+
"""
|
852
|
+
if isinstance(tos, tuple):
|
853
|
+
tos, lang = tos
|
854
|
+
else:
|
855
|
+
lang = DEFAULT_LANG
|
856
|
+
if self.frame_set[frames.TOS_FID]:
|
857
|
+
self.frame_set[frames.TOS_FID][0].text = tos
|
858
|
+
self.frame_set[frames.TOS_FID][0].lang = lang
|
859
|
+
else:
|
860
|
+
self.frame_set[frames.TOS_FID] = frames.TermsOfUseFrame(text=tos, lang=lang)
|
861
|
+
|
862
|
+
def _setCopyright(self, copyrt):
|
863
|
+
self.setTextFrame(frames.COPYRIGHT_FID, copyrt)
|
864
|
+
|
865
|
+
def _getCopyright(self):
|
866
|
+
if frames.COPYRIGHT_FID in self.frame_set:
|
867
|
+
return self.frame_set[frames.COPYRIGHT_FID][0].text
|
868
|
+
|
869
|
+
copyright = property(_getCopyright, _setCopyright)
|
870
|
+
|
871
|
+
def _setEncodedBy(self, enc):
|
872
|
+
self.setTextFrame(frames.ENCODED_BY_FID, enc)
|
873
|
+
|
874
|
+
def _getEncodedBy(self):
|
875
|
+
if frames.ENCODED_BY_FID in self.frame_set:
|
876
|
+
return self.frame_set[frames.ENCODED_BY_FID][0].text
|
877
|
+
|
878
|
+
encoded_by = property(_getEncodedBy, _setEncodedBy)
|
879
|
+
|
880
|
+
def _raiseIfReadonly(self):
|
881
|
+
if self.read_only:
|
882
|
+
raise RuntimeError("Tag is set read only.")
|
883
|
+
|
884
|
+
def save(self, filename=None, version=None, encoding=None, backup=False,
|
885
|
+
preserve_file_time=False, max_padding=None):
|
886
|
+
"""Save the tag. If ``filename`` is not give the value from the
|
887
|
+
``file_info`` member is used, or a ``TagException`` is raised. The
|
888
|
+
``version`` argument can be used to select an ID3 version other than
|
889
|
+
the version read. ``Select text encoding with ``encoding`` or use
|
890
|
+
the existing (or default) encoding. If ``backup`` is True the original
|
891
|
+
file is preserved; likewise if ``preserve_file_time`` is True the
|
892
|
+
file´s modification/access times are not updated.
|
893
|
+
"""
|
894
|
+
self._raiseIfReadonly()
|
895
|
+
|
896
|
+
if not (filename or self.file_info):
|
897
|
+
raise TagException("No file")
|
898
|
+
elif filename:
|
899
|
+
self.file_info = FileInfo(filename)
|
900
|
+
|
901
|
+
version = version if version else self.version
|
902
|
+
if version == ID3_V2_2:
|
903
|
+
raise NotImplementedError("Unable to write ID3 v2.2")
|
904
|
+
self.version = version
|
905
|
+
|
906
|
+
if backup and os.path.isfile(self.file_info.name):
|
907
|
+
backup_name = "%s.%s" % (self.file_info.name, "orig")
|
908
|
+
i = 1
|
909
|
+
while os.path.isfile(backup_name):
|
910
|
+
backup_name = "%s.%s.%d" % (self.file_info.name, "orig", i)
|
911
|
+
i += 1
|
912
|
+
shutil.copyfile(self.file_info.name, backup_name)
|
913
|
+
|
914
|
+
if version[0] == 1:
|
915
|
+
self._saveV1Tag(version)
|
916
|
+
elif version[0] == 2:
|
917
|
+
self._saveV2Tag(version, encoding, max_padding)
|
918
|
+
else:
|
919
|
+
assert not "Version bug: %s" % str(version)
|
920
|
+
|
921
|
+
if preserve_file_time and None not in (self.file_info.atime,
|
922
|
+
self.file_info.mtime):
|
923
|
+
self.file_info.touch((self.file_info.atime, self.file_info.mtime))
|
924
|
+
else:
|
925
|
+
self.file_info.initStatTimes()
|
926
|
+
|
927
|
+
def _saveV1Tag(self, version):
|
928
|
+
self._raiseIfReadonly()
|
929
|
+
|
930
|
+
assert version[0] == 1
|
931
|
+
|
932
|
+
def pack(s, n):
|
933
|
+
assert type(s) is bytes
|
934
|
+
if len(s) > n:
|
935
|
+
log.warning(f"ID3 v1.x text value truncated to length {n}")
|
936
|
+
return s.ljust(n, b'\x00')[:n]
|
937
|
+
|
938
|
+
def encode(s):
|
939
|
+
return s.encode("latin_1", "replace")
|
940
|
+
|
941
|
+
# Build tag buffer.
|
942
|
+
tag = b"TAG"
|
943
|
+
tag += pack(encode(self.title) if self.title else b"", ID3_V1_MAX_TEXTLEN)
|
944
|
+
tag += pack(encode(self.artist) if self.artist else b"", ID3_V1_MAX_TEXTLEN)
|
945
|
+
tag += pack(encode(self.album) if self.album else b"", ID3_V1_MAX_TEXTLEN)
|
946
|
+
|
947
|
+
release_date = self.getBestDate()
|
948
|
+
year = str(release_date.year).encode("ascii") if release_date else b""
|
949
|
+
tag += pack(year, 4)
|
950
|
+
|
951
|
+
cmt = ""
|
952
|
+
for c in self.comments:
|
953
|
+
if c.description == ID3_V1_COMMENT_DESC:
|
954
|
+
cmt = c.text
|
955
|
+
# We prefer this one over ""
|
956
|
+
break
|
957
|
+
elif c.description == "":
|
958
|
+
cmt = c.text
|
959
|
+
# Keep searching in case we find the description eyeD3 uses.
|
960
|
+
cmt = pack(encode(cmt), ID3_V1_MAX_TEXTLEN)
|
961
|
+
|
962
|
+
if version != ID3_V1_0:
|
963
|
+
track = self.track_num[0]
|
964
|
+
if track is not None:
|
965
|
+
cmt = cmt[0:28] + b"\x00" + bytes([int(track) & 0xff])
|
966
|
+
tag += cmt
|
967
|
+
|
968
|
+
if not self.genre or self.genre.id is None:
|
969
|
+
genre = 12 # Other
|
970
|
+
else:
|
971
|
+
genre = self.genre.id
|
972
|
+
tag += bytes([genre & 0xff])
|
973
|
+
|
974
|
+
assert len(tag) == 128
|
975
|
+
|
976
|
+
mode = "rb+" if os.path.isfile(self.file_info.name) else "w+b"
|
977
|
+
with open(self.file_info.name, mode) as tag_file:
|
978
|
+
# Write the tag over top an original or append it.
|
979
|
+
try:
|
980
|
+
tag_file.seek(-128, 2)
|
981
|
+
if tag_file.read(3) == b"TAG":
|
982
|
+
tag_file.seek(-128, 2)
|
983
|
+
else:
|
984
|
+
tag_file.seek(0, 2)
|
985
|
+
except IOError:
|
986
|
+
# File is smaller than 128 bytes.
|
987
|
+
tag_file.seek(0, 2)
|
988
|
+
|
989
|
+
tag_file.write(tag)
|
990
|
+
tag_file.flush()
|
991
|
+
|
992
|
+
def _checkForConversions(self, target_version):
|
993
|
+
"""Check the current frame set against `target_version` for frames
|
994
|
+
requiring conversion.
|
995
|
+
:param: The version the frames need to map to.
|
996
|
+
:returns: A 2-tuple where the first element is a list of frames that
|
997
|
+
are accepted for `target_version`, and the second a list of frames
|
998
|
+
requiring conversion.
|
999
|
+
"""
|
1000
|
+
std_frames = []
|
1001
|
+
non_std_frames = []
|
1002
|
+
for f in self.frame_set.getAllFrames():
|
1003
|
+
try:
|
1004
|
+
_, fversion, _ = frames.ID3_FRAMES[f.id]
|
1005
|
+
if fversion in (target_version, ID3_V2):
|
1006
|
+
std_frames.append(f)
|
1007
|
+
else:
|
1008
|
+
non_std_frames.append(f)
|
1009
|
+
except KeyError:
|
1010
|
+
# Not a standard frame (ID3_FRAMES)
|
1011
|
+
try:
|
1012
|
+
_, fversion, _ = frames.NONSTANDARD_ID3_FRAMES[f.id]
|
1013
|
+
# but is it one we can handle.
|
1014
|
+
if fversion in (target_version, ID3_V2):
|
1015
|
+
std_frames.append(f)
|
1016
|
+
else:
|
1017
|
+
non_std_frames.append(f)
|
1018
|
+
except KeyError:
|
1019
|
+
# Don't know anything about this pass it on for the error
|
1020
|
+
# check there.
|
1021
|
+
non_std_frames.append(f)
|
1022
|
+
|
1023
|
+
return std_frames, non_std_frames
|
1024
|
+
|
1025
|
+
def _render(self, version, curr_tag_size, max_padding_size):
|
1026
|
+
converted_frames = []
|
1027
|
+
std_frames, non_std_frames = self._checkForConversions(version)
|
1028
|
+
if non_std_frames:
|
1029
|
+
converted_frames = self._convertFrames(std_frames, non_std_frames,
|
1030
|
+
version)
|
1031
|
+
|
1032
|
+
# Render all frames first so the data size is known for the tag header.
|
1033
|
+
frame_data = b""
|
1034
|
+
for f in std_frames + converted_frames:
|
1035
|
+
frame_header = frames.FrameHeader(f.id, version)
|
1036
|
+
if f.header:
|
1037
|
+
frame_header.copyFlags(f.header)
|
1038
|
+
f.header = frame_header
|
1039
|
+
|
1040
|
+
log.debug(f"Rendering frame: {frame_header.id}")
|
1041
|
+
try:
|
1042
|
+
raw_frame = f.render()
|
1043
|
+
except Exception as ex:
|
1044
|
+
if not f.strict_rendering:
|
1045
|
+
log.warning(f"Ignoring failed render {f.__class__}: {ex}")
|
1046
|
+
continue
|
1047
|
+
else:
|
1048
|
+
raise
|
1049
|
+
log.debug(f"Rendered {len(raw_frame)} bytes")
|
1050
|
+
frame_data += raw_frame
|
1051
|
+
|
1052
|
+
log.debug("Rendered %d total frame bytes" % len(frame_data))
|
1053
|
+
|
1054
|
+
# eyeD3 never writes unsync'd data
|
1055
|
+
self.header.unsync = False
|
1056
|
+
|
1057
|
+
pending_size = TagHeader.SIZE + len(frame_data)
|
1058
|
+
if self.header.extended:
|
1059
|
+
# Using dummy data and padding, the actual size of this header
|
1060
|
+
# will be the same regardless, it's more about the flag bits
|
1061
|
+
tmp_ext_header_data = self.extended_header.render(version,
|
1062
|
+
b"\x00", 0)
|
1063
|
+
pending_size += len(tmp_ext_header_data)
|
1064
|
+
|
1065
|
+
if pending_size > curr_tag_size:
|
1066
|
+
# current tag (minus padding) larger than the current (plus padding)
|
1067
|
+
padding_size = DEFAULT_PADDING
|
1068
|
+
rewrite_required = True
|
1069
|
+
else:
|
1070
|
+
padding_size = curr_tag_size - pending_size
|
1071
|
+
if max_padding_size is not None and padding_size > max_padding_size:
|
1072
|
+
padding_size = min(DEFAULT_PADDING, max_padding_size)
|
1073
|
+
rewrite_required = True
|
1074
|
+
else:
|
1075
|
+
rewrite_required = False
|
1076
|
+
|
1077
|
+
assert padding_size >= 0
|
1078
|
+
log.debug(f"Using {padding_size} bytes of padding")
|
1079
|
+
|
1080
|
+
# Extended header
|
1081
|
+
ext_header_data = b""
|
1082
|
+
if self.header.extended:
|
1083
|
+
log.debug("Rendering extended header")
|
1084
|
+
ext_header_data += self.extended_header.render(self.header.version,
|
1085
|
+
frame_data,
|
1086
|
+
padding_size)
|
1087
|
+
|
1088
|
+
# Render the tag header.
|
1089
|
+
total_size = pending_size + padding_size
|
1090
|
+
data_size = total_size - TagHeader.SIZE
|
1091
|
+
log.debug(
|
1092
|
+
f"Rendering {versionToString(version)} tag header with size {data_size}"
|
1093
|
+
)
|
1094
|
+
header_data = self.header.render(data_size)
|
1095
|
+
|
1096
|
+
# Assemble the entire tag.
|
1097
|
+
tag_data = (header_data +
|
1098
|
+
ext_header_data +
|
1099
|
+
frame_data)
|
1100
|
+
assert len(tag_data) == (total_size - padding_size)
|
1101
|
+
return rewrite_required, tag_data, b"\x00" * padding_size
|
1102
|
+
|
1103
|
+
def _saveV2Tag(self, version, encoding, max_padding):
|
1104
|
+
self._raiseIfReadonly()
|
1105
|
+
|
1106
|
+
assert version[0] == 2 and version[1] != 2
|
1107
|
+
|
1108
|
+
log.debug("Rendering tag version: %s" % versionToString(version))
|
1109
|
+
|
1110
|
+
file_exists = os.path.exists(self.file_info.name)
|
1111
|
+
|
1112
|
+
if encoding:
|
1113
|
+
# Any invalid encoding is going to get coerced to a valid value
|
1114
|
+
# when the frame is rendered.
|
1115
|
+
for f in self.frame_set.getAllFrames():
|
1116
|
+
f.encoding = frames.stringToEncoding(encoding)
|
1117
|
+
|
1118
|
+
curr_tag_size = 0
|
1119
|
+
|
1120
|
+
if file_exists:
|
1121
|
+
# We may be converting from 1.x to 2.x so we need to find any
|
1122
|
+
# current v2.x tag otherwise we're gonna hork the file.
|
1123
|
+
# This also resets all offsets, state, etc. and makes me feel safe.
|
1124
|
+
tmp_tag = Tag()
|
1125
|
+
if tmp_tag.parse(self.file_info.name, ID3_V2):
|
1126
|
+
log.debug("Found current v2.x tag:")
|
1127
|
+
curr_tag_size = tmp_tag.file_info.tag_size
|
1128
|
+
log.debug("Current tag size: %d" % curr_tag_size)
|
1129
|
+
|
1130
|
+
rewrite_required, tag_data, padding = self._render(version,
|
1131
|
+
curr_tag_size,
|
1132
|
+
max_padding)
|
1133
|
+
log.debug("Writing %d bytes of tag data and %d bytes of "
|
1134
|
+
"padding" % (len(tag_data), len(padding)))
|
1135
|
+
if rewrite_required:
|
1136
|
+
# Open tmp file
|
1137
|
+
with tempfile.NamedTemporaryFile("wb", delete=False) \
|
1138
|
+
as tmp_file:
|
1139
|
+
tmp_file.write(tag_data + padding)
|
1140
|
+
|
1141
|
+
# Copy audio data in chunks
|
1142
|
+
with open(self.file_info.name, "rb") as tag_file:
|
1143
|
+
if curr_tag_size != 0:
|
1144
|
+
seek_point = curr_tag_size
|
1145
|
+
else:
|
1146
|
+
seek_point = 0
|
1147
|
+
log.debug("Seeking to beginning of audio data, "
|
1148
|
+
"byte %d (%x)" % (seek_point, seek_point))
|
1149
|
+
tag_file.seek(seek_point)
|
1150
|
+
chunkCopy(tag_file, tmp_file)
|
1151
|
+
|
1152
|
+
tmp_file.flush()
|
1153
|
+
|
1154
|
+
# Move tmp to orig.
|
1155
|
+
shutil.copyfile(tmp_file.name, self.file_info.name)
|
1156
|
+
os.unlink(tmp_file.name)
|
1157
|
+
|
1158
|
+
else:
|
1159
|
+
with open(self.file_info.name, "r+b") as tag_file:
|
1160
|
+
tag_file.write(tag_data + padding)
|
1161
|
+
|
1162
|
+
else:
|
1163
|
+
_, tag_data, padding = self._render(version, 0, None)
|
1164
|
+
with open(self.file_info.name, "wb") as tag_file:
|
1165
|
+
tag_file.write(tag_data + padding)
|
1166
|
+
|
1167
|
+
log.debug("Tag write complete. Updating FileInfo state.")
|
1168
|
+
self.file_info.tag_size = len(tag_data) + len(padding)
|
1169
|
+
|
1170
|
+
def _convertFrames_v1(self, std_frames, convert_list, version) -> list:
|
1171
|
+
assert version[0] == 1
|
1172
|
+
converted_frames = []
|
1173
|
+
|
1174
|
+
track_num_frame = None
|
1175
|
+
for frame in std_frames:
|
1176
|
+
if frame.id == frames.TRACKNUM_FID:
|
1177
|
+
# Find track_num so it can be enforced for 1.1
|
1178
|
+
track_num_frame = frame
|
1179
|
+
elif frame.id == frames.COMMENT_FID and frame.description == ID3_V1_COMMENT_DESC:
|
1180
|
+
# Comments truncated to make room for v1.1 track
|
1181
|
+
if version == ID3_V1_1:
|
1182
|
+
if len(frame.text) > ID3_V1_MAX_TEXTLEN - 2:
|
1183
|
+
trunc_text = frame.text[:ID3_V1_MAX_TEXTLEN - 2]
|
1184
|
+
log.info(f"Truncating ID3 v1 comment due to tag conversion: {frame.text}")
|
1185
|
+
frame.text = trunc_text
|
1186
|
+
|
1187
|
+
# v1.1 must have a track num
|
1188
|
+
if track_num_frame is None and version == ID3_V1_1:
|
1189
|
+
log.info("ID3 v1.0->v1.1 conversion forces track number, defaulting to 1")
|
1190
|
+
std_frames.append(frames.TextFrame(frames.TRACKNUM_FID, "1"))
|
1191
|
+
# v1.0 must not
|
1192
|
+
elif track_num_frame is not None and version == ID3_V1_0:
|
1193
|
+
log.info("ID3 v1.1->v1.0 conversion forces deleting track number")
|
1194
|
+
std_frames.remove(track_num_frame)
|
1195
|
+
|
1196
|
+
for frame in list(convert_list):
|
1197
|
+
# Let date frames thru, the right thing will happen on save
|
1198
|
+
if isinstance(frame, frames.DateFrame):
|
1199
|
+
converted_frames.append(frame)
|
1200
|
+
convert_list.remove(frame)
|
1201
|
+
|
1202
|
+
return converted_frames
|
1203
|
+
|
1204
|
+
def _convertFrames(self, std_frames, convert_list, version) -> list:
|
1205
|
+
"""Maps frame incompatibilities between ID3 tag versions.
|
1206
|
+
|
1207
|
+
The items in ``std_frames`` need no conversion, but the list/frames
|
1208
|
+
may be edited if necessary (e.g. a converted frame replaces a frame
|
1209
|
+
in the list). The items in ``convert_list`` are the frames to convert
|
1210
|
+
and return. The ``version`` is the target ID3 version."""
|
1211
|
+
from . import versionToString
|
1212
|
+
from .frames import DATE_FIDS, DEPRECATED_DATE_FIDS, DateFrame, TextFrame
|
1213
|
+
|
1214
|
+
if version[0] == 1:
|
1215
|
+
return self._convertFrames_v1(std_frames, convert_list, version)
|
1216
|
+
|
1217
|
+
# Only ID3 v2.x onward
|
1218
|
+
assert version[0] != 1
|
1219
|
+
converted_frames = []
|
1220
|
+
flist = list(convert_list)
|
1221
|
+
|
1222
|
+
# Date frame conversions.
|
1223
|
+
date_frames = {}
|
1224
|
+
for f in flist:
|
1225
|
+
if version == ID3_V2_4:
|
1226
|
+
if f.id in DEPRECATED_DATE_FIDS:
|
1227
|
+
date_frames[f.id] = f
|
1228
|
+
else:
|
1229
|
+
if f.id in DATE_FIDS:
|
1230
|
+
date_frames[f.id] = f
|
1231
|
+
|
1232
|
+
if date_frames:
|
1233
|
+
def fidHandled(_fid):
|
1234
|
+
# A duplicate text frame (illegal ID3 but oft seen) may exist. The date_frames dict
|
1235
|
+
# will have one, but the flist has multiple, hence the loop.
|
1236
|
+
for _frame in list(flist):
|
1237
|
+
if _frame.id == _fid:
|
1238
|
+
flist.remove(_frame)
|
1239
|
+
del date_frames[_fid]
|
1240
|
+
|
1241
|
+
if version == ID3_V2_4:
|
1242
|
+
if b"TORY" in date_frames or b"XDOR" in date_frames:
|
1243
|
+
# XDOR -> TDOR (full date)
|
1244
|
+
# TORY -> TDOR (year only)
|
1245
|
+
date = self._getV23OriginalReleaseDate()
|
1246
|
+
if date:
|
1247
|
+
converted_frames.append(DateFrame(b"TDOR", date))
|
1248
|
+
for fid in (b"TORY", b"XDOR"):
|
1249
|
+
if fid in flist:
|
1250
|
+
fidHandled(fid)
|
1251
|
+
|
1252
|
+
# TYER, TDAT, TIME -> TDRC
|
1253
|
+
if (b"TYER" in date_frames or b"TDAT" in date_frames or b"TIME" in date_frames):
|
1254
|
+
date = self._getV23RecordingDate()
|
1255
|
+
if date:
|
1256
|
+
converted_frames.append(DateFrame(b"TDRC", date))
|
1257
|
+
for fid in [b"TYER", b"TDAT", b"TIME"]:
|
1258
|
+
if fid in date_frames:
|
1259
|
+
fidHandled(fid)
|
1260
|
+
|
1261
|
+
elif version == ID3_V2_3:
|
1262
|
+
if b"TDOR" in date_frames:
|
1263
|
+
date = date_frames[b"TDOR"].date
|
1264
|
+
if date:
|
1265
|
+
# TORY is year only
|
1266
|
+
converted_frames.append(DateFrame(b"TORY", str(date.year)))
|
1267
|
+
if date and date.month:
|
1268
|
+
converted_frames.append(DateFrame(b"XDOR", str(date)))
|
1269
|
+
fidHandled(b"TDOR")
|
1270
|
+
|
1271
|
+
if b"TDRC" in date_frames:
|
1272
|
+
date = date_frames[b"TDRC"].date
|
1273
|
+
|
1274
|
+
if date:
|
1275
|
+
converted_frames.append(DateFrame(b"TYER", str(date.year)))
|
1276
|
+
if None not in (date.month, date.day):
|
1277
|
+
date_str = "%s%s" %\
|
1278
|
+
(str(date.day).rjust(2, "0"),
|
1279
|
+
str(date.month).rjust(2, "0"))
|
1280
|
+
converted_frames.append(TextFrame(b"TDAT",
|
1281
|
+
date_str))
|
1282
|
+
if None not in (date.hour, date.minute):
|
1283
|
+
date_str = "%s%s" %\
|
1284
|
+
(str(date.hour).rjust(2, "0"),
|
1285
|
+
str(date.minute).rjust(2, "0"))
|
1286
|
+
converted_frames.append(TextFrame(b"TIME",
|
1287
|
+
date_str))
|
1288
|
+
|
1289
|
+
fidHandled(b"TDRC")
|
1290
|
+
|
1291
|
+
if b"TDRL" in date_frames:
|
1292
|
+
# TDRL -> Nothing
|
1293
|
+
log.warning("TDRL value dropped.")
|
1294
|
+
fidHandled(b"TDRL")
|
1295
|
+
|
1296
|
+
# All other date frames have no conversion
|
1297
|
+
for fid in date_frames:
|
1298
|
+
log.warning(f"{str(fid, 'ascii')} frame being dropped due to conversion to "
|
1299
|
+
f"{versionToString(version)}")
|
1300
|
+
flist.remove(date_frames[fid])
|
1301
|
+
|
1302
|
+
# Convert sort order frames 2.3 (XSO*) <-> 2.4 (TSO*)
|
1303
|
+
prefix = b"X" if version == ID3_V2_4 else b"T"
|
1304
|
+
fids = [prefix + suffix for suffix in [b"SOA", b"SOP", b"SOT"]]
|
1305
|
+
soframes = [f for f in flist if f.id in fids]
|
1306
|
+
|
1307
|
+
for frame in soframes:
|
1308
|
+
frame.id = (b"X" if prefix == b"T" else b"T") + frame.id[1:]
|
1309
|
+
flist.remove(frame)
|
1310
|
+
converted_frames.append(frame)
|
1311
|
+
|
1312
|
+
# TSIZ (v2.3) are completely deprecated, remove them
|
1313
|
+
if version == ID3_V2_4:
|
1314
|
+
flist = [f for f in flist if f.id != b"TSIZ"]
|
1315
|
+
|
1316
|
+
# TSST (v2.4) --> TIT3 (2.3)
|
1317
|
+
if version == ID3_V2_3 and b"TSST" in [f.id for f in flist]:
|
1318
|
+
tsst_frame = [f for f in flist if f.id == b"TSST"][0]
|
1319
|
+
flist.remove(tsst_frame)
|
1320
|
+
tsst_frame = frames.UserTextFrame(
|
1321
|
+
description="Subtitle (converted)", text=tsst_frame.text)
|
1322
|
+
converted_frames.append(tsst_frame)
|
1323
|
+
|
1324
|
+
# RVAD (v2.3) --> RVA2* (2.4)
|
1325
|
+
if version == ID3_V2_4 and b"RVAD" in [f.id for f in flist]:
|
1326
|
+
rvad = [f for f in flist if f.id == b"RVAD"][0]
|
1327
|
+
for rva2 in rvad.toV24():
|
1328
|
+
converted_frames.append(rva2)
|
1329
|
+
flist.remove(rvad)
|
1330
|
+
# RVA2* (v2.4) --> RVAD (2.3)
|
1331
|
+
elif version == ID3_V2_3 and b"RVA2" in [f.id for f in flist]:
|
1332
|
+
adj = frames.RelVolAdjFrameV23.VolumeAdjustments()
|
1333
|
+
for rva2 in [f for f in flist if f.id == b"RVA2"]:
|
1334
|
+
adj.setChannelAdj(rva2.channel_type, rva2.adjustment * 512)
|
1335
|
+
adj.setChannelPeak(rva2.channel_type, rva2.peak)
|
1336
|
+
flist.remove(rva2)
|
1337
|
+
|
1338
|
+
rvad = frames.RelVolAdjFrameV23()
|
1339
|
+
rvad.adjustments = adj
|
1340
|
+
converted_frames.append(rvad)
|
1341
|
+
|
1342
|
+
# Raise an error for frames that could not be converted.
|
1343
|
+
if len(flist) != 0:
|
1344
|
+
unconverted = ", ".join([f.id.decode("ascii") for f in flist])
|
1345
|
+
if version[0] != 1:
|
1346
|
+
raise TagException("Unable to convert the following frames to "
|
1347
|
+
f"version {versionToString(version)}: {unconverted}")
|
1348
|
+
|
1349
|
+
# Some frames in converted_frames may replace/edit frames in std_frames.
|
1350
|
+
for cframe in converted_frames:
|
1351
|
+
for sframe in std_frames:
|
1352
|
+
if cframe.id == sframe.id:
|
1353
|
+
std_frames.remove(sframe)
|
1354
|
+
|
1355
|
+
return converted_frames
|
1356
|
+
|
1357
|
+
@staticmethod
|
1358
|
+
def remove(filename, version=ID3_ANY_VERSION, preserve_file_time=False):
|
1359
|
+
tag = None
|
1360
|
+
retval = False
|
1361
|
+
|
1362
|
+
if version[0] & ID3_V1[0]:
|
1363
|
+
# ID3 v1.x
|
1364
|
+
tag = Tag()
|
1365
|
+
with open(filename, "r+b") as tag_file:
|
1366
|
+
found = tag.parse(tag_file, ID3_V1)
|
1367
|
+
if found:
|
1368
|
+
tag_file.seek(-128, 2)
|
1369
|
+
log.debug("Removing ID3 v1.x Tag")
|
1370
|
+
tag_file.truncate()
|
1371
|
+
retval |= True
|
1372
|
+
|
1373
|
+
if version[0] & ID3_V2[0]:
|
1374
|
+
tag = Tag()
|
1375
|
+
with open(filename, "rb") as tag_file:
|
1376
|
+
found = tag.parse(tag_file, ID3_V2)
|
1377
|
+
if found:
|
1378
|
+
log.debug("Removing ID3 %s tag" %
|
1379
|
+
versionToString(tag.version))
|
1380
|
+
tag_file.seek(tag.file_info.tag_size)
|
1381
|
+
|
1382
|
+
# Open tmp file
|
1383
|
+
with tempfile.NamedTemporaryFile("wb", delete=False) \
|
1384
|
+
as tmp_file:
|
1385
|
+
chunkCopy(tag_file, tmp_file)
|
1386
|
+
|
1387
|
+
# Move tmp to orig
|
1388
|
+
shutil.copyfile(tmp_file.name, filename)
|
1389
|
+
os.unlink(tmp_file.name)
|
1390
|
+
|
1391
|
+
retval |= True
|
1392
|
+
|
1393
|
+
if preserve_file_time and retval and None not in (tag.file_info.atime,
|
1394
|
+
tag.file_info.mtime):
|
1395
|
+
tag.file_info.touch((tag.file_info.atime, tag.file_info.mtime))
|
1396
|
+
|
1397
|
+
return retval
|
1398
|
+
|
1399
|
+
@property
|
1400
|
+
def chapters(self):
|
1401
|
+
return self._chapters
|
1402
|
+
|
1403
|
+
@property
|
1404
|
+
def table_of_contents(self):
|
1405
|
+
return self._tocs
|
1406
|
+
|
1407
|
+
@property
|
1408
|
+
def album_type(self):
|
1409
|
+
if TXXX_ALBUM_TYPE in self.user_text_frames:
|
1410
|
+
return self.user_text_frames.get(TXXX_ALBUM_TYPE).text
|
1411
|
+
else:
|
1412
|
+
return None
|
1413
|
+
|
1414
|
+
@album_type.setter
|
1415
|
+
def album_type(self, t):
|
1416
|
+
if not t:
|
1417
|
+
self.user_text_frames.remove(TXXX_ALBUM_TYPE)
|
1418
|
+
elif t in ALBUM_TYPE_IDS:
|
1419
|
+
self.user_text_frames.set(t, TXXX_ALBUM_TYPE)
|
1420
|
+
else:
|
1421
|
+
raise ValueError("Invalid album_type: %s" % t)
|
1422
|
+
|
1423
|
+
@property
|
1424
|
+
def artist_origin(self):
|
1425
|
+
"""Returns None or a `ArtistOrigin` dataclass: (city, state, country) Any may be ``None``.
|
1426
|
+
"""
|
1427
|
+
if TXXX_ARTIST_ORIGIN not in self.user_text_frames:
|
1428
|
+
return None
|
1429
|
+
|
1430
|
+
origin = self.user_text_frames.get(TXXX_ARTIST_ORIGIN).text
|
1431
|
+
vals = origin.split('\t')
|
1432
|
+
|
1433
|
+
vals.extend([None] * (3 - len(vals)))
|
1434
|
+
vals = [None if not v else v for v in vals]
|
1435
|
+
return ArtistOrigin(*vals)
|
1436
|
+
|
1437
|
+
@artist_origin.setter
|
1438
|
+
def artist_origin(self, origin: ArtistOrigin):
|
1439
|
+
if origin is None or origin == (None, None, None):
|
1440
|
+
self.user_text_frames.remove(TXXX_ARTIST_ORIGIN)
|
1441
|
+
else:
|
1442
|
+
self.user_text_frames.set(origin.id3Encode(), TXXX_ARTIST_ORIGIN)
|
1443
|
+
|
1444
|
+
def frameiter(self, fids=None):
|
1445
|
+
"""A iterator for tag frames. If ``fids`` is passed it must be a list
|
1446
|
+
of frame IDs to filter and return."""
|
1447
|
+
fids = fids or []
|
1448
|
+
fids = [(b(f, codecs.ascii_encode) if isinstance(f, str) else f) for f in fids]
|
1449
|
+
for f in self.frame_set.getAllFrames():
|
1450
|
+
if not fids or f.id in fids:
|
1451
|
+
yield f
|
1452
|
+
|
1453
|
+
def _getOrigArtist(self):
|
1454
|
+
return self.getTextFrame(frames.ORIG_ARTIST_FID)
|
1455
|
+
|
1456
|
+
def _setOrigArtist(self, name):
|
1457
|
+
self.setTextFrame(frames.ORIG_ARTIST_FID, name)
|
1458
|
+
|
1459
|
+
@property
|
1460
|
+
def original_artist(self):
|
1461
|
+
return self._getOrigArtist()
|
1462
|
+
|
1463
|
+
@original_artist.setter
|
1464
|
+
def original_artist(self, name):
|
1465
|
+
self._setOrigArtist(name)
|
1466
|
+
|
1467
|
+
|
1468
|
+
class FileInfo:
|
1469
|
+
"""
|
1470
|
+
This class is for storing information about a parsed file. It contains info
|
1471
|
+
such as the filename, original tag size, and amount of padding; all of which
|
1472
|
+
can make rewriting faster.
|
1473
|
+
"""
|
1474
|
+
def __init__(self, file_name, tagsz=0, tpadd=0):
|
1475
|
+
from .. import LOCAL_FS_ENCODING
|
1476
|
+
|
1477
|
+
if type(file_name) is str:
|
1478
|
+
self.name = file_name
|
1479
|
+
else:
|
1480
|
+
try:
|
1481
|
+
self.name = str(file_name, LOCAL_FS_ENCODING)
|
1482
|
+
except UnicodeDecodeError:
|
1483
|
+
# Work around the local encoding not matching that of a mounted
|
1484
|
+
# filesystem
|
1485
|
+
log.warning("Mismatched file system encoding for file '%s'" %
|
1486
|
+
repr(file_name))
|
1487
|
+
self.name = file_name
|
1488
|
+
|
1489
|
+
self.tag_size = tagsz or 0 # This includes the padding byte count.
|
1490
|
+
self.tag_padding_size = tpadd or 0
|
1491
|
+
|
1492
|
+
self.atime, self.mtime = None, None
|
1493
|
+
self.initStatTimes()
|
1494
|
+
|
1495
|
+
def initStatTimes(self):
|
1496
|
+
try:
|
1497
|
+
s = os.stat(self.name)
|
1498
|
+
except OSError:
|
1499
|
+
self.atime, self.mtime = None, None
|
1500
|
+
else:
|
1501
|
+
self.atime, self.mtime = s.st_atime, s.st_mtime
|
1502
|
+
|
1503
|
+
def touch(self, times):
|
1504
|
+
"""times is a 2-tuple of (atime, mtime)."""
|
1505
|
+
os.utime(self.name, times)
|
1506
|
+
self.initStatTimes()
|
1507
|
+
|
1508
|
+
|
1509
|
+
class AccessorBase:
|
1510
|
+
def __init__(self, fid, fs, match_func=None):
|
1511
|
+
self._fid = fid
|
1512
|
+
self._fs = fs
|
1513
|
+
self._match_func = match_func
|
1514
|
+
|
1515
|
+
def __iter__(self):
|
1516
|
+
for f in self._fs[self._fid] or []:
|
1517
|
+
yield f
|
1518
|
+
|
1519
|
+
def __len__(self):
|
1520
|
+
return len(self._fs[self._fid] or [])
|
1521
|
+
|
1522
|
+
def __getitem__(self, i):
|
1523
|
+
frames = self._fs[self._fid]
|
1524
|
+
if not frames:
|
1525
|
+
raise IndexError("list index out of range")
|
1526
|
+
return frames[i]
|
1527
|
+
|
1528
|
+
def get(self, *args, **kwargs):
|
1529
|
+
for frame in self._fs[self._fid] or []:
|
1530
|
+
if self._match_func(frame, *args, **kwargs):
|
1531
|
+
return frame
|
1532
|
+
return None
|
1533
|
+
|
1534
|
+
def remove(self, *args, **kwargs):
|
1535
|
+
"""Returns the removed item or ``None`` if not found."""
|
1536
|
+
fid_frames = self._fs[self._fid] or []
|
1537
|
+
for frame in fid_frames:
|
1538
|
+
if self._match_func(frame, *args, **kwargs):
|
1539
|
+
fid_frames.remove(frame)
|
1540
|
+
return frame
|
1541
|
+
return None
|
1542
|
+
|
1543
|
+
|
1544
|
+
class DltAccessor(AccessorBase):
|
1545
|
+
"""Access matching tag frames by "description" and/or "lang" values."""
|
1546
|
+
def __init__(self, FrameClass, fid, fs):
|
1547
|
+
def match_func(frame, description, lang=DEFAULT_LANG):
|
1548
|
+
return (frame.description == description and
|
1549
|
+
frame.lang == (lang if isinstance(lang, bytes)
|
1550
|
+
else lang.encode("ascii")))
|
1551
|
+
|
1552
|
+
super().__init__(fid, fs, match_func)
|
1553
|
+
self.FrameClass = FrameClass
|
1554
|
+
|
1555
|
+
@requireUnicode(1, 2)
|
1556
|
+
def set(self, text, description="", lang=DEFAULT_LANG):
|
1557
|
+
lang = lang or DEFAULT_LANG
|
1558
|
+
for f in self._fs[self._fid] or []:
|
1559
|
+
if f.description == description and f.lang == lang:
|
1560
|
+
# Exists, update text
|
1561
|
+
f.text = text
|
1562
|
+
return f
|
1563
|
+
|
1564
|
+
new_frame = self.FrameClass(description=description, lang=lang,
|
1565
|
+
text=text)
|
1566
|
+
self._fs[self._fid] = new_frame
|
1567
|
+
return new_frame
|
1568
|
+
|
1569
|
+
@requireUnicode(1)
|
1570
|
+
def remove(self, description, lang=DEFAULT_LANG):
|
1571
|
+
return super().remove(description, lang=lang or DEFAULT_LANG)
|
1572
|
+
|
1573
|
+
@requireUnicode(1)
|
1574
|
+
def get(self, description, lang=DEFAULT_LANG):
|
1575
|
+
return super().get(description, lang=lang or DEFAULT_LANG)
|
1576
|
+
|
1577
|
+
|
1578
|
+
class CommentsAccessor(DltAccessor):
|
1579
|
+
def __init__(self, fs):
|
1580
|
+
super().__init__(frames.CommentFrame, frames.COMMENT_FID, fs)
|
1581
|
+
|
1582
|
+
|
1583
|
+
class LyricsAccessor(DltAccessor):
|
1584
|
+
def __init__(self, fs):
|
1585
|
+
super().__init__(frames.LyricsFrame, frames.LYRICS_FID, fs)
|
1586
|
+
|
1587
|
+
|
1588
|
+
class ImagesAccessor(AccessorBase):
|
1589
|
+
def __init__(self, fs):
|
1590
|
+
def match_func(frame, description):
|
1591
|
+
return frame.description == description
|
1592
|
+
super().__init__(frames.IMAGE_FID, fs, match_func)
|
1593
|
+
|
1594
|
+
@requireUnicode("description")
|
1595
|
+
def set(self, type_, img_data, mime_type, description="", img_url=None):
|
1596
|
+
"""Add an image of ``type_`` (a type constant from ImageFrame).
|
1597
|
+
The ``img_data`` is either bytes or ``None``. In the latter case
|
1598
|
+
``img_url`` MUST be the URL to the image. In this case ``mime_type``
|
1599
|
+
is ignored and "-->" is used to signal this as a link and not data
|
1600
|
+
(per the ID3 spec)."""
|
1601
|
+
img_url = b(img_url) if img_url else None
|
1602
|
+
|
1603
|
+
if not img_data and not img_url:
|
1604
|
+
raise ValueError("img_url MUST not be none when no image data")
|
1605
|
+
|
1606
|
+
mime_type = mime_type if img_data else frames.ImageFrame.URL_MIME_TYPE
|
1607
|
+
mime_type = b(mime_type)
|
1608
|
+
|
1609
|
+
images = self._fs[frames.IMAGE_FID] or []
|
1610
|
+
for img in images:
|
1611
|
+
if img.description == description:
|
1612
|
+
# update
|
1613
|
+
if not img_data:
|
1614
|
+
img.image_url = img_url
|
1615
|
+
img.image_data = None
|
1616
|
+
img.mime_type = frames.ImageFrame.URL_MIME_TYPE
|
1617
|
+
else:
|
1618
|
+
img.image_url = None
|
1619
|
+
img.image_data = img_data
|
1620
|
+
img.mime_type = mime_type
|
1621
|
+
img.picture_type = type_
|
1622
|
+
return img
|
1623
|
+
|
1624
|
+
img_frame = frames.ImageFrame(description=description,
|
1625
|
+
image_data=img_data,
|
1626
|
+
image_url=img_url,
|
1627
|
+
mime_type=mime_type,
|
1628
|
+
picture_type=type_)
|
1629
|
+
self._fs[frames.IMAGE_FID] = img_frame
|
1630
|
+
return img_frame
|
1631
|
+
|
1632
|
+
@requireUnicode(1)
|
1633
|
+
def remove(self, description):
|
1634
|
+
return super().remove(description)
|
1635
|
+
|
1636
|
+
@requireUnicode(1)
|
1637
|
+
def get(self, description):
|
1638
|
+
return super().get(description)
|
1639
|
+
|
1640
|
+
|
1641
|
+
class ObjectsAccessor(AccessorBase):
|
1642
|
+
def __init__(self, fs):
|
1643
|
+
def match_func(frame, description):
|
1644
|
+
return frame.description == description
|
1645
|
+
super().__init__(frames.OBJECT_FID, fs, match_func)
|
1646
|
+
|
1647
|
+
@requireUnicode("description", "filename")
|
1648
|
+
def set(self, data, mime_type, description="", filename=""):
|
1649
|
+
objects = self._fs[frames.OBJECT_FID] or []
|
1650
|
+
for obj in objects:
|
1651
|
+
if obj.description == description:
|
1652
|
+
# update
|
1653
|
+
obj.object_data = data
|
1654
|
+
obj.mime_type = mime_type
|
1655
|
+
obj.filename = filename
|
1656
|
+
return obj
|
1657
|
+
|
1658
|
+
obj_frame = frames.ObjectFrame(description=description,
|
1659
|
+
filename=filename,
|
1660
|
+
object_data=data,
|
1661
|
+
mime_type=mime_type)
|
1662
|
+
self._fs[frames.OBJECT_FID] = obj_frame
|
1663
|
+
return obj_frame
|
1664
|
+
|
1665
|
+
@requireUnicode(1)
|
1666
|
+
def remove(self, description):
|
1667
|
+
return super().remove(description)
|
1668
|
+
|
1669
|
+
@requireUnicode(1)
|
1670
|
+
def get(self, description):
|
1671
|
+
return super().get(description)
|
1672
|
+
|
1673
|
+
|
1674
|
+
class PrivatesAccessor(AccessorBase):
|
1675
|
+
def __init__(self, fs):
|
1676
|
+
def match_func(frame, owner_id):
|
1677
|
+
return frame.owner_id == owner_id
|
1678
|
+
super().__init__(frames.PRIVATE_FID, fs, match_func)
|
1679
|
+
|
1680
|
+
def set(self, data, owner_id):
|
1681
|
+
priv_frames = self._fs[frames.PRIVATE_FID] or []
|
1682
|
+
for f in priv_frames:
|
1683
|
+
if f.owner_id == owner_id:
|
1684
|
+
# update
|
1685
|
+
f.owner_data = data
|
1686
|
+
return f
|
1687
|
+
|
1688
|
+
priv_frame = frames.PrivateFrame(owner_id=owner_id, owner_data=data)
|
1689
|
+
self._fs[frames.PRIVATE_FID] = priv_frame
|
1690
|
+
return priv_frame
|
1691
|
+
|
1692
|
+
def remove(self, owner_id):
|
1693
|
+
return super().remove(owner_id)
|
1694
|
+
|
1695
|
+
def get(self, owner_id):
|
1696
|
+
return super().get(owner_id)
|
1697
|
+
|
1698
|
+
|
1699
|
+
class UserTextsAccessor(AccessorBase):
|
1700
|
+
def __init__(self, fs):
|
1701
|
+
def match_func(frame, description):
|
1702
|
+
return frame.description == description
|
1703
|
+
super().__init__(frames.USERTEXT_FID, fs, match_func)
|
1704
|
+
|
1705
|
+
@requireUnicode(1, "description")
|
1706
|
+
def set(self, text, description=""):
|
1707
|
+
flist = self._fs[frames.USERTEXT_FID] or []
|
1708
|
+
for utf in flist:
|
1709
|
+
if utf.description == description:
|
1710
|
+
# update
|
1711
|
+
utf.text = text
|
1712
|
+
return utf
|
1713
|
+
|
1714
|
+
utf = frames.UserTextFrame(description=description,
|
1715
|
+
text=text)
|
1716
|
+
self._fs[frames.USERTEXT_FID] = utf
|
1717
|
+
return utf
|
1718
|
+
|
1719
|
+
@requireUnicode(1)
|
1720
|
+
def remove(self, description):
|
1721
|
+
return super().remove(description)
|
1722
|
+
|
1723
|
+
@requireUnicode(1)
|
1724
|
+
def get(self, description):
|
1725
|
+
return super().get(description)
|
1726
|
+
|
1727
|
+
@requireUnicode(1)
|
1728
|
+
def __contains__(self, description):
|
1729
|
+
return bool(self.get(description))
|
1730
|
+
|
1731
|
+
|
1732
|
+
class UniqueFileIdAccessor(AccessorBase):
|
1733
|
+
def __init__(self, fs):
|
1734
|
+
def match_func(frame, owner_id):
|
1735
|
+
return frame.owner_id == owner_id
|
1736
|
+
super().__init__(frames.UNIQUE_FILE_ID_FID, fs, match_func)
|
1737
|
+
|
1738
|
+
def set(self, data, owner_id):
|
1739
|
+
data, owner_id = b(data), b(owner_id)
|
1740
|
+
if len(data) > 64:
|
1741
|
+
raise TagException("UFID data must be 64 bytes or less")
|
1742
|
+
|
1743
|
+
flist = self._fs[frames.UNIQUE_FILE_ID_FID] or []
|
1744
|
+
for f in flist:
|
1745
|
+
if f.owner_id == owner_id:
|
1746
|
+
# update
|
1747
|
+
f.uniq_id = data
|
1748
|
+
return f
|
1749
|
+
|
1750
|
+
uniq_id_frame = frames.UniqueFileIDFrame(owner_id=owner_id,
|
1751
|
+
uniq_id=data)
|
1752
|
+
self._fs[frames.UNIQUE_FILE_ID_FID] = uniq_id_frame
|
1753
|
+
return uniq_id_frame
|
1754
|
+
|
1755
|
+
def remove(self, owner_id):
|
1756
|
+
owner_id = b(owner_id)
|
1757
|
+
return super().remove(owner_id)
|
1758
|
+
|
1759
|
+
def get(self, owner_id):
|
1760
|
+
owner_id = b(owner_id)
|
1761
|
+
return super().get(owner_id)
|
1762
|
+
|
1763
|
+
|
1764
|
+
class UserUrlsAccessor(AccessorBase):
|
1765
|
+
def __init__(self, fs):
|
1766
|
+
def match_func(frame, description):
|
1767
|
+
return frame.description == description
|
1768
|
+
super().__init__(frames.USERURL_FID, fs, match_func)
|
1769
|
+
|
1770
|
+
@requireUnicode("description")
|
1771
|
+
def set(self, url, description=""):
|
1772
|
+
flist = self._fs[frames.USERURL_FID] or []
|
1773
|
+
for uuf in flist:
|
1774
|
+
if uuf.description == description:
|
1775
|
+
# update
|
1776
|
+
uuf.url = url
|
1777
|
+
return uuf
|
1778
|
+
|
1779
|
+
uuf = frames.UserUrlFrame(description=description, url=url)
|
1780
|
+
self._fs[frames.USERURL_FID] = uuf
|
1781
|
+
return uuf
|
1782
|
+
|
1783
|
+
@requireUnicode(1)
|
1784
|
+
def remove(self, description):
|
1785
|
+
return super().remove(description)
|
1786
|
+
|
1787
|
+
@requireUnicode(1)
|
1788
|
+
def get(self, description):
|
1789
|
+
return super().get(description)
|
1790
|
+
|
1791
|
+
|
1792
|
+
class PopularitiesAccessor(AccessorBase):
|
1793
|
+
def __init__(self, fs):
|
1794
|
+
def match_func(frame, email):
|
1795
|
+
return frame.email == email
|
1796
|
+
super().__init__(frames.POPULARITY_FID, fs, match_func)
|
1797
|
+
|
1798
|
+
def set(self, email, rating, play_count):
|
1799
|
+
flist = self._fs[frames.POPULARITY_FID] or []
|
1800
|
+
for popm in flist:
|
1801
|
+
if popm.email == email:
|
1802
|
+
# update
|
1803
|
+
popm.rating = rating
|
1804
|
+
popm.count = play_count
|
1805
|
+
return popm
|
1806
|
+
|
1807
|
+
popm = frames.PopularityFrame(email=email, rating=rating,
|
1808
|
+
count=play_count)
|
1809
|
+
self._fs[frames.POPULARITY_FID] = popm
|
1810
|
+
return popm
|
1811
|
+
|
1812
|
+
def remove(self, email):
|
1813
|
+
return super().remove(email)
|
1814
|
+
|
1815
|
+
def get(self, email):
|
1816
|
+
return super().get(email)
|
1817
|
+
|
1818
|
+
|
1819
|
+
class ChaptersAccessor(AccessorBase):
|
1820
|
+
def __init__(self, fs):
|
1821
|
+
def match_func(frame, element_id):
|
1822
|
+
return frame.element_id == element_id
|
1823
|
+
super().__init__(frames.CHAPTER_FID, fs, match_func)
|
1824
|
+
|
1825
|
+
def set(self, element_id, times, offsets=(None, None), sub_frames=None):
|
1826
|
+
flist = self._fs[frames.CHAPTER_FID] or []
|
1827
|
+
for chap in flist:
|
1828
|
+
if chap.element_id == element_id:
|
1829
|
+
# update
|
1830
|
+
chap.times, chap.offsets = times, offsets
|
1831
|
+
if sub_frames:
|
1832
|
+
chap.sub_frames = sub_frames
|
1833
|
+
return chap
|
1834
|
+
|
1835
|
+
chap = frames.ChapterFrame(element_id=element_id,
|
1836
|
+
times=times, offsets=offsets,
|
1837
|
+
sub_frames=sub_frames)
|
1838
|
+
self._fs[frames.CHAPTER_FID] = chap
|
1839
|
+
return chap
|
1840
|
+
|
1841
|
+
def remove(self, element_id):
|
1842
|
+
return super().remove(element_id)
|
1843
|
+
|
1844
|
+
def get(self, element_id):
|
1845
|
+
return super().get(element_id)
|
1846
|
+
|
1847
|
+
def __getitem__(self, elem_id):
|
1848
|
+
"""Overiding the index based __getitem__ for one indexed with chapter
|
1849
|
+
element IDs. These are stored in the tag's table of contents frames."""
|
1850
|
+
for chapter in (self._fs[frames.CHAPTER_FID] or []):
|
1851
|
+
if chapter.element_id == elem_id:
|
1852
|
+
return chapter
|
1853
|
+
raise IndexError("chapter '%s' not found" % elem_id)
|
1854
|
+
|
1855
|
+
|
1856
|
+
class TocAccessor(AccessorBase):
|
1857
|
+
def __init__(self, fs):
|
1858
|
+
def match_func(frame, element_id):
|
1859
|
+
return frame.element_id == element_id
|
1860
|
+
super().__init__(frames.TOC_FID, fs, match_func)
|
1861
|
+
|
1862
|
+
def __iter__(self):
|
1863
|
+
tocs = list(self._fs[self._fid] or [])
|
1864
|
+
for toc_frame in tocs:
|
1865
|
+
# Find and put top level at the front of the list
|
1866
|
+
if toc_frame.toplevel:
|
1867
|
+
tocs.remove(toc_frame)
|
1868
|
+
tocs.insert(0, toc_frame)
|
1869
|
+
break
|
1870
|
+
|
1871
|
+
for toc in tocs:
|
1872
|
+
yield toc
|
1873
|
+
|
1874
|
+
@requireUnicode("description")
|
1875
|
+
def set(self, element_id, toplevel=False, ordered=True, child_ids=None,
|
1876
|
+
description=""):
|
1877
|
+
flist = self._fs[frames.TOC_FID] or []
|
1878
|
+
|
1879
|
+
# Enforce one top-level
|
1880
|
+
if toplevel:
|
1881
|
+
for toc in flist:
|
1882
|
+
if toc.toplevel:
|
1883
|
+
raise ValueError("There may only be one top-level "
|
1884
|
+
"table of contents. Toc '%s' is current "
|
1885
|
+
"top-level." % toc.element_id)
|
1886
|
+
for toc in flist:
|
1887
|
+
if toc.element_id == element_id:
|
1888
|
+
# update
|
1889
|
+
toc.toplevel = toplevel
|
1890
|
+
toc.ordered = ordered
|
1891
|
+
toc.child_ids = child_ids
|
1892
|
+
toc.description = description
|
1893
|
+
return toc
|
1894
|
+
|
1895
|
+
toc = frames.TocFrame(element_id=element_id, toplevel=toplevel,
|
1896
|
+
ordered=ordered, child_ids=child_ids,
|
1897
|
+
description=description)
|
1898
|
+
self._fs[frames.TOC_FID] = toc
|
1899
|
+
return toc
|
1900
|
+
|
1901
|
+
def remove(self, element_id):
|
1902
|
+
return super().remove(element_id)
|
1903
|
+
|
1904
|
+
def get(self, element_id):
|
1905
|
+
return super().get(element_id)
|
1906
|
+
|
1907
|
+
def __getitem__(self, elem_id):
|
1908
|
+
"""Overiding the index based __getitem__ for one indexed with table
|
1909
|
+
of contents element IDs."""
|
1910
|
+
for toc in (self._fs[frames.TOC_FID] or []):
|
1911
|
+
if toc.element_id == elem_id:
|
1912
|
+
return toc
|
1913
|
+
raise IndexError("toc '%s' not found" % elem_id)
|
1914
|
+
|
1915
|
+
|
1916
|
+
class TagTemplate(string.Template):
|
1917
|
+
idpattern = r'[_a-z][_a-z0-9:]*'
|
1918
|
+
|
1919
|
+
def __init__(self, pattern, path_friendly="-", dotted_dates=False):
|
1920
|
+
super().__init__(pattern)
|
1921
|
+
|
1922
|
+
if type(path_friendly) is bool and path_friendly:
|
1923
|
+
# Previous versions used boolean values, convert old default to new
|
1924
|
+
path_friendly = "-"
|
1925
|
+
self._path_friendly = path_friendly
|
1926
|
+
|
1927
|
+
self._dotted_dates = dotted_dates
|
1928
|
+
|
1929
|
+
def substitute(self, tag, zeropad=True):
|
1930
|
+
mapping = self._makeMapping(tag, zeropad)
|
1931
|
+
|
1932
|
+
# Helper function for .sub()
|
1933
|
+
def convert(mo):
|
1934
|
+
named = mo.group('named')
|
1935
|
+
if named is not None:
|
1936
|
+
try:
|
1937
|
+
if type(mapping[named]) is tuple:
|
1938
|
+
func, args = mapping[named][0], mapping[named][1:]
|
1939
|
+
return '%s' % func(tag, named, *args)
|
1940
|
+
# We use this idiom instead of str() because the latter
|
1941
|
+
# will fail if val is a Unicode containing non-ASCII
|
1942
|
+
return '%s' % (mapping[named],)
|
1943
|
+
except KeyError:
|
1944
|
+
return self.delimiter + named
|
1945
|
+
braced = mo.group('braced')
|
1946
|
+
if braced is not None:
|
1947
|
+
try:
|
1948
|
+
if type(mapping[braced]) is tuple:
|
1949
|
+
func, args = mapping[braced][0], mapping[braced][1:]
|
1950
|
+
return '%s' % func(tag, braced, *args)
|
1951
|
+
return '%s' % (mapping[braced],)
|
1952
|
+
except KeyError:
|
1953
|
+
return self.delimiter + '{' + braced + '}'
|
1954
|
+
if mo.group('escaped') is not None:
|
1955
|
+
return self.delimiter
|
1956
|
+
if mo.group('invalid') is not None:
|
1957
|
+
return self.delimiter
|
1958
|
+
raise ValueError('Unrecognized named group in pattern',
|
1959
|
+
self.pattern)
|
1960
|
+
|
1961
|
+
name = self.pattern.sub(convert, self.template)
|
1962
|
+
if self._path_friendly:
|
1963
|
+
name = name.replace("/", self._path_friendly)
|
1964
|
+
return name
|
1965
|
+
|
1966
|
+
safe_substitute = substitute
|
1967
|
+
|
1968
|
+
def _dates(self, tag, param):
|
1969
|
+
if param.startswith("release_"):
|
1970
|
+
date = tag.release_date
|
1971
|
+
elif param.startswith("recording_"):
|
1972
|
+
date = tag.recording_date
|
1973
|
+
elif param.startswith("original_release_"):
|
1974
|
+
date = tag.original_release_date
|
1975
|
+
else:
|
1976
|
+
date = tag.getBestDate(
|
1977
|
+
prefer_recording_date=":prefer_recording" in param)
|
1978
|
+
|
1979
|
+
if date and param.endswith(":year"):
|
1980
|
+
dstr = str(date.year)
|
1981
|
+
elif date:
|
1982
|
+
dstr = str(date)
|
1983
|
+
else:
|
1984
|
+
dstr = ""
|
1985
|
+
|
1986
|
+
if self._dotted_dates:
|
1987
|
+
dstr = dstr.replace('-', '.')
|
1988
|
+
|
1989
|
+
return dstr
|
1990
|
+
|
1991
|
+
@staticmethod
|
1992
|
+
def _nums(num_tuple, param, zeropad) -> int:
|
1993
|
+
|
1994
|
+
nn, nt = ((str(n) if n else None) for n in num_tuple)
|
1995
|
+
if zeropad:
|
1996
|
+
if nt:
|
1997
|
+
nt = nt.rjust(2, "0")
|
1998
|
+
nn = nn.rjust(len(nt) if nt else 2, "0")
|
1999
|
+
|
2000
|
+
if param.endswith(":num"):
|
2001
|
+
return nn
|
2002
|
+
elif param.endswith(":total"):
|
2003
|
+
return nt
|
2004
|
+
else:
|
2005
|
+
raise ValueError("Unknown template param: %s" % param)
|
2006
|
+
|
2007
|
+
def _track(self, tag, param, zeropad):
|
2008
|
+
return self._nums(tag.track_num, param, zeropad)
|
2009
|
+
|
2010
|
+
def _disc(self, tag, param, zeropad):
|
2011
|
+
return self._nums(tag.disc_num, param, zeropad)
|
2012
|
+
|
2013
|
+
@staticmethod
|
2014
|
+
def _file(tag, param):
|
2015
|
+
assert param.startswith("file")
|
2016
|
+
|
2017
|
+
if param.endswith(":ext"):
|
2018
|
+
return os.path.splitext(tag.file_info.name)[1][1:]
|
2019
|
+
else:
|
2020
|
+
return tag.file_info.name
|
2021
|
+
|
2022
|
+
def _makeMapping(self, tag, zeropad):
|
2023
|
+
return {"artist": tag.artist if tag else None,
|
2024
|
+
"album_artist": tag.album_artist if tag else None,
|
2025
|
+
"album": tag.album if tag else None,
|
2026
|
+
"title": tag.title if tag else None,
|
2027
|
+
"track:num": (self._track, zeropad) if tag else None,
|
2028
|
+
"track:total": (self._track, zeropad) if tag else None,
|
2029
|
+
"release_date": (self._dates,) if tag else None,
|
2030
|
+
"release_date:year": (self._dates,) if tag else None,
|
2031
|
+
"recording_date": (self._dates,) if tag else None,
|
2032
|
+
"recording_date:year": (self._dates,) if tag else None,
|
2033
|
+
"original_release_date": (self._dates,) if tag else None,
|
2034
|
+
"original_release_date:year": (self._dates,) if tag else None,
|
2035
|
+
"best_date": (self._dates,) if tag else None,
|
2036
|
+
"best_date:year": (self._dates,) if tag else None,
|
2037
|
+
"best_date:prefer_recording": (self._dates,) if tag else None,
|
2038
|
+
"best_date:prefer_release": (self._dates,) if tag else None,
|
2039
|
+
"best_date:prefer_recording:year": (self._dates,) if tag
|
2040
|
+
else None,
|
2041
|
+
"best_date:prefer_release:year": (self._dates,) if tag
|
2042
|
+
else None,
|
2043
|
+
"file": (self._file,) if tag else None,
|
2044
|
+
"file:ext": (self._file,) if tag else None,
|
2045
|
+
"disc:num": (self._disc, zeropad) if tag else None,
|
2046
|
+
"disc:total": (self._disc, zeropad) if tag else None,
|
2047
|
+
}
|