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/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
+ }