Skip to content

Commit

Permalink
Merge pull request #1450 from tomjaspers/metasync-itunes
Browse files Browse the repository at this point in the history
MetaSync: more OO structure +  iTunes support
  • Loading branch information
sampsyo committed May 13, 2015
2 parents dff4fea + 94edc7a commit 71d7c0b
Show file tree
Hide file tree
Showing 8 changed files with 719 additions and 51 deletions.
130 changes: 89 additions & 41 deletions beetsplug/metasync/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,64 @@

"""Synchronize information from music player libraries
"""
from abc import abstractmethod, ABCMeta
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'

# Dictionary to map the MODULE and the CLASS NAME of meta sources
SOURCES = {
'amarok': 'Amarok',
'itunes': 'Itunes',
}


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
"""
meta_sources = {}

for module_path, class_name in SOURCES.items():
module = import_module(METASYNC_MODULE + '.' + module_path)
meta_sources[class_name.lower()] = getattr(module, class_name)

return meta_sources


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 +81,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 +92,43 @@ 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)])

module = 'beetsplug.metasync.' + player

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

classes = inspect.getmembers(modules[module], inspect.isclass)

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

for item in lib.items(query):
for player in sources.values():
player.get_data(item)
sources = []
for source in opts.sources:
sources.extend(source.split(','))

sources = sources or self.config['source'].as_str_seq()

meta_source_instances = {}
items = lib.items(query)

# Avoid needlessly instantiating meta sources (can be expensive)
if not items:
self._log.info(u'No items found matching query')
return

# Instantiate the meta sources
for player in sources:
try:
meta_source_instances[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))

# Avoid needlessly iterating over items
if not meta_source_instances:
self._log.error(u'No valid metadata sources found')
return

# Sync the items with all of the meta sources
for item in items:
for meta_source in meta_source_instances.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
116 changes: 116 additions & 0 deletions beetsplug/metasync/itunes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# 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
import urllib
from urlparse import urlparse
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)


def _norm_itunes_path(path):
# Itunes prepends the location with 'file://' on posix systems,
# and with 'file://localhost/' on Windows systems.
# The actual path to the file is always saved as posix form
# E.g., 'file://Users/Music/bar' or 'file://localhost/G:/Music/bar'

# The entire path will also be capitalized (e.g., '/Music/Alt-J')
# Note that this means the path will always have a leading separator,
# which is unwanted in the case of Windows systems.
# E.g., '\\G:\\Music\\bar' needs to be stripped to 'G:\\Music\\bar'

return util.bytestring_path(os.path.normpath(
urllib.unquote(urlparse(path).path)).lstrip('\\')).lower()


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)

config.add({'itunes': {
'library': '~/Music/iTunes/iTunes Library.xml'
}})

# Load the iTunes library, which has to be the .xml one (not the .itl)
library_path = config['itunes']['library'].as_filename()

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)

# Make the iTunes library queryable using the path
self.collection = {_norm_itunes_path(track['Location']): track
for track in raw_library['Tracks'].values()}

def sync_from_source(self, item):
result = self.collection.get(util.bytestring_path(item.path).lower())

if 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

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

0 comments on commit 71d7c0b

Please sign in to comment.