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