diff --git a/docs/changelog.rst b/docs/changelog.rst index e3b4826f..48c7d822 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -20,6 +20,12 @@ v0.18.0 (UNRELEASED) - Add :class:`mopidy.models.Ref` class for use as a lightweight reference to other model types, containing just an URI, a name, and an object type. +**Extension registry** + +- Switched to using a registry model for classes provided by extension. This + allows extensions to be extended as needed for plugable local libraries. + (Fixes :issue:`601`) + **Pluggable local libraries** Fixes issues :issue:`44`, partially resolves :issue:`397`, and causes @@ -38,6 +44,15 @@ a temporary regression of :issue:`527`. - Added support for deprecated config values in order to allow for graceful removal of :confval:`local/tag_cache_file`. +- Added :confval:`local/library` to select which library to use. + +- Added :confval:`local/data_dir` to have a common setting for where to store + local library data. This is intended to avoid every single local library + provider having to have it's own setting for this. + +- Added :confval:`local/scan_flush_threshold` to control how often to tell + local libraries to store changes. + **Streaming backend** - Live lookup of URI metadata has been added. (Fixes :issue:`540`) diff --git a/docs/commands/mopidy.rst b/docs/commands/mopidy.rst index 44e961e6..49c7b5b9 100644 --- a/docs/commands/mopidy.rst +++ b/docs/commands/mopidy.rst @@ -83,6 +83,10 @@ Additionally, extensions can provide extra commands. Run `mopidy --help` for a list of what is available on your system and command-specific help. Commands for disabled extensions will be listed, but can not be run. +.. cmdoption:: local clear + + Clear local media files from the local library. + .. cmdoption:: local scan Scan local media files present in your library. diff --git a/docs/ext/local.rst b/docs/ext/local.rst index 0c16142c..aaaeb8e4 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -29,10 +29,20 @@ Configuration values If the local extension should be enabled or not. +.. confval:: local/library + + Local library provider to use, change this if you want to use a third party + library for local files. + .. confval:: local/media_dir Path to directory with local media files. +.. confval:: local/data_dir + + Path to directory to store local metadata such as libraries and playlists + in. + .. confval:: local/playlists_dir Path to playlists directory with m3u files for local media. @@ -42,6 +52,11 @@ Configuration values Number of milliseconds before giving up scanning a file and moving on to the next file. +.. confval:: local/scan_flush_threshold + + Number of tracks to wait before telling library it should try and store + its progress so far. Some libraries might not respect this setting. + .. confval:: local/excluded_file_extensions File extensions to exclude when scanning the media directory. Values @@ -84,34 +99,13 @@ Pluggable library support ------------------------- Local libraries are fully pluggable. What this means is that users may opt to -disable the current default library ``local-json``, replacing it with a third +disable the current default library ``json``, replacing it with a third party one. When running :command:`mopidy local scan` mopidy will populate whatever the current active library is with data. Only one library may be active at a time. - -***************** -Mopidy-Local-JSON -***************** - -Extension for storing local music library in a JSON file, default built in -library for local files. - - -Default configuration -===================== - -.. literalinclude:: ../../mopidy/backends/local/json/ext.conf - :language: ini - - -Configuration values -==================== - -.. confval:: local-json/enabled - - If the local-json extension should be enabled or not. - -.. confval:: local-json/json_file - - Path to a file to store the gzipped JSON data in. +To create a new library provider you must create class that implements the +:class:`~mopidy.backends.local.Libary` interface and install it in the +extension registry under ``local:library``. Any data that the library needs +to store on disc should be stored in :confval:`local/data_dir` using the +library name as part of the filename or directory to avoid any conflicts. diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 2e6a6cc5..1ddd76a4 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -40,11 +40,13 @@ def main(): signal.signal(signal.SIGUSR1, pykka.debug.log_thread_tracebacks) try: + registry = ext.Registry() + root_cmd = commands.RootCommand() config_cmd = commands.ConfigCommand() deps_cmd = commands.DepsCommand() - root_cmd.set(extension=None) + root_cmd.set(extension=None, registry=registry) root_cmd.add_child('config', config_cmd) root_cmd.add_child('deps', deps_cmd) @@ -84,7 +86,6 @@ def main(): enabled_extensions.append(extension) log_extension_info(installed_extensions, enabled_extensions) - ext.register_gstreamer_elements(enabled_extensions) # Config and deps commands are simply special cased for now. if args.command == config_cmd: @@ -108,10 +109,13 @@ def main(): args.extension.ext_name) return 1 + for extension in enabled_extensions: + extension.setup(registry) + # Anything that wants to exit after this point must use # mopidy.utils.process.exit_process as actors can have been started. try: - return args.command.run(args, proxied_config, enabled_extensions) + return args.command.run(args, proxied_config) except NotImplementedError: print(root_cmd.format_help()) return 1 diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index f797a84d..0c8e3478 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -181,6 +181,12 @@ def audio_data_to_track(data): track_kwargs['uri'] = data['uri'] track_kwargs['album'] = Album(**album_kwargs) + # TODO: this feels like a half assed workaround. we need to be sure that we + # don't suddenly have lists in our models where we expect strings etc + if ('genre' in track_kwargs and + not isinstance(track_kwargs['genre'], basestring)): + track_kwargs['genre'] = ', '.join(track_kwargs['genre']) + if ('name' in artist_kwargs and not isinstance(artist_kwargs['name'], basestring)): track_kwargs['artists'] = [Artist(name=artist) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index e9204338..7cb2f0d5 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -5,7 +5,6 @@ import os import mopidy from mopidy import config, ext -from mopidy.utils import encoding, path logger = logging.getLogger(__name__) @@ -22,25 +21,136 @@ class Extension(ext.Extension): def get_config_schema(self): schema = super(Extension, self).get_config_schema() + schema['library'] = config.String() schema['media_dir'] = config.Path() + schema['data_dir'] = config.Path() schema['playlists_dir'] = config.Path() schema['tag_cache_file'] = config.Deprecated() schema['scan_timeout'] = config.Integer( minimum=1000, maximum=1000*60*60) + schema['scan_flush_threshold'] = config.Integer(minimum=0) schema['excluded_file_extensions'] = config.List(optional=True) return schema - def validate_environment(self): - try: - path.get_or_create_dir(b'$XDG_DATA_DIR/mopidy/local') - except EnvironmentError as error: - error = encoding.locale_decode(error) - logger.warning('Could not create local data dir: %s', error) - - def get_backend_classes(self): + def setup(self, registry): from .actor import LocalBackend - return [LocalBackend] + from .json import JsonLibrary + + LocalBackend.libraries = registry['local:library'] + + registry.add('backend', LocalBackend) + registry.add('local:library', JsonLibrary) def get_command(self): from .commands import LocalCommand return LocalCommand() + + +class Library(object): + """ + Local library interface. + + Extensions that wish to provide an alternate local library storage backend + need to sub-class this class and install and configure it with an + extension. Both scanning and library calls will use the active local + library. + + :param config: Config dictionary + """ + + #: Name of the local library implementation, must be overriden. + name = None + + def __init__(self, config): + self._config = config + + def load(self): + """ + (Re)load any tracks stored in memory, if any, otherwise just return + number of available tracks currently available. Will be called at + startup for both library and update use cases, so if you plan to store + tracks in memory this is when the should be (re)loaded. + + :rtype: :class:`int` representing number of tracks in library. + """ + return 0 + + def lookup(self, uri): + """ + Lookup the given URI. + + Unlike the core APIs, local tracks uris can only be resolved to a + single track. + + :param string uri: track URI + :rtype: :class:`~mopidy.models.Track` + """ + raise NotImplementedError + + # TODO: remove uris, replacing it with support in query language. + # TODO: remove exact, replacing it with support in query language. + def search(self, query=None, limit=100, offset=0, exact=False, uris=None): + """ + Search the library for tracks where ``field`` contains ``values``. + + :param dict query: one or more queries to search for + :param int limit: maximum number of results to return + :param int offset: offset into result set to use. + :param bool exact: whether to look for exact matches + :param uris: zero or more URI roots to limit the search to + :type uris: list of strings or :class:`None` + :rtype: :class:`~mopidy.models.SearchResult` + """ + raise NotImplementedError + + # TODO: add file browsing support. + + # Remaining methods are use for the update process. + def begin(self): + """ + Prepare library for accepting updates. Exactly what this means is + highly implementation depended. This must however return an iterator + that generates all tracks in the library for efficient scanning. + + :rtype: :class:`~mopidy.models.Track` iterator + """ + raise NotImplementedError + + def add(self, track): + """ + Add the given track to library. + + :param :class:`~mopidy.models.Track` track: Track to add to the library + """ + raise NotImplementedError + + def remove(self, uri): + """ + Remove the given track from the library. + + :param str uri: URI to remove from the library/ + """ + raise NotImplementedError + + def flush(self): + """ + Called for every n-th track indicating that work should be committed. + Sub-classes are free to ignore these hints. + + :rtype: Boolean indicating if state was flushed. + """ + return False + + def close(self): + """ + Close any resources used for updating, commit outstanding work etc. + """ + pass + + def clear(self): + """ + Clear out whatever data storage is used by this backend. + + :rtype: Boolean indicating if state was cleared. + """ + return False diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index a9902c8b..c29a5dbe 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -8,13 +8,17 @@ import pykka from mopidy.backends import base from mopidy.utils import encoding, path -from .playlists import LocalPlaylistsProvider +from .library import LocalLibraryProvider from .playback import LocalPlaybackProvider +from .playlists import LocalPlaylistsProvider logger = logging.getLogger(__name__) class LocalBackend(pykka.ThreadingActor, base.Backend): + uri_schemes = ['local'] + libraries = [] + def __init__(self, config, audio): super(LocalBackend, self).__init__() @@ -22,16 +26,33 @@ class LocalBackend(pykka.ThreadingActor, base.Backend): self.check_dirs_and_files() + libraries = dict((l.name, l) for l in self.libraries) + library_name = config['local']['library'] + + if library_name in libraries: + library = libraries[library_name](config) + logger.debug('Using %s as the local library', library_name) + else: + library = None + logger.warning('Local library %s not found', library_name) + self.playback = LocalPlaybackProvider(audio=audio, backend=self) self.playlists = LocalPlaylistsProvider(backend=self) - - self.uri_schemes = ['local'] + self.library = LocalLibraryProvider(backend=self, library=library) def check_dirs_and_files(self): if not os.path.isdir(self.config['local']['media_dir']): logger.warning('Local media dir %s does not exist.' % self.config['local']['media_dir']) + try: + path.get_or_create_dir(self.config['local']['data_dir']) + except EnvironmentError as error: + logger.warning( + 'Could not create local data dir: %s', + encoding.locale_decode(error)) + + # TODO: replace with data dir? try: path.get_or_create_dir(self.config['local']['playlists_dir']) except EnvironmentError as error: diff --git a/mopidy/backends/local/commands.py b/mopidy/backends/local/commands.py index e9eb807c..5e4bfe62 100644 --- a/mopidy/backends/local/commands.py +++ b/mopidy/backends/local/commands.py @@ -13,45 +13,72 @@ from . import translator logger = logging.getLogger(__name__) +def _get_library(args, config): + libraries = dict((l.name, l) for l in args.registry['local:library']) + library_name = config['local']['library'] + + if library_name not in libraries: + logger.warning('Local library %s not found', library_name) + return 1 + + logger.debug('Using %s as the local library', library_name) + return libraries[library_name](config) + + class LocalCommand(commands.Command): def __init__(self): super(LocalCommand, self).__init__() self.add_child('scan', ScanCommand()) + self.add_child('clear', ClearCommand()) + + +class ClearCommand(commands.Command): + help = 'Clear local media files from the local library.' + + def run(self, args, config): + library = _get_library(args, config) + prompt = 'Are you sure you want to clear the library? [y/N] ' + + if raw_input(prompt).lower() != 'y': + logging.info('Clearing library aborted.') + return 0 + + if library.clear(): + logging.info('Library succesfully cleared.') + return 0 + + logging.warning('Unable to clear library.') + return 1 class ScanCommand(commands.Command): - help = "Scan local media files and populate the local library." + help = 'Scan local media files and populate the local library.' - def run(self, args, config, extensions): + def __init__(self): + super(ScanCommand, self).__init__() + self.add_argument('--limit', + action='store', type=int, dest='limit', default=None, + help='Maxmimum number of tracks to scan') + + def run(self, args, config): media_dir = config['local']['media_dir'] scan_timeout = config['local']['scan_timeout'] + flush_threshold = config['local']['scan_flush_threshold'] + excluded_file_extensions = config['local']['excluded_file_extensions'] excluded_file_extensions = set( - ext.lower() for ext in config['local']['excluded_file_extensions']) + file_ext.lower() for file_ext in excluded_file_extensions) - updaters = {} - for e in extensions: - for updater_class in e.get_library_updaters(): - if updater_class and 'local' in updater_class.uri_schemes: - updaters[e.ext_name] = updater_class - - if not updaters: - logger.error('No usable library updaters found.') - return 1 - elif len(updaters) > 1: - logger.error('More than one library updater found. ' - 'Provided by: %s', ', '.join(updaters.keys())) - return 1 - - local_updater = updaters.values()[0](config) + library = _get_library(args, config) uri_path_mapping = {} uris_in_library = set() uris_to_update = set() uris_to_remove = set() - tracks = local_updater.load() - logger.info('Checking %d tracks from library.', len(tracks)) - for track in tracks: + num_tracks = library.load() + logger.info('Checking %d tracks from library.', num_tracks) + + for track in library.begin(): uri_path_mapping[track.uri] = translator.local_track_uri_to_path( track.uri, media_dir) try: @@ -65,16 +92,17 @@ class ScanCommand(commands.Command): logger.info('Removing %d missing tracks.', len(uris_to_remove)) for uri in uris_to_remove: - local_updater.remove(uri) + library.remove(uri) logger.info('Checking %s for unknown tracks.', media_dir) for relpath in path.find_files(media_dir): + uri = translator.path_to_local_track_uri(relpath) file_extension = os.path.splitext(relpath)[1] + if file_extension.lower() in excluded_file_extensions: logger.debug('Skipped %s: File extension excluded.', uri) continue - uri = translator.path_to_local_track_uri(relpath) if uri not in uris_in_library: uris_to_update.add(uri) uri_path_mapping[uri] = os.path.join(media_dir, relpath) @@ -82,36 +110,44 @@ class ScanCommand(commands.Command): logger.info('Found %d unknown tracks.', len(uris_to_update)) logger.info('Scanning...') - scanner = scan.Scanner(scan_timeout) - progress = Progress(len(uris_to_update)) + uris_to_update = sorted(uris_to_update)[:args.limit] - for uri in sorted(uris_to_update): + scanner = scan.Scanner(scan_timeout) + progress = _Progress(flush_threshold, len(uris_to_update)) + + for uri in uris_to_update: try: data = scanner.scan(path.path_to_uri(uri_path_mapping[uri])) track = scan.audio_data_to_track(data).copy(uri=uri) - local_updater.add(track) + library.add(track) logger.debug('Added %s', track.uri) except exceptions.ScannerError as error: logger.warning('Failed %s: %s', uri, error) - progress.increment() + if progress.increment(): + progress.log() + if library.flush(): + logger.debug('Progress flushed.') - logger.info('Commiting changes.') - local_updater.commit() + progress.log() + library.close() + logger.info('Done scanning.') return 0 -# TODO: move to utils? -class Progress(object): - def __init__(self, total): +class _Progress(object): + def __init__(self, batch_size, total): self.count = 0 + self.batch_size = batch_size self.total = total self.start = time.time() def increment(self): self.count += 1 - if self.count % 1000 == 0 or self.count == self.total: - duration = time.time() - self.start - remainder = duration / self.count * (self.total - self.count) - logger.info('Scanned %d of %d files in %ds, ~%ds left.', - self.count, self.total, duration, remainder) + return self.count % self.batch_size == 0 + + def log(self): + duration = time.time() - self.start + remainder = duration / self.count * (self.total - self.count) + logger.info('Scanned %d of %d files in %ds, ~%ds left.', + self.count, self.total, duration, remainder) diff --git a/mopidy/backends/local/ext.conf b/mopidy/backends/local/ext.conf index f906a04f..8f1e860c 100644 --- a/mopidy/backends/local/ext.conf +++ b/mopidy/backends/local/ext.conf @@ -1,8 +1,11 @@ [local] enabled = true +library = json media_dir = $XDG_MUSIC_DIR +data_dir = $XDG_DATA_DIR/mopidy/local playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists scan_timeout = 1000 +scan_flush_threshold = 1000 excluded_file_extensions = .html .jpeg diff --git a/mopidy/backends/local/json.py b/mopidy/backends/local/json.py new file mode 100644 index 00000000..7bccf101 --- /dev/null +++ b/mopidy/backends/local/json.py @@ -0,0 +1,91 @@ +from __future__ import absolute_import, unicode_literals + +import gzip +import json +import logging +import os +import tempfile + +import mopidy +from mopidy import models +from mopidy.backends import local +from mopidy.backends.local import search + +logger = logging.getLogger(__name__) + + +# TODO: move to load and dump in models? +def load_library(json_file): + try: + with gzip.open(json_file, 'rb') as fp: + return json.load(fp, object_hook=models.model_json_decoder) + except (IOError, ValueError) as e: + logger.warning('Loading JSON local library failed: %s', e) + return {} + + +def write_library(json_file, data): + data['version'] = mopidy.__version__ + directory, basename = os.path.split(json_file) + + # TODO: cleanup directory/basename.* files. + tmp = tempfile.NamedTemporaryFile( + prefix=basename + '.', dir=directory, delete=False) + + try: + with gzip.GzipFile(fileobj=tmp, mode='wb') as fp: + json.dump(data, fp, cls=models.ModelJSONEncoder, + indent=2, separators=(',', ': ')) + os.rename(tmp.name, json_file) + finally: + if os.path.exists(tmp.name): + os.remove(tmp.name) + + +class JsonLibrary(local.Library): + name = b'json' + + def __init__(self, config): + self._tracks = {} + self._media_dir = config['local']['media_dir'] + self._json_file = os.path.join( + config['local']['data_dir'], b'library.json.gz') + + def load(self): + logger.debug('Loading json library from %s', self._json_file) + library = load_library(self._json_file) + self._tracks = dict((t.uri, t) for t in library.get('tracks', [])) + return len(self._tracks) + + def lookup(self, uri): + try: + return self._tracks[uri] + except KeyError: + return None + + def search(self, query=None, limit=100, offset=0, uris=None, exact=False): + tracks = self._tracks.values() + # TODO: pass limit and offset into search helpers + if exact: + return search.find_exact(tracks, query=query, uris=uris) + else: + return search.search(tracks, query=query, uris=uris) + + def begin(self): + return self._tracks.itervalues() + + def add(self, track): + self._tracks[track.uri] = track + + def remove(self, uri): + self._tracks.pop(uri, None) + + def close(self): + write_library(self._json_file, {'tracks': self._tracks.values()}) + + def clear(self): + try: + os.remove(self._json_file) + return True + except OSError: + return False diff --git a/mopidy/backends/local/json/__init__.py b/mopidy/backends/local/json/__init__.py deleted file mode 100644 index 031dae51..00000000 --- a/mopidy/backends/local/json/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import unicode_literals - -import os - -import mopidy -from mopidy import config, ext - - -class Extension(ext.Extension): - - dist_name = 'Mopidy-Local-JSON' - ext_name = 'local-json' - version = mopidy.__version__ - - def get_default_config(self): - conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return config.read(conf_file) - - def get_config_schema(self): - schema = super(Extension, self).get_config_schema() - schema['json_file'] = config.Path() - return schema - - def get_backend_classes(self): - from .actor import LocalJsonBackend - return [LocalJsonBackend] - - def get_library_updaters(self): - from .library import LocalJsonLibraryUpdateProvider - return [LocalJsonLibraryUpdateProvider] diff --git a/mopidy/backends/local/json/actor.py b/mopidy/backends/local/json/actor.py deleted file mode 100644 index 4fc46417..00000000 --- a/mopidy/backends/local/json/actor.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import unicode_literals - -import logging -import os - -import pykka - -from mopidy.backends import base -from mopidy.utils import encoding - -from . import library - -logger = logging.getLogger(__name__) - - -class LocalJsonBackend(pykka.ThreadingActor, base.Backend): - def __init__(self, config, audio): - super(LocalJsonBackend, self).__init__() - - self.config = config - self.library = library.LocalJsonLibraryProvider(backend=self) - self.uri_schemes = ['local'] - - if not os.path.exists(config['local-json']['json_file']): - try: - library.write_library(config['local-json']['json_file'], {}) - logger.info('Created empty local JSON library.') - except EnvironmentError as error: - error = encoding.locale_decode(error) - logger.warning('Could not create local library: %s', error) diff --git a/mopidy/backends/local/json/ext.conf b/mopidy/backends/local/json/ext.conf deleted file mode 100644 index db0b784a..00000000 --- a/mopidy/backends/local/json/ext.conf +++ /dev/null @@ -1,3 +0,0 @@ -[local-json] -enabled = true -json_file = $XDG_DATA_DIR/mopidy/local/library.json.gz diff --git a/mopidy/backends/local/json/library.py b/mopidy/backends/local/json/library.py deleted file mode 100644 index 99640543..00000000 --- a/mopidy/backends/local/json/library.py +++ /dev/null @@ -1,108 +0,0 @@ -from __future__ import unicode_literals - -import gzip -import json -import logging -import os -import tempfile - -import mopidy -from mopidy import models -from mopidy.backends import base -from mopidy.backends.local import search - -logger = logging.getLogger(__name__) - - -def load_library(json_file): - try: - with gzip.open(json_file, 'rb') as fp: - return json.load(fp, object_hook=models.model_json_decoder) - except (IOError, ValueError) as e: - logger.warning('Loading JSON local library failed: %s', e) - return {} - - -def write_library(json_file, data): - data['version'] = mopidy.__version__ - directory, basename = os.path.split(json_file) - - # TODO: cleanup directory/basename.* files. - tmp = tempfile.NamedTemporaryFile( - prefix=basename + '.', dir=directory, delete=False) - - try: - with gzip.GzipFile(fileobj=tmp, mode='wb') as fp: - json.dump(data, fp, cls=models.ModelJSONEncoder, - indent=2, separators=(',', ': ')) - os.rename(tmp.name, json_file) - finally: - if os.path.exists(tmp.name): - os.remove(tmp.name) - - -class LocalJsonLibraryProvider(base.BaseLibraryProvider): - def __init__(self, *args, **kwargs): - super(LocalJsonLibraryProvider, self).__init__(*args, **kwargs) - self._uri_mapping = {} - self._media_dir = self.backend.config['local']['media_dir'] - self._json_file = self.backend.config['local-json']['json_file'] - self.refresh() - - def refresh(self, uri=None): - logger.debug( - 'Loading local tracks from %s using %s', - self._media_dir, self._json_file) - - tracks = load_library(self._json_file).get('tracks', []) - uris_to_remove = set(self._uri_mapping) - - for track in tracks: - self._uri_mapping[track.uri] = track - uris_to_remove.discard(track.uri) - - for uri in uris_to_remove: - del self._uri_mapping[uri] - - logger.info( - 'Loaded %d local tracks from %s using %s', - len(tracks), self._media_dir, self._json_file) - - def lookup(self, uri): - try: - return [self._uri_mapping[uri]] - except KeyError: - logger.debug('Failed to lookup %r', uri) - return [] - - def find_exact(self, query=None, uris=None): - tracks = self._uri_mapping.values() - return search.find_exact(tracks, query=query, uris=uris) - - def search(self, query=None, uris=None): - tracks = self._uri_mapping.values() - return search.search(tracks, query=query, uris=uris) - - -class LocalJsonLibraryUpdateProvider(base.BaseLibraryProvider): - uri_schemes = ['local'] - - def __init__(self, config): - self._tracks = {} - self._media_dir = config['local']['media_dir'] - self._json_file = config['local-json']['json_file'] - - def load(self): - for track in load_library(self._json_file).get('tracks', []): - self._tracks[track.uri] = track - return self._tracks.values() - - def add(self, track): - self._tracks[track.uri] = track - - def remove(self, uri): - if uri in self._tracks: - del self._tracks[uri] - - def commit(self): - write_library(self._json_file, {'tracks': self._tracks.values()}) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py new file mode 100644 index 00000000..c83bec20 --- /dev/null +++ b/mopidy/backends/local/library.py @@ -0,0 +1,42 @@ +from __future__ import unicode_literals + +import logging + +from mopidy.backends import base + +logger = logging.getLogger(__name__) + + +class LocalLibraryProvider(base.BaseLibraryProvider): + """Proxy library that delegates work to our active local library.""" + + def __init__(self, backend, library): + super(LocalLibraryProvider, self).__init__(backend) + self._library = library + self.refresh() + + def refresh(self, uri=None): + if not self._library: + return 0 + num_tracks = self._library.load() + logger.info('Loaded %d local tracks using %s', + num_tracks, self._library.name) + + def lookup(self, uri): + if not self._library: + return [] + track = self._library.lookup(uri) + if track is None: + logger.debug('Failed to lookup %r', uri) + return [] + return [track] + + def find_exact(self, query=None, uris=None): + if not self._library: + return None + return self._library.search(query=query, uris=uris, exact=True) + + def search(self, query=None, uris=None): + if not self._library: + return None + return self._library.search(query=query, uris=uris, exact=False) diff --git a/mopidy/commands.py b/mopidy/commands.py index 46989c9c..e73f9373 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -257,22 +257,26 @@ class RootCommand(Command): type=config_override_type, metavar='OPTIONS', help='`section/key=value` values to override config options') - def run(self, args, config, extensions): + def run(self, args, config): loop = gobject.MainLoop() + + backend_classes = args.registry['backend'] + frontend_classes = args.registry['frontend'] + try: audio = self.start_audio(config) - backends = self.start_backends(config, extensions, audio) + backends = self.start_backends(config, backend_classes, audio) core = self.start_core(audio, backends) - self.start_frontends(config, extensions, core) + self.start_frontends(config, frontend_classes, core) loop.run() except KeyboardInterrupt: logger.info('Interrupted. Exiting...') return finally: loop.quit() - self.stop_frontends(extensions) + self.stop_frontends(frontend_classes) self.stop_core() - self.stop_backends(extensions) + self.stop_backends(backend_classes) self.stop_audio() process.stop_remaining_actors() @@ -280,11 +284,7 @@ class RootCommand(Command): logger.info('Starting Mopidy audio') return Audio.start(config=config).proxy() - def start_backends(self, config, extensions, audio): - backend_classes = [] - for extension in extensions: - backend_classes.extend(extension.get_backend_classes()) - + def start_backends(self, config, backend_classes, audio): logger.info( 'Starting Mopidy backends: %s', ', '.join(b.__name__ for b in backend_classes) or 'none') @@ -300,11 +300,7 @@ class RootCommand(Command): logger.info('Starting Mopidy core') return Core.start(audio=audio, backends=backends).proxy() - def start_frontends(self, config, extensions, core): - frontend_classes = [] - for extension in extensions: - frontend_classes.extend(extension.get_frontend_classes()) - + def start_frontends(self, config, frontend_classes, core): logger.info( 'Starting Mopidy frontends: %s', ', '.join(f.__name__ for f in frontend_classes) or 'none') @@ -312,21 +308,19 @@ class RootCommand(Command): for frontend_class in frontend_classes: frontend_class.start(config=config, core=core) - def stop_frontends(self, extensions): + def stop_frontends(self, frontend_classes): logger.info('Stopping Mopidy frontends') - for extension in extensions: - for frontend_class in extension.get_frontend_classes(): - process.stop_actors_by_class(frontend_class) + for frontend_class in frontend_classes: + process.stop_actors_by_class(frontend_class) def stop_core(self): logger.info('Stopping Mopidy core') process.stop_actors_by_class(Core) - def stop_backends(self, extensions): + def stop_backends(self, backend_classes): logger.info('Stopping Mopidy backends') - for extension in extensions: - for backend_class in extension.get_backend_classes(): - process.stop_actors_by_class(backend_class) + for backend_class in backend_classes: + process.stop_actors_by_class(backend_class) def stop_audio(self): logger.info('Stopping Mopidy audio') diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 4924cca2..dba8d76d 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -91,25 +91,24 @@ class Backends(list): self.with_playback = collections.OrderedDict() self.with_playlists = collections.OrderedDict() + backends_by_scheme = {} + name = lambda backend: backend.actor_ref.actor_class.__name__ + for backend in backends: has_library = backend.has_library().get() has_playback = backend.has_playback().get() has_playlists = backend.has_playlists().get() for scheme in backend.uri_schemes.get(): - self.add(self.with_library, has_library, scheme, backend) - self.add(self.with_playback, has_playback, scheme, backend) - self.add(self.with_playlists, has_playlists, scheme, backend) + assert scheme not in backends_by_scheme, ( + 'Cannot add URI scheme %s for %s, ' + 'it is already handled by %s' + ) % (scheme, name(backend), name(backends_by_scheme[scheme])) + backends_by_scheme[scheme] = backend - def add(self, registry, supported, uri_scheme, backend): - if not supported: - return - - if uri_scheme not in registry: - registry[uri_scheme] = backend - return - - get_name = lambda actor: actor.actor_ref.actor_class.__name__ - raise AssertionError( - 'Cannot add URI scheme %s for %s, it is already handled by %s' % - (uri_scheme, get_name(backend), get_name(registry[uri_scheme]))) + if has_library: + self.with_library[scheme] = backend + if has_playback: + self.with_playback[scheme] = backend + if has_playlists: + self.with_playlists[scheme] = backend diff --git a/mopidy/ext.py b/mopidy/ext.py index 33b9497d..b29523a7 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import collections import logging import pkg_resources @@ -61,6 +62,15 @@ class Extension(object): """ pass + def setup(self, registry): + for backend_class in self.get_backend_classes(): + registry.add('backend', backend_class) + + for frontend_class in self.get_frontend_classes(): + registry.add('frontend', frontend_class) + + self.register_gstreamer_elements() + def get_frontend_classes(self): """List of frontend actor classes @@ -79,6 +89,7 @@ class Extension(object): """ return [] + # TODO: remove def get_library_updaters(self): """List of library updater classes @@ -112,6 +123,24 @@ class Extension(object): pass +# TODO: document +class Registry(collections.Mapping): + def __init__(self): + self._registry = {} + + def add(self, name, cls): + self._registry.setdefault(name, []).append(cls) + + def __getitem__(self, name): + return self._registry.setdefault(name, []) + + def __iter__(self): + return iter(self._registry) + + def __len__(self): + return len(self._registry) + + def load_extensions(): """Find all installed extensions. @@ -166,15 +195,3 @@ def validate_extension(extension): return False return True - - -def register_gstreamer_elements(enabled_extensions): - """Registers custom GStreamer elements from extensions. - - :param enabled_extensions: list of enabled extensions - """ - - for extension in enabled_extensions: - logger.debug( - 'Registering GStreamer elements for: %s', extension.ext_name) - extension.register_gstreamer_elements() diff --git a/setup.py b/setup.py index b6857d4e..607496b7 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,6 @@ setup( 'mopidy.ext': [ 'http = mopidy.http:Extension [http]', 'local = mopidy.backends.local:Extension', - 'local-json = mopidy.backends.local.json:Extension', 'mpd = mopidy.mpd:Extension', 'stream = mopidy.backends.stream:Extension', ], diff --git a/tests/backends/local/events_test.py b/tests/backends/local/events_test.py index 1e26a68c..967d4cdb 100644 --- a/tests/backends/local/events_test.py +++ b/tests/backends/local/events_test.py @@ -17,7 +17,9 @@ class LocalBackendEventsTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), + 'data_dir': path_to_data_dir(''), 'playlists_dir': b'', + 'library': 'json', } } diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index e4c00570..4ca5abf0 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -1,13 +1,14 @@ from __future__ import unicode_literals -import copy +import os +import shutil import tempfile import unittest import pykka from mopidy import core -from mopidy.backends.local.json import actor +from mopidy.backends.local import actor, json from mopidy.models import Track, Album, Artist from tests import path_to_data_dir @@ -61,21 +62,22 @@ class LocalLibraryProviderTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), + 'data_dir': path_to_data_dir(''), 'playlists_dir': b'', - }, - 'local-json': { - 'json_file': path_to_data_dir('library.json.gz'), + 'library': 'json', }, } def setUp(self): - self.backend = actor.LocalJsonBackend.start( + actor.LocalBackend.libraries = [json.JsonLibrary] + self.backend = actor.LocalBackend.start( config=self.config, audio=None).proxy() self.core = core.Core(backends=[self.backend]) self.library = self.core.library def tearDown(self): pykka.ActorRegistry.stop_all() + actor.LocalBackend.libraries = [] def test_refresh(self): self.library.refresh() @@ -88,28 +90,30 @@ class LocalLibraryProviderTest(unittest.TestCase): # Verifies that https://github.com/mopidy/mopidy/issues/500 # has been fixed. - with tempfile.NamedTemporaryFile() as library: - with open(self.config['local-json']['json_file']) as fh: - library.write(fh.read()) - library.flush() + tmpdir = tempfile.mkdtemp() + try: + tmplib = os.path.join(tmpdir, 'library.json.gz') + shutil.copy(path_to_data_dir('library.json.gz'), tmplib) - config = copy.deepcopy(self.config) - config['local-json']['json_file'] = library.name - backend = actor.LocalJsonBackend(config=config, audio=None) + config = {'local': self.config['local'].copy()} + config['local']['data_dir'] = tmpdir + backend = actor.LocalBackend(config=config, audio=None) # Sanity check that value is in the library result = backend.library.lookup(self.tracks[0].uri) self.assertEqual(result, self.tracks[0:1]) - # Clear library and refresh - library.seek(0) - library.truncate() + # Clear and refresh. + open(tmplib, 'w').close() backend.library.refresh() # Now it should be gone. result = backend.library.lookup(self.tracks[0].uri) self.assertEqual(result, []) + finally: + shutil.rmtree(tmpdir) + def test_lookup(self): tracks = self.library.lookup(self.tracks[0].uri) self.assertEqual(tracks, self.tracks[0:1]) diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 4c3dd70d..7d48cfea 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -22,7 +22,9 @@ class LocalPlaybackProviderTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), + 'data_dir': path_to_data_dir(''), 'playlists_dir': b'', + 'library': 'json', } } diff --git a/tests/backends/local/playlists_test.py b/tests/backends/local/playlists_test.py index c02e1d23..6c602282 100644 --- a/tests/backends/local/playlists_test.py +++ b/tests/backends/local/playlists_test.py @@ -20,6 +20,8 @@ class LocalPlaylistsProviderTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), + 'data_dir': path_to_data_dir(''), + 'library': 'json', } } diff --git a/tests/backends/local/tracklist_test.py b/tests/backends/local/tracklist_test.py index c7cfe51f..28def50c 100644 --- a/tests/backends/local/tracklist_test.py +++ b/tests/backends/local/tracklist_test.py @@ -18,7 +18,9 @@ class LocalTracklistProviderTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), + 'data_dir': path_to_data_dir(''), 'playlists_dir': b'', + 'library': 'json', } } tracks = [ diff --git a/tests/core/actor_test.py b/tests/core/actor_test.py index e9e5f396..4a808cad 100644 --- a/tests/core/actor_test.py +++ b/tests/core/actor_test.py @@ -13,9 +13,11 @@ class CoreActorTest(unittest.TestCase): def setUp(self): self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] + self.backend1.actor_ref.actor_class.__name__ = b'B1' self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2'] + self.backend2.actor_ref.actor_class.__name__ = b'B2' self.core = Core(audio=None, backends=[self.backend1, self.backend2]) @@ -29,32 +31,12 @@ class CoreActorTest(unittest.TestCase): self.assertIn('dummy2', result) def test_backends_with_colliding_uri_schemes_fails(self): - self.backend1.actor_ref.actor_class.__name__ = b'B1' - self.backend2.actor_ref.actor_class.__name__ = b'B2' self.backend2.uri_schemes.get.return_value = ['dummy1', 'dummy2'] + self.assertRaisesRegexp( AssertionError, 'Cannot add URI scheme dummy1 for B2, it is already handled by B1', Core, audio=None, backends=[self.backend1, self.backend2]) - def test_backends_with_colliding_uri_schemes_passes(self): - """ - Checks that backends with overlapping schemes, but distinct sub parts - provided can co-exist. - """ - - self.backend1.has_library().get.return_value = False - self.backend1.has_playlists().get.return_value = False - - self.backend2.uri_schemes.get.return_value = ['dummy1'] - self.backend2.has_playback().get.return_value = False - self.backend2.has_playlists().get.return_value = False - - core = Core(audio=None, backends=[self.backend1, self.backend2]) - self.assertEqual(core.backends.with_playback, - {'dummy1': self.backend1}) - self.assertEqual(core.backends.with_library, - {'dummy1': self.backend2}) - def test_version(self): self.assertEqual(self.core.version, versioning.get_version())