Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MetaSync: more OO structure + iTunes support #1450

Merged
merged 11 commits into from
May 13, 2015
118 changes: 83 additions & 35 deletions beetsplug/metasync/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,72 @@

"""Synchronize information from music player libraries
"""
from abc import abstractmethod, ABCMeta
import inspect
import pkgutil
from importlib import import_module

from beets import ui, logging
from beets.util.confit import ConfigValueError
from beets import ui
from beets.plugins import BeetsPlugin
from beets.dbcore import types
from beets.library import DateType
from sys import modules
import inspect

# Loggers.
log = logging.getLogger('beets.metasync')

METASYNC_MODULE = 'beetsplug.metasync'


class MetaSource(object):
__metaclass__ = ABCMeta

def __init__(self, config, log):
self.item_types = {}
self.config = config
self._log = log

@abstractmethod
def sync_from_source(self, item):
pass


def load_meta_sources():
""" Returns a dictionary of all the MetaSources
E.g., {'itunes': Itunes} with isinstance(Itunes, MetaSource) true
"""

def is_meta_source_implementation(c):
return inspect.isclass(c) and \
not inspect.isabstract(c) and \
issubclass(c, MetaSource)

meta_sources = {}

module_names = [name for _, name, _ in pkgutil.walk_packages(
import_module(METASYNC_MODULE).__path__)]

for module_name in module_names:
module = import_module(METASYNC_MODULE + '.' + module_name)
classes = inspect.getmembers(module, is_meta_source_implementation)

for cls_name, _cls in classes:
meta_sources[cls_name.lower()] = _cls

return meta_sources
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is some very clever work to get auto-discovery in order! It could be, though, that it would be easier just to hard-code the list of modules to import and pull classes out of—this sort of pkgutil and inspect magic could make this harder to maintain in the future. (We already run into some amount of trouble with testing, for example, when it interacts with similar magic in the beets plugin loading system.)

So I'll advocate gently for the "dumb" solution here: just redundantly writing down the list of available backends rather than automatically discovering them. But if anyone thinks this is too nifty to throw away, I could be convinced. 😃

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I should mention I realize that you didn't invent the magic here; it's just clearer this time, so it's the first time I thought about it carefully.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose you are right, considering there won't be that many sources.

Think I was just having some fun writing the auto-discovery, and sort of forgot the whole "Simple is better than complex" 😃



META_SOURCES = load_meta_sources()


def load_item_types():
""" Returns a dictionary containing the item_types of all the MetaSources
"""
item_types = {}
for meta_source in META_SOURCES.values():
item_types.update(meta_source.item_types)
return item_types


class MetaSyncPlugin(BeetsPlugin):

item_types = {
'amarok_rating': types.INTEGER,
'amarok_score': types.FLOAT,
'amarok_uid': types.STRING,
'amarok_playcount': types.INTEGER,
'amarok_firstplayed': DateType(),
'amarok_lastplayed': DateType()
}
item_types = load_item_types()

def __init__(self):
super(MetaSyncPlugin, self).__init__()
Expand All @@ -45,9 +89,9 @@ def commands(self):
help='update metadata from music player libraries')
cmd.parser.add_option('-p', '--pretend', action='store_true',
help='show all changes but do nothing')
cmd.parser.add_option('-s', '--source', action='store_false',
default=self.config['source'].as_str_seq(),
help="select specific sources to import from")
cmd.parser.add_option('-s', '--source', default=[],
action='append', dest='sources',
help='comma-separated list of sources to sync')
cmd.parser.add_format_option()
cmd.func = self.func
return [cmd]
Expand All @@ -56,31 +100,35 @@ def func(self, lib, opts, args):
"""Command handler for the metasync function.
"""
pretend = opts.pretend
source = opts.source
query = ui.decargs(args)

sources = {}

for player in source:
__import__('beetsplug.metasync', fromlist=[str(player)])
sources = []
for source in opts.sources:
sources.extend(source.split(','))

module = 'beetsplug.metasync.' + player
sources = sources or self.config['source'].as_str_seq()

if module not in modules.keys():
log.error(u'Unknown metadata source \'' + player + '\'')
continue
meta_sources = {}

classes = inspect.getmembers(modules[module], inspect.isclass)
# Instantiate the meta sources
for player in sources:
try:
meta_sources[player] = \
META_SOURCES[player](self.config, self._log)
except KeyError:
self._log.error(u'Unknown metadata source \'{0}\''.format(
player))
except (ImportError, ConfigValueError) as e:
self._log.error(u'Failed to instantiate metadata source '
u'\'{0}\': {1}'.format(player, e))

for entry in classes:
if entry[0].lower() == player:
sources[player] = entry[1]()
else:
continue
if not meta_sources:
return

# Sync the items with all of the meta sources
for item in lib.items(query):
for player in sources.values():
player.get_data(item)
for meta_source in meta_sources.values():
meta_source.sync_from_source(item)

changed = ui.show_model_changes(item)

Expand Down
36 changes: 31 additions & 5 deletions beetsplug/metasync/amarok.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,50 @@
from os.path import basename
from datetime import datetime
from time import mktime
from beets.util import displayable_path
from xml.sax.saxutils import escape
import dbus

from beets.util import displayable_path
from beets.dbcore import types
from beets.library import DateType
from beetsplug.metasync import MetaSource


def import_dbus():
try:
return __import__('dbus')
except ImportError:
return None

dbus = import_dbus()

class Amarok(object):

class Amarok(MetaSource):

item_types = {
'amarok_rating': types.INTEGER,
'amarok_score': types.FLOAT,
'amarok_uid': types.STRING,
'amarok_playcount': types.INTEGER,
'amarok_firstplayed': DateType(),
'amarok_lastplayed': DateType(),
}

queryXML = u'<query version="1.0"> \
<filters> \
<and><include field="filename" value="%s" /></and> \
</filters> \
</query>'

def __init__(self):
def __init__(self, config, log):
super(Amarok, self).__init__(config, log)

if not dbus:
raise ImportError('failed to import dbus')

self.collection = \
dbus.SessionBus().get_object('org.kde.amarok', '/Collection')

def get_data(self, item):
def sync_from_source(self, item):
path = displayable_path(item.path)

# amarok unfortunately doesn't allow searching for the full path, only
Expand Down
97 changes: 97 additions & 0 deletions beetsplug/metasync/itunes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# This file is part of beets.
# Copyright 2015, Tom Jaspers.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.

"""Synchronize information from iTunes's library
"""
from contextlib import contextmanager
import os
import shutil
import tempfile
import plistlib
from time import mktime

from beets import util
from beets.dbcore import types
from beets.library import DateType
from beets.util.confit import ConfigValueError
from beetsplug.metasync import MetaSource


@contextmanager
def create_temporary_copy(path):
temp_dir = tempfile.mkdtemp()
temp_path = os.path.join(temp_dir, 'temp_itunes_lib')
shutil.copyfile(path, temp_path)
try:
yield temp_path
finally:
shutil.rmtree(temp_dir)


class Itunes(MetaSource):

item_types = {
'itunes_rating': types.INTEGER, # 0..100 scale
'itunes_playcount': types.INTEGER,
'itunes_skipcount': types.INTEGER,
'itunes_lastplayed': DateType(),
'itunes_lastskipped': DateType(),
}

def __init__(self, config, log):
super(Itunes, self).__init__(config, log)

# Load the iTunes library, which has to be the .xml one (not the .itl)
library_path = util.normpath(config['itunes']['library'].get(str))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think .as_path() instead of .get(str) will obviate the need for the normpath.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has the as_path() method maybe been renamed or removed? I can't seem to find it anywhere.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops! Sorry; I was freehanding. I meant as_filename: https://github.com/sampsyo/beets/blob/master/beets/util/confit.py#L378


try:
self._log.debug(
u'loading iTunes library from {0}'.format(library_path))
with create_temporary_copy(library_path) as library_copy:
raw_library = plistlib.readPlist(library_copy)
except IOError as e:
raise ConfigValueError(u'invalid iTunes library: ' + e.strerror)
except Exception:
# It's likely the user configured their '.itl' library (<> xml)
if os.path.splitext(library_path)[1].lower() != '.xml':
hint = u': please ensure that the configured path' \
u' points to the .XML library'
else:
hint = ''
raise ConfigValueError(u'invalid iTunes library' + hint)

# Convert the library in to something we can query more easily
self.collection = {
(track['Name'], track['Album'], track['Album Artist']): track
for track in raw_library['Tracks'].values()}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this is looking up tracks by their metadata. Would it be possible/simpler to use their paths instead? Then we wouldn't run into any weird aliasing problems.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was my original intention (and how I would prefer) it, but iTunes saves path in Unix style on all systems. This is conflicting with the way paths are saved in beets for Windows.

In Itunes Library.xml:
file://localhost/G:/Experiments/Alt-J/An%20Awesome%20Wave/01%20Intro.mp3

And in beets:
G:\Experiments\Alt-J\An Awesome Wave\01 Intro.mp3

I'm gonna try to resort to converting the iTunes path to match beets' style. Will take a while to test it properly on Windows system though, but using a path is probably better in the long run.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aha; that's really tricky. Yes, maybe converting iTunes's path on load is the way to go (perhaps just a normpath would do it?).

I may be able to find my Windows VM somewhere to do some testing.


def sync_from_source(self, item):
key = (item.title, item.album, item.albumartist)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny style thing: no parentheses are required on this tuple.

result = self.collection.get(key)

if not all(key) or not result:
self._log.warning(u'no iTunes match found for {0}'.format(item))
return

item.itunes_rating = result.get('Rating')
item.itunes_playcount = result.get('Play Count')
item.itunes_skipcount = result.get('Skip Count')

if result.get('Play Date UTC'):
item.itunes_lastplayed = mktime(
result.get('Play Date UTC').timetuple())

if result.get('Skip Date'):
item.itunes_lastskipped = mktime(
result.get('Skip Date').timetuple())
6 changes: 6 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ Changelog
1.3.14 (in development)
-----------------------

New features:

* The :doc:`/plugins/metasync` plugin now lets you get metadata from iTunes.
This plugin is still in an experimental phase. :bug:`1450`


Fixes:

* :doc:`/plugins/mpdstats`: Avoid a crash when the music played is not in the
Expand Down
25 changes: 20 additions & 5 deletions docs/plugins/metasync.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ MetaSync Plugin
This plugin provides the ``metasync`` command, which lets you fetch certain
metadata from other sources: for example, your favorite audio player.

Currently, the plugin supports synchronizing with the `Amarok`_ music player.
Currently, the plugin supports synchronizing with the `Amarok`_ music player,
and with `iTunes`_.
It can fetch the rating, score, first-played date, last-played date, play
count, and track uid from Amarok.

.. _Amarok: https://amarok.kde.org/
.. _iTunes: https://www.apple.com/itunes/


Installation
Expand All @@ -29,10 +31,23 @@ Configuration
To configure the plugin, make a ``metasync:`` section in your configuration
file. The available options are:

- **source**: A list of sources to fetch metadata from. Set this to "amarok"
to enable synchronization with that player.
- **source**: A list of comma-separated sources to fetch metadata from.
Set this to "amarok" or "itunes" to enable synchronization with that player.
Default: empty

The follow subsections describe additional configure required for some players.

itunes
''''''

The path to your iTunes library **xml** file has to be configured, e.g.::

metasync:
source: itunes
itunes:
library: ~/Music/iTunes Library.xml
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we provide a default location for the library XML? It seems like most users will probably have theirs here (at least on OS X).


Please note the indentation.

Usage
-----
Expand All @@ -44,5 +59,5 @@ The command has a few command-line options:

* To preview the changes that would be made without applying them, use the
``-p`` (``--pretend``) flag.
* To specify a temporary source to fetch metadata from, use the ``-s``
(``--source``) flag.
* To specify temporary sources to fetch metadata from, use the ``-s``
(``--source``) flag with a comma-separated list of a sources.
Loading