1. Markus Kuppe
  2. eyeD3

Commits

Travis Shirk  committed 2acc6e9 Merge

merge

  • Participants
  • Parent commits a0c6eb9, ea0a2c1
  • Branches default

Comments (0)

Files changed (7)

File docs/compliance.rst

View file
  • Ignore whitespace
+##########
+Compliance
+##########
+
+
+ID3
+===
+
+Unsupported Features
+--------------------
+* ID3 frame encryption
+* Writing of sync-safe data (i.e. unsynchronized) because it is 2012.
+  Reading of unsyncronized tags (v2.3) and frames (v2.4) **is** supported.
+
+Dates
+-----
+One of the major differences between 2.3 and 2.4 is dates.
+
+ID3 v2.3 Date Frames
+~~~~~~~~~~~~~~~~~~~~
+- TDAT date (recording date of form DDMM, always 4 bytes)
+- TYER year (recording year of form YYYY, always 4 bytes)
+- TIME time (recording time of form HHMM, always 4 bytes)
+- TORY orig release year
+- TRDA recording date (more freeform replacement for TDAT, TYER, TIME.
+  e.g., "4th-7th June, 12th June" in combination with TYER)
+- TDLY playlist delay (also defined in ID3 v2.4)
+
+ID3 v2.4 Date Frames
+~~~~~~~~~~~~~~~~~~~~
+All v2.4 dates follow ISO 8601 formats.
+
+- TDEN encoding datetime
+- TDOR orig release date
+- TDRC recording date
+- TDRL release date
+- TDTG tagging time
+- TDLY playlist delay (also defined in ID3 v2.3)
+
+From the ID3 specs::
+
+    yyyy-MM-ddTHH:mm:ss (year, "-", month, "-", day, "T", hour (out of
+    24), ":", minutes, ":", seconds), but the precision may be reduced by
+    removing as many time indicators as wanted. Hence valid timestamps
+    are yyyy, yyyy-MM, yyyy-MM-dd, yyyy-MM-ddTHH, yyyy-MM-ddTHH:mm
+    and yyyy-MM-ddTHH:mm:ss. All time stamps are UTC. For
+    durations, use the slash character as described in 8601, and for
+    multiple non- contiguous dates, use multiple strings, if allowed
+    by the frame definition.
+
+The ISO 8601 'W' delimiter for numeric weeks is NOT supported.
+
+Times that contain a 'Z' at the end to signal the time is UTC is supported.
+
+Common Date Frame Extensions
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+MusicBrainz uses *XDOR* in v2.3 tags as the **full** original release date,
+whereas *TORY* (v2.3) only represents the release year. Version 2.4 does not 
+use/need this extension since *TDOR* is available.
+
+v2.4 <-> 2.3 mappings
+~~~~~~~~~~~~~~~~~~~~~
+When converting to/from v2.3 and v2.4 it is neceswsary to convert date frames.
+The following is the mappings eyeD3 uses when converting.::
+
+Version 2.3 --> version 2.4
+
+* TYER, TDAT, TIME --> TDRC
+* TORY             --> TDOR
+* TRDA             --> none
+* XDOR             --> TDOR
+
+If both *TORY* and *XDOR* exist, XDOR is preferred.
+
+Version 2.4 --> version 2.3
+
+* TDRC --> TYER, TDAT, TIME
+* TDOR --> TORY
+* TDRL --> TORY
+* TDEN --> none
+* TDTG --> none
+
+Non Standard Frame Support
+--------------------------
+
+NCON
+~~~~
+A MusicMatch extension of unknown binary format. Frames of this type are
+parsed as raw ``Frame`` objects, therefore the data is not parsed. The frames
+are preserved and can be deleted and written (as is).
+
+TCMP
+~~~~
+An iTunes extension to signify that a track is part of a compilation.
+This frame is handled by ``TextFrame`` and the data is either a '1' if
+part of a compilation or '0' (or empty) if not.
+
+XSOA, XSOP, XSOT
+~~~~~~~~~~~~~~~~
+These are alternative sort-order strings for album, performer, and title,
+respectively. They are often added to ID3v2.3 tags while v2.4 does not
+require them since TSOA, TSOP, and TSOT are native frames.
+
+These frames are preserved but are not written when using v2.3. If the
+tag is converted to v2.4 then the corresponding native frame is used.
+
+XDOR
+~~~~
+A MusicBrainz extension for the **full** original release date, since TORY
+only contains the year of original release.  In ID3 v2.4 this frame became
+TDOR.
+
+PCST, WFED, TKWD, TDES, TGID
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Apple extensions for podcasts.

File docs/index.rst

View file
  • Ignore whitespace
 
     cli
     api/modules
+    compliance
 
 .. toctree::
     :hidden:

File src/eyed3/id3/frames.py

View file
  • Ignore whitespace
 
     def parse(self, f, tag_header, extended_header):
         '''Read frames starting from the current read position of the file
-        object ``f``. Returns the amount of padding which occurs after the tag,
-        but before the audio content.
-        '''
+        object. Returns the amount of padding which occurs after the tag, but
+        before the audio content.  A return valule of 0 does not mean error.'''
         from .headers import FrameHeader
 
         self.clear()
         assert(fid[0] == "T" and fid in list(ID3_FRAMES.keys()))
 
         if fid in self:
-            curr = self[fid][0]
-            if isinstance(curr, DateFrame):
-                curr.date = text
-            else:
-                curr.text = text
+            curr = self[fid][0].text = text
         else:
             if fid in DATE_FIDS:
-                self[fid] = DateFrame(date_str=text)
+                self[fid] = DateFrame(fid, date=text)
             else:
                 self[fid] = TextFrame(fid, text=text)
 

File src/eyed3/id3/tag.py

View file
  • Ignore whitespace
 
     @property
     def best_release_date(self):
-        return (self.release_date or
-                self.recording_date or
-                self.original_release_date)
+        '''This method tries its best to return a date of some sort, amongst
+        alll the possible date frames. The order of preference for a release
+        date is 1) date of original release 2) date of this versions release
+        3) the recording date. Or None is returned.'''
+        return (self.original_release_date or
+                self.release_date or
+                self.recording_date)
 
     def _getReleaseDate(self):
-        return self._getDate("TDRL")
+        return self._getDate("TDRL") if self.version == ID3_V2_4\
+                                     else self._getV23OrignalReleaseDate()
     def _setReleaseDate(self, date):
-        self._setDate("TDRL", date)
+        self._setDate("TDRL" if self.version == ID3_V2_4 else "TORY", date)
+
     release_date = property(_getReleaseDate, _setReleaseDate)
+    '''The date the audio was released. This is NOT the original date the
+    work was released, instead it is more like the pressing or version of the
+    release. Original release date is usually what is intended but many programs
+    use this frame and/or don't distinguish between the two.'''
 
     def _getOrigReleaseDate(self):
-        return self._getDate("TDOR") or self._v23OrignalReleaseDate()
+        return self._getDate("TDOR") or self._getV23OrignalReleaseDate()
     def _setOrigReleaseDate(self, date):
         self._setDate("TDOR", date)
+
     original_release_date = property(_getOrigReleaseDate, _setOrigReleaseDate)
+    '''The date the work was originally released.'''
 
     def _getRecordingDate(self):
-        return self._getDate("TDRC") or self._v23RecordingDate()
+        return self._getDate("TDRC") or self._getV23RecordingDate()
     def _setRecordingDate(self, date):
-        self._setDate("TDRC", date)
+        if self.version == ID3_V2_4:
+            self._setDate("TDRC", date)
+        elif date:
+            self._setDate("TYER", unicode(date.year))
+            if None not in (date.month, date.day):
+                date_str = u"%s%s" % (str(date.day).rjust(2, "0"),
+                                      str(date.month).rjust(2, "0"))
+                self._setDate("TDAT", date_str)
+            if None not in (date.hour, date.minute):
+                date_str = u"%s%s" % (str(date.hour).rjust(2, "0"),
+                                      str(date.minute).rjust(2, "0"))
+                self._setDate("TIME", date_str)
+        else:
+            self._setDate("TYER", None)
+            self._setDate("TDAT", None)
+            self._setDate("TIME", None)
+
     recording_date = property(_getRecordingDate, _setRecordingDate)
+    '''The date of the recording. Many applications use this for release date
+    regardless of the fact that this value is rarely known, and release dates
+    are more correct.'''
 
-    def _v23RecordingDate(self):
+    def _getV23RecordingDate(self):
         # v2.3 TYER (yyyy), TDAT (DDMM), TIME (HHmm)
         date = None
         try:
             date_str = ""
-            if self.frame_set["TYER"]:
+            if "TYER" in self.frame_set:
                 date_str = self.frame_set["TYER"][0].text.encode("latin1")
                 date = core.Date.parse(date_str)
-            if self.frame_set["TDAT"]:
+            if "TDAT" in self.frame_set:
                 text = self.frame_set["TDAT"][0].text.encode("latin1")
                 date_str += "-%s-%s" % (text[2:], text[:2])
                 date = core.Date.parse(date_str)
-            if self.frame_set["TIME"]:
+            if "TIME" in self.frame_set:
                 text = self.frame_set["TIME"][0].text.encode("latin1")
                 date_str += "T%s:%s" % (text[:2], text[2:])
                 date = core.Date.parse(date_str)
 
         return date
 
-    def _v23OrignalReleaseDate(self):
-        # v2.3 TORY (yyyy)
-        date = None
+    def _getV23OrignalReleaseDate(self):
+        date, date_str = None, None
         try:
-            if self.frame_set["TORY"]:
-                # YYYY
-                date_str = self.frame_set["TORY"][0].text.encode("latin1")
+            for fid in ("XDOR", "TORY"):
+                # Prefering XDOR over TORY since it can contain full date.
+                if fid in self.frame_set:
+                    date_str = self.frame_set[fid][0].text.encode("latin1")
+                    break
+            if date_str:
                 date = core.Date.parse(date_str)
         except ValueError as ex:
-            log.warning("Invalid v2.3 TORY frame: %s" % ex)
+            log.warning("Invalid v2.3 TORY/XDOR frame: %s" % ex)
 
         return date
 
     tagging_date = property(_getTaggingDate, _setTaggingDate)
 
     def _setDate(self, fid, date):
-        assert(fid in frames.DATE_FIDS)
+        assert(fid in frames.DATE_FIDS or
+                fid in frames.DEPRECATED_DATE_FIDS)
 
         if date is None:
             try:
                 del self.frame_set[fid]
             except KeyError:
                 pass
-            finally:
-                return
+            return
 
-        # Convert to ISO format which is what FrameSet wants.
-        date_type = type(date)
-        if date_type is int:
-            # The integer year
-            date = core.Date(date)
-        elif date_type in types.StringTypes:
-            date = core.Date.parse(date)
-        elif not isinstance(date, core.Date):
-            raise TypeError("Invalid type: %s" % str(type(date)))
+        # Special casing the conversion to DATE objects cuz TDAT and TIME won't
+        if fid not in ("TDAT", "TIME"):
+            # Convert to ISO format which is what FrameSet wants.
+            date_type = type(date)
+            if date_type is int:
+                # The integer year
+                date = core.Date(date)
+            elif date_type in types.StringTypes:
+                date = core.Date.parse(date)
+            elif not isinstance(date, core.Date):
+                raise TypeError("Invalid type: %s" % str(type(date)))
 
         date_text = unicode(str(date))
         if fid in self.frame_set:
             self.frame_set[fid][0].date = date
         else:
-            self.frame_set[fid] = frames.DateFrame(fid, date=date_text)
+            self.frame_set[fid] = frames.DateFrame(fid, date_text)
 
     def _getDate(self, fid):
+        if fid in ("TORY", "XDOR"):
+            return self._getV23OrignalReleaseDate()
+
         if fid in self.frame_set:
-            return self.frame_set[fid][0].date
+            if fid in ("TYER", "TDAT", "TIME"):
+                if fid == "TYER":
+                    # Contain years only, date conversion can happen
+                    return core.Date(int(self.frame_set[fid][0].text))
+                else:
+                    return self.frame_set[fid][0].text
+            else:
+                return self.frame_set[fid][0].date
         else:
             return None
 
 
     def _render(self, version, curr_tag_size):
         std_frames = []
-        converted_frames = []
+        non_std_frames = []
         for f in self.frame_set.getAllFrames():
             try:
                 _, fversion, _ = frames.ID3_FRAMES[f.id]
                 if fversion in (version, ID3_V2):
                     std_frames.append(f)
                 else:
-                    converted_frames.append(f)
+                    non_std_frames.append(f)
             except KeyError:
                 # Not a standard frame (ID3_FRAMES)
                 try:
                     _, fversion, _ = frames.NONSTANDARD_ID3_FRAMES[f.id]
-                    # but it is one we can handle.
+                    # but is it one we can handle.
                     if fversion in (version, ID3_V2):
                         std_frames.append(f)
                     else:
-                        converted_frames.append(f)
+                        non_std_frames.append(f)
                 except KeyError:
                     # Don't know anything about this pass it on for the error
                     # check there.
-                    converted_frames.append(f)
+                    non_std_frames.append(f)
 
-        if converted_frames:
+        if non_std_frames:
             # actually, they're not converted yet
-            converted_frames = self._convertFrames(converted_frames, version)
+            non_std_frames = self._convertFrames(std_frames, non_std_frames,
+                                                 version)
 
         # Render all frames first so the data size is known for the tag header.
         frame_data = b""
-        for f in std_frames + converted_frames:
+        for f in std_frames + non_std_frames:
             frame_header = frames.FrameHeader(f.id, version)
             if f.header:
                 frame_header.copyFlags(f.header)
         log.debug("Tag write complete. Updating FileInfo state.")
         self.file_info.tag_size = len(tag_data) + len(padding)
 
-    def _convertFrames(self, flist, version):
-        '''Maps frame imcompatibilies between ID3 v2.3 and v2.4'''
+    def _convertFrames(self, std_frames, convert_list, version):
+        '''Maps frame imcompatibilies between ID3 v2.3 and v2.4.
+        The items in ``std_frames`` need no conversion, but the list/frames
+        may be edited if necessary (e.g. a converted frame replaces a frame
+        in the list).  The items in ``convert_list`` are the frames to convert
+        and return. The ``version`` is the target ID3 version.'''
         from . import versionToString
         from .frames import (DATE_FIDS, DEPRECATED_DATE_FIDS,
                              DateFrame, TextFrame)
         converted_frames = []
-        flist = list(flist)
+        flist = list(convert_list)
 
         # Date frame conversions.
         date_frames = {f.id: f for f in flist if f.id in DEPRECATED_DATE_FIDS}\
                       {f.id: f for f in flist if f.id in DATE_FIDS}
         if date_frames:
             if version == ID3_V2_4:
-                if "TORY" in date_frames:
+                if "TORY" in date_frames or "XDOR" in date_frames:
+                    # XDOR -> TDOR (full date)
                     # TORY -> TDOR (year only)
-                    date = self._v23OrignalReleaseDate()
+                    date = self._getV23OrignalReleaseDate()
                     if date:
                         converted_frames.append(DateFrame("TDOR", date))
-                    flist.remove(date_frames["TORY"])
-                    del date_frames["TORY"]
+                    for fid in ("TORY", "XDOR"):
+                        if fid in flist:
+                            flist.remove(date_frames[fid])
+                            del date_frames[fid]
 
-                if "TYER" in date_frames:
-                    # TYER, TDAT, TIME -> TDRC
-                    date = self._v23RecordingDate()
+                # TYER, TDAT, TIME -> TDRC
+                if ("TYER" in date_frames or "TDAT" in date_frames or
+                        "TIME" in date_frames):
+                    date = self._getV23RecordingDate()
                     if date:
                         converted_frames.append(DateFrame("TDRC", date))
                     for fid in ["TYER", "TDAT", "TIME"]:
                             flist.remove(date_frames[fid])
                             del date_frames[fid]
 
-                if "XDOR" in date_frames:
-                    # XDOR -> TDRC
-                    xdor = date_frames["XDOR"]
-                    converted_frames.append(DateFrame("TDRC", xdor.text))
-
-                    flist.remove(xdor)
-                    del date_frames["XDOR"]
-
             elif version == ID3_V2_3:
                 if "TDOR" in date_frames:
                     date = date_frames["TDOR"].date
                     flist.remove(date_frames["TDOR"])
                     del date_frames["TDOR"]
 
-                if "TDRL" in date_frames:
-                    date = date_frames["TDRL"].date
+                if "TDRC" in date_frames:
+                    date = date_frames["TDRC"].date
 
                     if date:
                         converted_frames.append(DateFrame("TYER",
                                      str(date.minute).rjust(2, "0"))
                             converted_frames.append(TextFrame("TIME", date_str))
 
+                    flist.remove(date_frames["TDRC"])
+                    del date_frames["TDRC"]
+
+                if "TDRL" in date_frames:
+                    # TDRL -> XDOR
+                    date = date_frames["TDRL"].date
+                    if date:
+                        converted_frames.append(DateFrame("XDOR", str(date)))
                     flist.remove(date_frames["TDRL"])
                     del date_frames["TDRL"]
 
-                if "TDRC" in date_frames:
-                    # TDRC -> XDOR
-                    date = date_frames["TDRC"].date
-                    if date:
-                        converted_frames.append(DateFrame("XDOR", str(date)))
-                    flist.remove(date_frames["TDRC"])
-                    del date_frames["TDRC"]
-
             # All other date frames have no conversion
             for fid in date_frames:
                 log.warning("%s frame being dropped due to conversion to %s" %
                             (fid, versionToString(version)))
-
                 flist.remove(date_frames[fid])
 
         # Convert sort order frames 2.3 (XSO*) <-> 2.4 (TSO*)
             flist.remove(frame)
             converted_frames.append(frame)
 
+        # Raise an error for frames that could not be converted.
         if len(flist) != 0:
             unconverted = ", ".join([f.id for f in flist])
             raise TagException("Unable to covert the following frames to "
                                "version %s: %s" % (versionToString(version),
                                                    unconverted))
+
+        # Some frames in converted_frames may replace/edit frames in std_frames.
+        for cframe in converted_frames:
+            for sframe in std_frames:
+                if cframe.id == sframe.id:
+                    std_frames.remove(sframe)
+
         return converted_frames
 
     @staticmethod

File src/eyed3/plugins/classic.py

View file
  • Ignore whitespace
                           default=False, help=ARGS_HELP["--remove-v2"])
         gid3.add_argument("--remove-all", action="store_true", default=False,
                           dest="remove_all", help=ARGS_HELP["--remove-all"])
+        gid3.add_argument("--remove-frame", action="append", default=[],
+                          dest="remove_fids", metavar="FID",
+                          help=ARGS_HELP["--remove-frame"])
 
         _encodings = ["latin1", "utf8", "utf16", "utf16-be"]
         gid3.add_argument("--encoding", dest="text_encoding", default=None,
                                                        self.audio_file.path))
             printMsg("-" * 79)
         except exceptions.Exception as ex:
-            printError("Error: %s" % ex)
             log.error(traceback.format_exc())
             if self.args.debug_pdb:
                 import pdb; pdb.set_trace()
             printMsg("%s: %s" % (boldText("title"), title))
             printMsg("%s: %s" % (boldText("artist"), artist))
             printMsg("%s: %s" % (boldText("album"), album))
+
             for date, date_label in [
                     (tag.release_date, "release date"),
                     (tag.original_release_date, "original release date"),
             if tag.terms_of_use:
                 printMsg("\nTerms of Use (%s): %s" % (boldText("USER"),
                                                       tag.terms_of_use))
+
             if self.args.verbose:
                 printMsg("-" * 79)
-                printMsg("%d ID3 Frames:" % len(self.audio_file.tag.frame_set))
-                for frm in self.audio_file.tag.frame_set:
-                    printMsg(frm)
-
+                printMsg("%d ID3 Frames:" % len(tag.frame_set))
+                for fid in tag.frame_set:
+                    num_frames = len(tag.frame_set[fid])
+                    count = " x %d" % num_frames if num_frames > 1 else ""
+                    printMsg("%s%s" % (fid, count))
         else:
             raise TypeError("Unknown tag type: " + str(type(tag)))
 
                               (owner_id, id))
                 retval = True
 
+        # --remove-frame
+        for fid in self.args.remove_fids:
+            if fid in tag.frame_set:
+                del tag.frame_set[fid]
+                retval = True
+
         return retval
 
 
         "--remove-v2": "Remove ID3 v2.x tag.",
         "--remove-all": "Remove ID3 v1.x and v2.x tags.",
 
+        "--remove-frame": "Remove all frames with the given ID. This option "
+                          "may be specified multiple times.",
+
         "--backup": "Make a backup of any file modified. The backup is made in "
                     "same directory with a '.orig' extension added.",
         "--force-update": "Rewrite the tag despite there being no edit "

File src/test/id3/test_tag.py

View file
  • Ignore whitespace
     finally:
         os.remove(test_file)
 
-def test_XDOR_TDRC_Conversions():
+def test_XDOR_TDOR_Conversions():
     test_file = "/tmp/xdortdrc.id3"
 
     tag = Tag()
         tag = eyed3.load(test_file).tag
         assert_equal(tag.version, ID3_V2_4)
         assert_equal(len(tag.frame_set), 1)
-        del tag.frame_set["TDRC"]
+        del tag.frame_set["TDOR"]
         assert_equal(len(tag.frame_set), 0)
     finally:
         os.remove(test_file)
         tag.save(test_file, version=eyed3.id3.ID3_V2_3)
         tag = eyed3.load(test_file).tag
         assert_equal(tag.version, ID3_V2_3)
-        assert_equal(len(tag.frame_set), 1)
-        del tag.frame_set["XDOR"]
+        assert_equal(len(tag.frame_set), 2)
+        del tag.frame_set["TYER"]
+        del tag.frame_set["TDAT"]
         assert_equal(len(tag.frame_set), 0)
     finally:
         os.remove(test_file)

File src/test/test_classic_plugin.py

View file
  • Ignore whitespace
             assert_is_not_none(af)
             assert_is_not_none(af.tag)
             if version == id3.ID3_V2_3:
-                assert_equal(af.tag.recording_date.year, 1981)
+                assert_equal(af.tag.original_release_date.year, 1981)
             else:
                 assert_equal(af.tag.release_date.year, 1981)
 
         assert_equal(af.tag.track_num, (14, 14 if version[0] != 1 else None))
         assert_equal((af.tag.genre.name, af.tag.genre.id), ("Rock", 17))
         if version == id3.ID3_V2_3:
-            assert_equal(af.tag.recording_date.year, 1981)
+            assert_equal(af.tag.original_release_date.year, 1981)
         else:
             assert_equal(af.tag.release_date.year, 1981)