diff --git a/beetsplug/metasync/__init__.py b/beetsplug/metasync/__init__.py index 743bc8277..380c72707 100644 --- a/beetsplug/metasync/__init__.py +++ b/beetsplug/metasync/__init__.py @@ -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__() @@ -45,9 +81,9 @@ class MetaSyncPlugin(BeetsPlugin): 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] @@ -56,31 +92,43 @@ class MetaSyncPlugin(BeetsPlugin): """Command handler for the metasync function. """ pretend = opts.pretend - source = opts.source query = ui.decargs(args) - sources = {} + sources = [] + for source in opts.sources: + sources.extend(source.split(',')) - for player in source: - __import__('beetsplug.metasync', fromlist=[str(player)]) + sources = sources or self.config['source'].as_str_seq() - module = 'beetsplug.metasync.' + player + meta_source_instances = {} + items = lib.items(query) - if module not in modules.keys(): - log.error(u'Unknown metadata source \'' + player + '\'') - continue + # Avoid needlessly instantiating meta sources (can be expensive) + if not items: + self._log.info(u'No items found matching query') + return - classes = inspect.getmembers(modules[module], inspect.isclass) + # 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)) - for entry in classes: - if entry[0].lower() == player: - sources[player] = entry[1]() - else: - continue + # Avoid needlessly iterating over items + if not meta_source_instances: + self._log.error(u'No valid metadata sources found') + return - for item in lib.items(query): - for player in sources.values(): - player.get_data(item) + # 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) diff --git a/beetsplug/metasync/amarok.py b/beetsplug/metasync/amarok.py index 3ffeaeac9..b544c22ec 100644 --- a/beetsplug/metasync/amarok.py +++ b/beetsplug/metasync/amarok.py @@ -18,12 +18,33 @@ 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 -class Amarok(object): +def import_dbus(): + try: + return __import__('dbus') + except ImportError: + return None + +dbus = import_dbus() + + +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' \ \ @@ -31,11 +52,16 @@ class Amarok(object): \ ' - 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 diff --git a/beetsplug/metasync/itunes.py b/beetsplug/metasync/itunes.py new file mode 100644 index 000000000..b2795f9a4 --- /dev/null +++ b/beetsplug/metasync/itunes.py @@ -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()) diff --git a/docs/changelog.rst b/docs/changelog.rst index 4488e821b..9432a932c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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 diff --git a/docs/plugins/metasync.rst b/docs/plugins/metasync.rst index cd157eacd..6703d3c19 100644 --- a/docs/plugins/metasync.rst +++ b/docs/plugins/metasync.rst @@ -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 @@ -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 ----- @@ -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. diff --git a/test/rsrc/itunes_library_unix.xml b/test/rsrc/itunes_library_unix.xml new file mode 100644 index 000000000..c95bb52b4 --- /dev/null +++ b/test/rsrc/itunes_library_unix.xml @@ -0,0 +1,167 @@ + + + + + Major Version1 + Minor Version1 + Date2015-05-08T14:36:28Z + Application Version12.1.2.27 + Features5 + Show Content Ratings + Music Folderfile:////Music/ + Library Persistent ID1ABA8417E4946A32 + Tracks + + 634 + + Track ID634 + NameTessellate + Artistalt-J + Album Artistalt-J + AlbumAn Awesome Wave + GenreAlternative + KindMPEG audio file + Size5525212 + Total Time182674 + Disc Number1 + Disc Count1 + Track Number3 + Track Count13 + Year2012 + Date Modified2015-02-02T15:23:08Z + Date Added2014-04-24T09:28:38Z + Bit Rate238 + Sample Rate44100 + Play Count0 + Play Date3513593824 + Skip Count3 + Skip Date2015-02-05T15:41:04Z + Rating80 + Album Rating80 + Album Rating Computed + Artwork Count1 + Sort AlbumAwesome Wave + Sort Artistalt-J + Persistent ID20E89D1580C31363 + Track TypeFile + Locationfile:///Music/Alt-J/An%20Awesome%20Wave/03%20Tessellate.mp3 + File Folder Count4 + Library Folder Count2 + + 636 + + Track ID636 + NameBreezeblocks + Artistalt-J + Album Artistalt-J + AlbumAn Awesome Wave + GenreAlternative + KindMPEG audio file + Size6827195 + Total Time227082 + Disc Number1 + Disc Count1 + Track Number4 + Track Count13 + Year2012 + Date Modified2015-02-02T15:23:08Z + Date Added2014-04-24T09:28:38Z + Bit Rate237 + Sample Rate44100 + Play Count31 + Play Date3513594051 + Play Date UTC2015-05-04T12:20:51Z + Skip Count0 + Rating100 + Album Rating80 + Album Rating Computed + Artwork Count1 + Sort AlbumAwesome Wave + Sort Artistalt-J + Persistent IDD7017B127B983D38 + Track TypeFile + Locationfile://localhost/Music/Alt-J/An%20Awesome%20Wave/04%20Breezeblocks.mp3 + File Folder Count4 + Library Folder Count2 + + 638 + + Track ID638 + Name❦ (Ripe & Ruin) + Artistalt-J + Album Artistalt-J + AlbumAn Awesome Wave + KindMPEG audio file + Size2173293 + Total Time72097 + Disc Number1 + Disc Count1 + Track Number2 + Track Count13 + Year2012 + Date Modified2015-05-09T17:04:53Z + Date Added2015-02-02T15:28:39Z + Bit Rate233 + Sample Rate44100 + Play Count8 + Play Date3514109973 + Play Date UTC2015-05-10T11:39:33Z + Skip Count1 + Skip Date2015-02-02T15:29:10Z + Album Rating80 + Album Rating Computed + Artwork Count1 + Sort AlbumAwesome Wave + Sort Artistalt-J + Persistent ID183699FA0554D0E6 + Track TypeFile + Locationfile:///Music/Alt-J/An%20Awesome%20Wave/02%20%E2%9D%A6%20(Ripe%20&%20Ruin).mp3 + File Folder Count4 + Library Folder Count2 + + + Playlists + + + NameLibrary + Master + Playlist ID11480 + Playlist Persistent IDCD6FF684E7A6A166 + Visible + All Items + Playlist Items + + + Track ID634 + + + Track ID636 + + + Track ID638 + + + + + NameMusic + Playlist ID16906 + Playlist Persistent ID4FB2E64E0971DD45 + Distinguished Kind4 + Music + All Items + Playlist Items + + + Track ID634 + + + Track ID636 + + + Track ID638 + + + + + + diff --git a/test/rsrc/itunes_library_windows.xml b/test/rsrc/itunes_library_windows.xml new file mode 100644 index 000000000..19184c3f2 --- /dev/null +++ b/test/rsrc/itunes_library_windows.xml @@ -0,0 +1,167 @@ + + + + + Major Version1 + Minor Version1 + Date2015-05-11T15:27:14Z + Application Version12.1.2.27 + Features5 + Show Content Ratings + Music Folderfile://localhost/C:/Documents%20and%20Settings/Owner/My%20Documents/My%20Music/iTunes/iTunes%20Media/ + Library Persistent IDB4C9F3EE26EFAF78 + Tracks + + 180 + + Track ID180 + NameTessellate + Artistalt-J + Album Artistalt-J + AlbumAn Awesome Wave + GenreAlternative + KindMPEG audio file + Size5525212 + Total Time182674 + Disc Number1 + Disc Count1 + Track Number3 + Track Count13 + Year2012 + Date Modified2015-02-02T15:23:08Z + Date Added2014-04-24T09:28:38Z + Bit Rate238 + Sample Rate44100 + Play Count0 + Play Date3513593824 + Skip Count3 + Skip Date2015-02-05T15:41:04Z + Rating80 + Album Rating80 + Album Rating Computed + Artwork Count1 + Sort AlbumAwesome Wave + Sort Artistalt-J + Persistent ID20E89D1580C31363 + Track TypeFile + Locationfile://localhost/G:/Music/Alt-J/An%20Awesome%20Wave/03%20Tessellate.mp3 + File Folder Count-1 + Library Folder Count-1 + + 183 + + Track ID183 + NameBreezeblocks + Artistalt-J + Album Artistalt-J + AlbumAn Awesome Wave + GenreAlternative + KindMPEG audio file + Size6827195 + Total Time227082 + Disc Number1 + Disc Count1 + Track Number4 + Track Count13 + Year2012 + Date Modified2015-02-02T15:23:08Z + Date Added2014-04-24T09:28:38Z + Bit Rate237 + Sample Rate44100 + Play Count31 + Play Date3513594051 + Play Date UTC2015-05-04T12:20:51Z + Skip Count0 + Rating100 + Album Rating80 + Album Rating Computed + Artwork Count1 + Sort AlbumAwesome Wave + Sort Artistalt-J + Persistent IDD7017B127B983D38 + Track TypeFile + Locationfile://localhost/G:/Music/Alt-J/An%20Awesome%20Wave/04%20Breezeblocks.mp3 + File Folder Count-1 + Library Folder Count-1 + + 638 + + Track ID638 + Name❦ (Ripe & Ruin) + Artistalt-J + Album Artistalt-J + AlbumAn Awesome Wave + KindMPEG audio file + Size2173293 + Total Time72097 + Disc Number1 + Disc Count1 + Track Number2 + Track Count13 + Year2012 + Date Modified2015-05-09T17:04:53Z + Date Added2015-02-02T15:28:39Z + Bit Rate233 + Sample Rate44100 + Play Count8 + Play Date3514109973 + Play Date UTC2015-05-10T11:39:33Z + Skip Count1 + Skip Date2015-02-02T15:29:10Z + Album Rating80 + Album Rating Computed + Artwork Count1 + Sort AlbumAwesome Wave + Sort Artistalt-J + Persistent ID183699FA0554D0E6 + Track TypeFile + Locationfile://localhost/G:/Experiments/Alt-J/An%20Awesome%20Wave/02%20%E2%9D%A6%20(Ripe%20&%20Ruin).mp3 + File Folder Count4 + Library Folder Count2 + + + Playlists + + + NameBibliotheek + Master + Playlist ID72 + Playlist Persistent ID728AA5B1D00ED23B + Visible + All Items + Playlist Items + + + Track ID180 + + + Track ID183 + + + Track ID638 + + + + + NameMuziek + Playlist ID103 + Playlist Persistent ID8120A002B0486AD7 + Distinguished Kind4 + Music + All Items + Playlist Items + + + Track ID180 + + + Track ID183 + + + Track ID638 + + + + + + diff --git a/test/test_metasync.py b/test/test_metasync.py new file mode 100644 index 000000000..8e2669c41 --- /dev/null +++ b/test/test_metasync.py @@ -0,0 +1,123 @@ +# 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. +import os +import platform +import time +from datetime import datetime +from beets.library import Item + +from test import _common +from test._common import unittest +from test.helper import TestHelper + + +def _parsetime(s): + return time.mktime(datetime.strptime(s, '%Y-%m-%d %H:%M:%S').timetuple()) + + +def _is_windows(): + return platform.system() == "Windows" + + +class MetaSyncTest(_common.TestCase, TestHelper): + itunes_library_unix = os.path.join(_common.RSRC, + 'itunes_library_unix.xml') + itunes_library_windows = os.path.join(_common.RSRC, + 'itunes_library_windows.xml') + + def setUp(self): + self.setup_beets() + self.load_plugins('metasync') + + self.config['metasync']['source'] = 'itunes' + + if _is_windows(): + self.config['metasync']['itunes']['library'] = \ + self.itunes_library_windows + else: + self.config['metasync']['itunes']['library'] = \ + self.itunes_library_unix + + self._set_up_data() + + def _set_up_data(self): + items = [_common.item() for _ in range(2)] + + items[0].title = 'Tessellate' + items[0].artist = 'alt-J' + items[0].albumartist = 'alt-J' + items[0].album = 'An Awesome Wave' + items[0].itunes_rating = 60 + + items[1].title = 'Breezeblocks' + items[1].artist = 'alt-J' + items[1].albumartist = 'alt-J' + items[1].album = 'An Awesome Wave' + + if _is_windows(): + items[0].path = \ + u'G:\\Music\\Alt-J\\An Awesome Wave\\03 Tessellate.mp3' + items[1].path = \ + u'G:\\Music\\Alt-J\\An Awesome Wave\\04 Breezeblocks.mp3' + else: + items[0].path = u'/Music/Alt-J/An Awesome Wave/03 Tessellate.mp3' + items[1].path = u'/Music/Alt-J/An Awesome Wave/04 Breezeblocks.mp3' + + for item in items: + self.lib.add(item) + + def tearDown(self): + self.unload_plugins() + self.teardown_beets() + + def test_load_item_types(self): + # This test also verifies that the MetaSources have loaded correctly + self.assertIn('amarok_score', Item._types) + self.assertIn('itunes_rating', Item._types) + + def test_pretend_sync_from_itunes(self): + out = self.run_with_output('metasync', '-p') + + self.assertIn('itunes_rating: 60 -> 80', out) + self.assertIn('itunes_rating: 100', out) + self.assertIn('itunes_playcount: 31', out) + self.assertIn('itunes_skipcount: 3', out) + self.assertIn('itunes_lastplayed: 2015-05-04 12:20:51', out) + self.assertIn('itunes_lastskipped: 2015-02-05 15:41:04', out) + self.assertEqual(self.lib.items()[0].itunes_rating, 60) + + def test_sync_from_itunes(self): + self.run_command('metasync') + + self.assertEqual(self.lib.items()[0].itunes_rating, 80) + self.assertEqual(self.lib.items()[0].itunes_playcount, 0) + self.assertEqual(self.lib.items()[0].itunes_skipcount, 3) + self.assertFalse(hasattr(self.lib.items()[0], 'itunes_lastplayed')) + self.assertEqual(self.lib.items()[0].itunes_lastskipped, + _parsetime('2015-02-05 15:41:04')) + + self.assertEqual(self.lib.items()[1].itunes_rating, 100) + self.assertEqual(self.lib.items()[1].itunes_playcount, 31) + self.assertEqual(self.lib.items()[1].itunes_skipcount, 0) + self.assertEqual(self.lib.items()[1].itunes_lastplayed, + _parsetime('2015-05-04 12:20:51')) + self.assertFalse(hasattr(self.lib.items()[1], 'itunes_lastskipped')) + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + + +if __name__ == b'__main__': + unittest.main(defaultTest='suite')