Commits

Travis Shirk committed 958a296 Merge

merge

Comments (0)

Files changed (13)

 processed the method ``handleDone`` is called and the program exits. Below
 is an 'echo' plugin that prints each filename/path and the file's mime-type.
 
-.. literalinclude:: ../examples/echo.py
+.. literalinclude:: ../examples/plugins/echo.py
 
 Many plugins might prefer to deal with only file types ``eyeD3`` natively
 supports, namely mp3 audio files. To automatically load
 In the next example the ``LoaderPlugin`` is used to set the ``audio_file``
 member variable which contains the info and tag objects.
 
-.. literalinclude:: ../examples/echo2.py
+.. literalinclude:: ../examples/plugins/echo2.py
 
 
 .. seealso:: :ref:`config-files`,

examples/echo.py

-from __future__ import print_function
-import eyed3
-from eyed3.plugins import Plugin
-from eyed3.utils import guessMimetype
-
-eyed3.require((0, 7))
-
-class EchoPlugin(eyed3.plugins.Plugin):
-    NAMES = ["echo"]
-    SUMMARY = u"Displays each filename and mime-type passed to the plugin"
-
-    def handleFile(self, f):
-        print("%s\t[ %s ]" % (f, guessMimetype(f)))
-

examples/echo2.py

-# -*- coding: utf-8 -*-
-from __future__ import print_function
-import eyed3
-from eyed3.plugins import LoaderPlugin
-
-eyed3.require((0, 7))
-
-class Echo2Plugin(LoaderPlugin):
-    SUMMARY = u"Displays details about audio files"
-    NAMES = ["echo2"]
-
-    def handleFile(self, f):
-        super(Echo2Plugin, self).handleFile(f)
-
-        if not self.audio_file:
-            print("%s: Unsupported type" % f)
-        else:
-            print("Audio info: %s Metadata tag: %s " %
-                  ("yes" if self.audio_file.info else "no",
-                   "yes" if self.audio_file.tag else "no"))

examples/plugins/echo.py

+from __future__ import print_function
+import eyed3
+from eyed3.plugins import Plugin
+from eyed3.utils import guessMimetype
+
+eyed3.require((0, 7))
+
+class EchoPlugin(eyed3.plugins.Plugin):
+    NAMES = ["echo"]
+    SUMMARY = u"Displays each filename and mime-type passed to the plugin"
+
+    def handleFile(self, f):
+        print("%s\t[ %s ]" % (f, guessMimetype(f)))
+

examples/plugins/echo2.py

+# -*- coding: utf-8 -*-
+from __future__ import print_function
+import eyed3
+from eyed3.plugins import LoaderPlugin
+
+eyed3.require((0, 7))
+
+class Echo2Plugin(LoaderPlugin):
+    SUMMARY = u"Displays details about audio files"
+    NAMES = ["echo2"]
+
+    def handleFile(self, f):
+        super(Echo2Plugin, self).handleFile(f)
+
+        if not self.audio_file:
+            print("%s: Unsupported type" % f)
+        else:
+            print("Audio info: %s Metadata tag: %s " %
+                  ("yes" if self.audio_file.info else "no",
+                   "yes" if self.audio_file.tag else "no"))

src/eyed3/core.py

 
     @property
     def track_num(self):
+        '''Track number property.
+        Must return a 2-tuple of (track-number, total-number-of-tracks).
+        Either tuple value may be ``None``.
+        '''
         return self._getTrackNum()
     @track_num.setter
     def track_num(self, v):
                           "%Y-%m-%dT%H",
                           "%Y-%m-%dT%H:%M",
                           "%Y-%m-%dT%H:%M:%S",
+                          # The following end with 'Z' signally time is UTC
+                          "%Y-%m-%dT%HZ",
+                          "%Y-%m-%dT%H:%MZ",
+                          "%Y-%m-%dT%H:%M:%SZ",
                           # The following are wrong per the specs, but ...
                           "%Y-%m-%d %H:%M:%S",
                           "%Y-00-00",

src/eyed3/id3/frames.py

 CDID_FID           = "MCDI"
 PRIVATE_FID        = "PRIV"
 TOS_FID            = "USER"
+POPULARITY_FID     = "POPM"
 
 URL_COMMERCIAL_FID = "WCOM"
 URL_COPYRIGHT_FID  = "WCOP"
         self.data = dec2bytes(self.count, 32)
         return super(PlayCountFrame, self).render()
 
+
+class PopularityFrame(Frame):
+    '''Frame type for 'POPM' frames; popularity.
+    Frame format:
+    <Header for 'Popularimeter', ID: "POPM">
+    Email to user   <text string> $00
+    Rating          $xx
+    Counter         $xx xx xx xx (xx ...)
+    '''
+    def __init__(self, id=POPULARITY_FID, email=b"", rating=0, count=0):
+        super(PopularityFrame, self).__init__(id)
+        assert(self.id == POPULARITY_FID)
+
+        self.email = email or b""
+        self.rating = rating
+        if count is None or count < 0:
+            raise ValueError("Invalid count value: %s" % str(count))
+        self.count = count
+
+    @property
+    def rating(self):
+        return self._rating
+    @rating.setter
+    def rating(self, rating):
+        if rating < 0 or rating > 255:
+            raise ValueError("Popularity rating must be >= 0 and <=255")
+        self._rating = rating
+
+    @property
+    def email(self):
+        return self._email
+    @email.setter
+    def email(self, email):
+        self._email = email.encode("ascii")
+
+    @property
+    def count(self):
+        return self._count
+    @count.setter
+    def count(self, count):
+        if count < 0:
+            raise ValueError("Popularity count must be > 0")
+        self._count = count
+
+    def parse(self, data, frame_header):
+        super(PopularityFrame, self).parse(data, frame_header)
+        data = self.data
+
+        null_byte = data.find('\x00')
+        try:
+            self.email = data[:null_byte]
+        except UnicodeDecodeError:
+            core.parseError(FrameException("Invalid (non-ascii) POPM email "
+                                           "address. Setting to 'BOGUS'"))
+            self.email = b"BOGUS"
+        data = data[null_byte + 1:]
+
+        self.rating = bytes2dec(data[0])
+
+        data = data[1:]
+        if len(self.data) < 4:
+            core.parseError(FrameException(
+                "Invalid POPM play count: less than 32 bits."))
+        self.count = bytes2dec(data)
+
+    def render(self):
+        data = (self.email or b"") + '\x00'
+        data += dec2bytes(self.rating)
+        data += dec2bytes(self.count, 32)
+
+        self.data = data
+        return super(PopularityFrame, self).render()
+
 class UniqueFileIDFrame(Frame):
     def __init__(self, id=UNIQUE_FILE_ID_FID, owner_id=None, uniq_id=None):
         super(UniqueFileIDFrame, self).__init__(id)
 
                "PRIV": ("Private frame", ID3_V2, PrivateFrame),
                "PCNT": ("Play counter", ID3_V2, PlayCountFrame),
-               "POPM": ("Popularimeter", ID3_V2, None),
+               "POPM": ("Popularimeter", ID3_V2, PopularityFrame),
                "POSS": ("Position synchronisation frame", ID3_V2, None),
 
                "RBUF": ("Recommended buffer size", ID3_V2, None),

src/eyed3/id3/tag.py

         self._unique_file_ids = UniqueFileIdAccessor(self.frame_set)
         self._user_urls = UserUrlsAccessor(self.frame_set)
         self._chapters = ChaptersAccessor(self.frame_set)
+        self._popularities = PopularitiesAccessor(self.frame_set)
         self.file_info = None
 
     def parse(self, fileobj, version=ID3_ANY_VERSION):
     def privates(self):
         return self._privates
 
+    @property
+    def popularities(self):
+        return self._popularities
+
     def _getGenre(self):
         f = self.frame_set[frames.GENRE_FID]
         if f and f[0].text:
     def get(self, description):
         return super(UserUrlsAccessor, self).get(description)
 
+class PopularitiesAccessor(AccessorBase):
+    def __init__(self, fs):
+        def match_func(frame, email):
+            return frame.email == email
+        super(PopularitiesAccessor, self).__init__(frames.POPULARITY_FID, fs,
+                                                   match_func)
+
+    def set(self, email, rating, play_count):
+        flist = self._fs[frames.POPULARITY_FID] or []
+        for popm in flist:
+            if popm.email == email:
+                # update
+                popm.rating = rating
+                popm.count = play_count
+                return popm
+
+        popm = frames.PopularityFrame(email=email, rating=rating,
+                                      count=play_count)
+        self._fs[frames.POPULARITY_FID] = popm
+        return popm
+
+    def remove(self, email):
+        return super(PopularitiesAccessor, self).remove(email)
+
+    def get(self, email):
+        return super(PopularitiesAccessor, self).get(email)
+
 
 class ChaptersAccessor(AccessorBase):
     def __init__(self, fs):

src/eyed3/main.py

 from __future__ import print_function
 import sys, exceptions, os.path
 import ConfigParser
-import traceback, pdb
+import traceback
 import textwrap
 import eyed3, eyed3.utils, eyed3.utils.cli, eyed3.plugins, eyed3.info
 
         sys.stderr.write("%s\n" % msg)
 
         if args.debug_pdb:
+            import pdb
             pdb.post_mortem()
     finally:
         sys.exit(retval)

src/eyed3/plugins/__init__.py

 #  Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 #
 ################################################################################
+from __future__ import print_function
 import os, sys, logging, exceptions, types
 from collections import OrderedDict
 from eyed3 import core, utils
                     and f[0] not in ('_', '.')
                     and f.endswith(".py"))
 
+    log.debug("Extra plugin paths: %s" % paths)
     for d in [os.path.dirname(__file__)] + (paths if paths else []):
         log.debug("Searching '%s' for plugins", d)
         if not os.path.isdir(d):
                             if name and name in PluginClass.NAMES:
                                 return PluginClass
 
+        except ImportError as ex:
+            log.warning("Plugin '%s' requires packages that are not "
+                        "installed: %s" % ((f, d), ex))
+            continue
         except exceptions.Exception as ex:
             log.exception("Bad plugin '%s'", (f, d))
             continue
 class LoaderPlugin(Plugin):
     '''A base class that provides auto loading of audio files'''
 
-    _num_loaded = 0
+    def __init__(self, arg_parser, cache_files=False):
+        '''Constructor. If ``cache_files`` is True (off by default) then each
+        AudioFile is appended to ``_file_cache`` during ``handleFile`` and
+        the list is cleared by ``handleDirectory``.'''
+        super(LoaderPlugin, self).__init__(arg_parser)
+        self._num_loaded = 0
+        self._file_cache = [] if cache_files else None
 
     def handleFile(self, f, *args, **kwargs):
         '''Loads ``f`` and sets ``self.audio_file`` to an instance of
 
         try:
             self.audio_file = core.load(f, *args, **kwargs)
-            self._num_loaded += 1
         except NotImplementedError as ex:
             # Frame decryption, for instance...
             printError(ex)
+            return
+
+        if self.audio_file:
+            self._num_loaded += 1
+            if self._file_cache is not None:
+                self._file_cache.append(self.audio_file)
+
+    def handleDirectory(self, d, _):
+        '''Override to make use of ``self._file_cache``. By default the list
+        is cleared, subclasses should consider doing the same otherwise every
+        AudioFile will be cached.'''
+        if self._file_cache is not None:
+            self._file_cache = []
 
     def handleDone(self):
+        '''If no audio files were loaded this simply prints "Nothing to do".'''
         if self._num_loaded == 0:
             printMsg("Nothing to do")
 

src/eyed3/plugins/classic.py

             if len(id) > 64:
                 raise ValueError("id must be <= 64 bytes")
             return (owner_id, id)
+        def PopularityArg(arg):
+            '''EMAIL:RATING[:PLAY_COUNT]
+            Returns (email, rating, play_count)'''
+            args = _splitArgs(arg)
+            if len(args) < 2:
+                raise ValueError("Incorrect number of argument components")
+            email = args[0]
+            rating = int(args[1])
+            if rating < 0 or rating > 255:
+                raise ValueError("Rating out-of-range")
+            play_count = 0
+            if len(args) > 2:
+                play_count = int(args[2])
+            if play_count < 0:
+                raise ValueError("Play count out-of-range")
+            return (email, rating, play_count)
 
         # Tag versions
         gid3.add_argument("-1", "--v1", action="store_const", const=id3.ID3_V1,
                           dest="remove_all_objects",
                           help=ARGS_HELP["--remove-all-objects"])
 
+        gid3.add_argument("--add-popularity", action="append",
+                          type=PopularityArg, dest="popularities", default=[],
+                          metavar="EMAIL:RATING[:PLAY_COUNT]",
+                          help=ARGS_HELP["--add-popularty"])
+        gid3.add_argument("--remove-popularity", action="append", type=str,
+                          dest="remove_popularity", default=[],
+                          metavar="EMAIL",
+                          help=ARGS_HELP["--remove-popularity"])
+
         gid3.add_argument("--remove-v1", action="store_true", dest="remove_v1",
                           default=False, help=ARGS_HELP["--remove-v1"])
         gid3.add_argument("--remove-v2", action="store_true", dest="remove_v2",
             if tag.play_count is not None:
                  printMsg("%s %d" % (boldText("Play Count:"), play_count))
 
+            # POPM
+            for popm in tag.popularities:
+                printMsg("%s [email: %s] [rating: %d] [play count: %d]" %
+                         (boldText("Popularity:"), popm.email, popm.rating,
+                          popm.count))
+
             # TBPM
             bpm = tag.bpm
             if bpm is not None:
                 tag.play_count = pc
             retval = True
 
+        # --add-popularty
+        for email, rating, play_count in self.args.popularities:
+            tag.popularities.set(email, rating, play_count)
+            retval = True
+
+        # --remove-popularity
+        for email in self.args.remove_popularity:
+            popm = tag.popularities.remove(email)
+            if popm:
+                retval = True
+
         # --text-frame, --url-frame
         for what, arg, setter in (
                 ("text frame", self.args.text_frames, tag.setTextFrame),
         "--write-objects": "Causes all attached objects (GEOB frames) to be "
                            "written to the specified directory.",
 
+        "--add-popularty": "Adds a pupularity metric. There may be multiples "
+                           "popularity values, but each must have a unique "
+                           "email address component. The rating is a number "
+                           "between 0 (worst) and 255 (best). The play count "
+                           "is optional, and defaults to 0, since there is "
+                           "already a dedicated play count frame.",
+        "--remove-popularity": "Removes the popularity frame with the "
+                               "specified email key.",
+
         "--remove-v1": "Remove ID3 v1.x tag.",
         "--remove-v2": "Remove ID3 v2.x tag.",
         "--remove-all": "Remove ID3 v1.x and v2.x tags.",

src/eyed3/utils/__init__.py

 #  Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 #
 ################################################################################
+from __future__ import print_function
 import os, re
 
 import mimetypes, StringIO
                 except StopIteration:
                     return
 
+        if files:
+            handler.handleDirectory(root, files)
+
 
 class FileHandler(object):
     '''A handler interface for :func:`eyed3.utils.walk` callbacks.'''
         raise a ``StopIteration`` exception.'''
         pass
 
+    def handleDirectory(self, d, files):
+        '''Called for each directory ``d`` **after** ``handleFile`` has been
+        called for each file in ``files``. ``StopIteration`` may be raised to
+        halt iteration.'''
+        pass
+
     def handleDone(self):
         '''Called when there are no more files to handle.'''
         pass
 
-
-##
-# Function decorator to enforce unicode argument types.
-# None is a valid argument value in all cases, and is obviously not unicode.
-#
-# \param args Positional arguments may be numeric argument index values
-#             (requireUnicode(1, 3) - requires argument 1 and 3 are unicode)
-#             or keyword argument names (requireUnicode("title")) or a 
-#             combination thereof.
 def requireUnicode(*args):
+    '''Function decorator to enforce unicode argument types.
+    ``None`` is a valid argument value, in all cases, regardless of not being
+    unicode.  ``*args`` Positional arguments may be numeric argument index
+    values (requireUnicode(1, 3) - requires argument 1 and 3 are unicode)
+    or keyword argument names (requireUnicode("title")) or a combination
+    thereof.
+    '''
     arg_indices = []
     kwarg_names = []
     for a in args:

src/eyed3/utils/binfuncs.py

     return bin2dec(bytes2bin(bytes, sz))
 
 
-def dec2bin(n, p=0):
+def dec2bin(n, p=1):
     '''Convert a decimal value ``n`` to an array of bits (MSB first).
     Optionally, pad the overall size to ``p`` bits.'''
     assert(n >= 0)
     return retVal
 
 
-def dec2bytes(n, p=0):
+def dec2bytes(n, p=1):
     return bin2bytes(dec2bin(n, p))
 
 
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.