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