Merge pull request #617 from adamcik/feature/extension-registry
Switch to registry for most of mopidy extension hooks
This commit is contained in:
commit
28cf3228b2
@ -20,6 +20,12 @@ v0.18.0 (UNRELEASED)
|
|||||||
- Add :class:`mopidy.models.Ref` class for use as a lightweight reference to
|
- 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.
|
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**
|
**Pluggable local libraries**
|
||||||
|
|
||||||
Fixes issues :issue:`44`, partially resolves :issue:`397`, and causes
|
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
|
- Added support for deprecated config values in order to allow for
|
||||||
graceful removal of :confval:`local/tag_cache_file`.
|
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**
|
**Streaming backend**
|
||||||
|
|
||||||
- Live lookup of URI metadata has been added. (Fixes :issue:`540`)
|
- Live lookup of URI metadata has been added. (Fixes :issue:`540`)
|
||||||
|
|||||||
@ -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.
|
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.
|
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
|
.. cmdoption:: local scan
|
||||||
|
|
||||||
Scan local media files present in your library.
|
Scan local media files present in your library.
|
||||||
|
|||||||
@ -29,10 +29,20 @@ Configuration values
|
|||||||
|
|
||||||
If the local extension should be enabled or not.
|
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
|
.. confval:: local/media_dir
|
||||||
|
|
||||||
Path to directory with local media files.
|
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
|
.. confval:: local/playlists_dir
|
||||||
|
|
||||||
Path to playlists directory with m3u files for local media.
|
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
|
Number of milliseconds before giving up scanning a file and moving on to
|
||||||
the next file.
|
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
|
.. confval:: local/excluded_file_extensions
|
||||||
|
|
||||||
File extensions to exclude when scanning the media directory. Values
|
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
|
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
|
party one. When running :command:`mopidy local scan` mopidy will populate
|
||||||
whatever the current active library is with data. Only one library may be
|
whatever the current active library is with data. Only one library may be
|
||||||
active at a time.
|
active at a time.
|
||||||
|
|
||||||
|
To create a new library provider you must create class that implements the
|
||||||
*****************
|
:class:`~mopidy.backends.local.Libary` interface and install it in the
|
||||||
Mopidy-Local-JSON
|
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.
|
||||||
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.
|
|
||||||
|
|||||||
@ -40,11 +40,13 @@ def main():
|
|||||||
signal.signal(signal.SIGUSR1, pykka.debug.log_thread_tracebacks)
|
signal.signal(signal.SIGUSR1, pykka.debug.log_thread_tracebacks)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
registry = ext.Registry()
|
||||||
|
|
||||||
root_cmd = commands.RootCommand()
|
root_cmd = commands.RootCommand()
|
||||||
config_cmd = commands.ConfigCommand()
|
config_cmd = commands.ConfigCommand()
|
||||||
deps_cmd = commands.DepsCommand()
|
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('config', config_cmd)
|
||||||
root_cmd.add_child('deps', deps_cmd)
|
root_cmd.add_child('deps', deps_cmd)
|
||||||
|
|
||||||
@ -84,7 +86,6 @@ def main():
|
|||||||
enabled_extensions.append(extension)
|
enabled_extensions.append(extension)
|
||||||
|
|
||||||
log_extension_info(installed_extensions, enabled_extensions)
|
log_extension_info(installed_extensions, enabled_extensions)
|
||||||
ext.register_gstreamer_elements(enabled_extensions)
|
|
||||||
|
|
||||||
# Config and deps commands are simply special cased for now.
|
# Config and deps commands are simply special cased for now.
|
||||||
if args.command == config_cmd:
|
if args.command == config_cmd:
|
||||||
@ -108,10 +109,13 @@ def main():
|
|||||||
args.extension.ext_name)
|
args.extension.ext_name)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
for extension in enabled_extensions:
|
||||||
|
extension.setup(registry)
|
||||||
|
|
||||||
# Anything that wants to exit after this point must use
|
# Anything that wants to exit after this point must use
|
||||||
# mopidy.utils.process.exit_process as actors can have been started.
|
# mopidy.utils.process.exit_process as actors can have been started.
|
||||||
try:
|
try:
|
||||||
return args.command.run(args, proxied_config, enabled_extensions)
|
return args.command.run(args, proxied_config)
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
print(root_cmd.format_help())
|
print(root_cmd.format_help())
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@ -181,6 +181,12 @@ def audio_data_to_track(data):
|
|||||||
track_kwargs['uri'] = data['uri']
|
track_kwargs['uri'] = data['uri']
|
||||||
track_kwargs['album'] = Album(**album_kwargs)
|
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
|
if ('name' in artist_kwargs
|
||||||
and not isinstance(artist_kwargs['name'], basestring)):
|
and not isinstance(artist_kwargs['name'], basestring)):
|
||||||
track_kwargs['artists'] = [Artist(name=artist)
|
track_kwargs['artists'] = [Artist(name=artist)
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import os
|
|||||||
|
|
||||||
import mopidy
|
import mopidy
|
||||||
from mopidy import config, ext
|
from mopidy import config, ext
|
||||||
from mopidy.utils import encoding, path
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -22,25 +21,136 @@ class Extension(ext.Extension):
|
|||||||
|
|
||||||
def get_config_schema(self):
|
def get_config_schema(self):
|
||||||
schema = super(Extension, self).get_config_schema()
|
schema = super(Extension, self).get_config_schema()
|
||||||
|
schema['library'] = config.String()
|
||||||
schema['media_dir'] = config.Path()
|
schema['media_dir'] = config.Path()
|
||||||
|
schema['data_dir'] = config.Path()
|
||||||
schema['playlists_dir'] = config.Path()
|
schema['playlists_dir'] = config.Path()
|
||||||
schema['tag_cache_file'] = config.Deprecated()
|
schema['tag_cache_file'] = config.Deprecated()
|
||||||
schema['scan_timeout'] = config.Integer(
|
schema['scan_timeout'] = config.Integer(
|
||||||
minimum=1000, maximum=1000*60*60)
|
minimum=1000, maximum=1000*60*60)
|
||||||
|
schema['scan_flush_threshold'] = config.Integer(minimum=0)
|
||||||
schema['excluded_file_extensions'] = config.List(optional=True)
|
schema['excluded_file_extensions'] = config.List(optional=True)
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
def validate_environment(self):
|
def setup(self, registry):
|
||||||
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):
|
|
||||||
from .actor import LocalBackend
|
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):
|
def get_command(self):
|
||||||
from .commands import LocalCommand
|
from .commands import LocalCommand
|
||||||
return 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
|
||||||
|
|||||||
@ -8,13 +8,17 @@ import pykka
|
|||||||
from mopidy.backends import base
|
from mopidy.backends import base
|
||||||
from mopidy.utils import encoding, path
|
from mopidy.utils import encoding, path
|
||||||
|
|
||||||
from .playlists import LocalPlaylistsProvider
|
from .library import LocalLibraryProvider
|
||||||
from .playback import LocalPlaybackProvider
|
from .playback import LocalPlaybackProvider
|
||||||
|
from .playlists import LocalPlaylistsProvider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class LocalBackend(pykka.ThreadingActor, base.Backend):
|
class LocalBackend(pykka.ThreadingActor, base.Backend):
|
||||||
|
uri_schemes = ['local']
|
||||||
|
libraries = []
|
||||||
|
|
||||||
def __init__(self, config, audio):
|
def __init__(self, config, audio):
|
||||||
super(LocalBackend, self).__init__()
|
super(LocalBackend, self).__init__()
|
||||||
|
|
||||||
@ -22,16 +26,33 @@ class LocalBackend(pykka.ThreadingActor, base.Backend):
|
|||||||
|
|
||||||
self.check_dirs_and_files()
|
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.playback = LocalPlaybackProvider(audio=audio, backend=self)
|
||||||
self.playlists = LocalPlaylistsProvider(backend=self)
|
self.playlists = LocalPlaylistsProvider(backend=self)
|
||||||
|
self.library = LocalLibraryProvider(backend=self, library=library)
|
||||||
self.uri_schemes = ['local']
|
|
||||||
|
|
||||||
def check_dirs_and_files(self):
|
def check_dirs_and_files(self):
|
||||||
if not os.path.isdir(self.config['local']['media_dir']):
|
if not os.path.isdir(self.config['local']['media_dir']):
|
||||||
logger.warning('Local media dir %s does not exist.' %
|
logger.warning('Local media dir %s does not exist.' %
|
||||||
self.config['local']['media_dir'])
|
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:
|
try:
|
||||||
path.get_or_create_dir(self.config['local']['playlists_dir'])
|
path.get_or_create_dir(self.config['local']['playlists_dir'])
|
||||||
except EnvironmentError as error:
|
except EnvironmentError as error:
|
||||||
|
|||||||
@ -13,45 +13,72 @@ from . import translator
|
|||||||
logger = logging.getLogger(__name__)
|
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):
|
class LocalCommand(commands.Command):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super(LocalCommand, self).__init__()
|
super(LocalCommand, self).__init__()
|
||||||
self.add_child('scan', ScanCommand())
|
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):
|
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']
|
media_dir = config['local']['media_dir']
|
||||||
scan_timeout = config['local']['scan_timeout']
|
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(
|
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 = {}
|
library = _get_library(args, config)
|
||||||
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)
|
|
||||||
|
|
||||||
uri_path_mapping = {}
|
uri_path_mapping = {}
|
||||||
uris_in_library = set()
|
uris_in_library = set()
|
||||||
uris_to_update = set()
|
uris_to_update = set()
|
||||||
uris_to_remove = set()
|
uris_to_remove = set()
|
||||||
|
|
||||||
tracks = local_updater.load()
|
num_tracks = library.load()
|
||||||
logger.info('Checking %d tracks from library.', len(tracks))
|
logger.info('Checking %d tracks from library.', num_tracks)
|
||||||
for track in tracks:
|
|
||||||
|
for track in library.begin():
|
||||||
uri_path_mapping[track.uri] = translator.local_track_uri_to_path(
|
uri_path_mapping[track.uri] = translator.local_track_uri_to_path(
|
||||||
track.uri, media_dir)
|
track.uri, media_dir)
|
||||||
try:
|
try:
|
||||||
@ -65,16 +92,17 @@ class ScanCommand(commands.Command):
|
|||||||
|
|
||||||
logger.info('Removing %d missing tracks.', len(uris_to_remove))
|
logger.info('Removing %d missing tracks.', len(uris_to_remove))
|
||||||
for uri in 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)
|
logger.info('Checking %s for unknown tracks.', media_dir)
|
||||||
for relpath in path.find_files(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]
|
file_extension = os.path.splitext(relpath)[1]
|
||||||
|
|
||||||
if file_extension.lower() in excluded_file_extensions:
|
if file_extension.lower() in excluded_file_extensions:
|
||||||
logger.debug('Skipped %s: File extension excluded.', uri)
|
logger.debug('Skipped %s: File extension excluded.', uri)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
uri = translator.path_to_local_track_uri(relpath)
|
|
||||||
if uri not in uris_in_library:
|
if uri not in uris_in_library:
|
||||||
uris_to_update.add(uri)
|
uris_to_update.add(uri)
|
||||||
uri_path_mapping[uri] = os.path.join(media_dir, relpath)
|
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('Found %d unknown tracks.', len(uris_to_update))
|
||||||
logger.info('Scanning...')
|
logger.info('Scanning...')
|
||||||
|
|
||||||
scanner = scan.Scanner(scan_timeout)
|
uris_to_update = sorted(uris_to_update)[:args.limit]
|
||||||
progress = Progress(len(uris_to_update))
|
|
||||||
|
|
||||||
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:
|
try:
|
||||||
data = scanner.scan(path.path_to_uri(uri_path_mapping[uri]))
|
data = scanner.scan(path.path_to_uri(uri_path_mapping[uri]))
|
||||||
track = scan.audio_data_to_track(data).copy(uri=uri)
|
track = scan.audio_data_to_track(data).copy(uri=uri)
|
||||||
local_updater.add(track)
|
library.add(track)
|
||||||
logger.debug('Added %s', track.uri)
|
logger.debug('Added %s', track.uri)
|
||||||
except exceptions.ScannerError as error:
|
except exceptions.ScannerError as error:
|
||||||
logger.warning('Failed %s: %s', uri, 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.')
|
progress.log()
|
||||||
local_updater.commit()
|
library.close()
|
||||||
|
logger.info('Done scanning.')
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
# TODO: move to utils?
|
class _Progress(object):
|
||||||
class Progress(object):
|
def __init__(self, batch_size, total):
|
||||||
def __init__(self, total):
|
|
||||||
self.count = 0
|
self.count = 0
|
||||||
|
self.batch_size = batch_size
|
||||||
self.total = total
|
self.total = total
|
||||||
self.start = time.time()
|
self.start = time.time()
|
||||||
|
|
||||||
def increment(self):
|
def increment(self):
|
||||||
self.count += 1
|
self.count += 1
|
||||||
if self.count % 1000 == 0 or self.count == self.total:
|
return self.count % self.batch_size == 0
|
||||||
duration = time.time() - self.start
|
|
||||||
remainder = duration / self.count * (self.total - self.count)
|
def log(self):
|
||||||
logger.info('Scanned %d of %d files in %ds, ~%ds left.',
|
duration = time.time() - self.start
|
||||||
self.count, self.total, duration, remainder)
|
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)
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
[local]
|
[local]
|
||||||
enabled = true
|
enabled = true
|
||||||
|
library = json
|
||||||
media_dir = $XDG_MUSIC_DIR
|
media_dir = $XDG_MUSIC_DIR
|
||||||
|
data_dir = $XDG_DATA_DIR/mopidy/local
|
||||||
playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists
|
playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists
|
||||||
scan_timeout = 1000
|
scan_timeout = 1000
|
||||||
|
scan_flush_threshold = 1000
|
||||||
excluded_file_extensions =
|
excluded_file_extensions =
|
||||||
.html
|
.html
|
||||||
.jpeg
|
.jpeg
|
||||||
|
|||||||
91
mopidy/backends/local/json.py
Normal file
91
mopidy/backends/local/json.py
Normal file
@ -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
|
||||||
@ -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]
|
|
||||||
@ -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)
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
[local-json]
|
|
||||||
enabled = true
|
|
||||||
json_file = $XDG_DATA_DIR/mopidy/local/library.json.gz
|
|
||||||
@ -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()})
|
|
||||||
42
mopidy/backends/local/library.py
Normal file
42
mopidy/backends/local/library.py
Normal file
@ -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)
|
||||||
@ -257,22 +257,26 @@ class RootCommand(Command):
|
|||||||
type=config_override_type, metavar='OPTIONS',
|
type=config_override_type, metavar='OPTIONS',
|
||||||
help='`section/key=value` values to override config options')
|
help='`section/key=value` values to override config options')
|
||||||
|
|
||||||
def run(self, args, config, extensions):
|
def run(self, args, config):
|
||||||
loop = gobject.MainLoop()
|
loop = gobject.MainLoop()
|
||||||
|
|
||||||
|
backend_classes = args.registry['backend']
|
||||||
|
frontend_classes = args.registry['frontend']
|
||||||
|
|
||||||
try:
|
try:
|
||||||
audio = self.start_audio(config)
|
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)
|
core = self.start_core(audio, backends)
|
||||||
self.start_frontends(config, extensions, core)
|
self.start_frontends(config, frontend_classes, core)
|
||||||
loop.run()
|
loop.run()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logger.info('Interrupted. Exiting...')
|
logger.info('Interrupted. Exiting...')
|
||||||
return
|
return
|
||||||
finally:
|
finally:
|
||||||
loop.quit()
|
loop.quit()
|
||||||
self.stop_frontends(extensions)
|
self.stop_frontends(frontend_classes)
|
||||||
self.stop_core()
|
self.stop_core()
|
||||||
self.stop_backends(extensions)
|
self.stop_backends(backend_classes)
|
||||||
self.stop_audio()
|
self.stop_audio()
|
||||||
process.stop_remaining_actors()
|
process.stop_remaining_actors()
|
||||||
|
|
||||||
@ -280,11 +284,7 @@ class RootCommand(Command):
|
|||||||
logger.info('Starting Mopidy audio')
|
logger.info('Starting Mopidy audio')
|
||||||
return Audio.start(config=config).proxy()
|
return Audio.start(config=config).proxy()
|
||||||
|
|
||||||
def start_backends(self, config, extensions, audio):
|
def start_backends(self, config, backend_classes, audio):
|
||||||
backend_classes = []
|
|
||||||
for extension in extensions:
|
|
||||||
backend_classes.extend(extension.get_backend_classes())
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
'Starting Mopidy backends: %s',
|
'Starting Mopidy backends: %s',
|
||||||
', '.join(b.__name__ for b in backend_classes) or 'none')
|
', '.join(b.__name__ for b in backend_classes) or 'none')
|
||||||
@ -300,11 +300,7 @@ class RootCommand(Command):
|
|||||||
logger.info('Starting Mopidy core')
|
logger.info('Starting Mopidy core')
|
||||||
return Core.start(audio=audio, backends=backends).proxy()
|
return Core.start(audio=audio, backends=backends).proxy()
|
||||||
|
|
||||||
def start_frontends(self, config, extensions, core):
|
def start_frontends(self, config, frontend_classes, core):
|
||||||
frontend_classes = []
|
|
||||||
for extension in extensions:
|
|
||||||
frontend_classes.extend(extension.get_frontend_classes())
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
'Starting Mopidy frontends: %s',
|
'Starting Mopidy frontends: %s',
|
||||||
', '.join(f.__name__ for f in frontend_classes) or 'none')
|
', '.join(f.__name__ for f in frontend_classes) or 'none')
|
||||||
@ -312,21 +308,19 @@ class RootCommand(Command):
|
|||||||
for frontend_class in frontend_classes:
|
for frontend_class in frontend_classes:
|
||||||
frontend_class.start(config=config, core=core)
|
frontend_class.start(config=config, core=core)
|
||||||
|
|
||||||
def stop_frontends(self, extensions):
|
def stop_frontends(self, frontend_classes):
|
||||||
logger.info('Stopping Mopidy frontends')
|
logger.info('Stopping Mopidy frontends')
|
||||||
for extension in extensions:
|
for frontend_class in frontend_classes:
|
||||||
for frontend_class in extension.get_frontend_classes():
|
process.stop_actors_by_class(frontend_class)
|
||||||
process.stop_actors_by_class(frontend_class)
|
|
||||||
|
|
||||||
def stop_core(self):
|
def stop_core(self):
|
||||||
logger.info('Stopping Mopidy core')
|
logger.info('Stopping Mopidy core')
|
||||||
process.stop_actors_by_class(Core)
|
process.stop_actors_by_class(Core)
|
||||||
|
|
||||||
def stop_backends(self, extensions):
|
def stop_backends(self, backend_classes):
|
||||||
logger.info('Stopping Mopidy backends')
|
logger.info('Stopping Mopidy backends')
|
||||||
for extension in extensions:
|
for backend_class in backend_classes:
|
||||||
for backend_class in extension.get_backend_classes():
|
process.stop_actors_by_class(backend_class)
|
||||||
process.stop_actors_by_class(backend_class)
|
|
||||||
|
|
||||||
def stop_audio(self):
|
def stop_audio(self):
|
||||||
logger.info('Stopping Mopidy audio')
|
logger.info('Stopping Mopidy audio')
|
||||||
|
|||||||
@ -91,25 +91,24 @@ class Backends(list):
|
|||||||
self.with_playback = collections.OrderedDict()
|
self.with_playback = collections.OrderedDict()
|
||||||
self.with_playlists = collections.OrderedDict()
|
self.with_playlists = collections.OrderedDict()
|
||||||
|
|
||||||
|
backends_by_scheme = {}
|
||||||
|
name = lambda backend: backend.actor_ref.actor_class.__name__
|
||||||
|
|
||||||
for backend in backends:
|
for backend in backends:
|
||||||
has_library = backend.has_library().get()
|
has_library = backend.has_library().get()
|
||||||
has_playback = backend.has_playback().get()
|
has_playback = backend.has_playback().get()
|
||||||
has_playlists = backend.has_playlists().get()
|
has_playlists = backend.has_playlists().get()
|
||||||
|
|
||||||
for scheme in backend.uri_schemes.get():
|
for scheme in backend.uri_schemes.get():
|
||||||
self.add(self.with_library, has_library, scheme, backend)
|
assert scheme not in backends_by_scheme, (
|
||||||
self.add(self.with_playback, has_playback, scheme, backend)
|
'Cannot add URI scheme %s for %s, '
|
||||||
self.add(self.with_playlists, has_playlists, scheme, backend)
|
'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 has_library:
|
||||||
if not supported:
|
self.with_library[scheme] = backend
|
||||||
return
|
if has_playback:
|
||||||
|
self.with_playback[scheme] = backend
|
||||||
if uri_scheme not in registry:
|
if has_playlists:
|
||||||
registry[uri_scheme] = backend
|
self.with_playlists[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])))
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import collections
|
||||||
import logging
|
import logging
|
||||||
import pkg_resources
|
import pkg_resources
|
||||||
|
|
||||||
@ -61,6 +62,15 @@ class Extension(object):
|
|||||||
"""
|
"""
|
||||||
pass
|
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):
|
def get_frontend_classes(self):
|
||||||
"""List of frontend actor classes
|
"""List of frontend actor classes
|
||||||
|
|
||||||
@ -79,6 +89,7 @@ class Extension(object):
|
|||||||
"""
|
"""
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# TODO: remove
|
||||||
def get_library_updaters(self):
|
def get_library_updaters(self):
|
||||||
"""List of library updater classes
|
"""List of library updater classes
|
||||||
|
|
||||||
@ -112,6 +123,24 @@ class Extension(object):
|
|||||||
pass
|
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():
|
def load_extensions():
|
||||||
"""Find all installed extensions.
|
"""Find all installed extensions.
|
||||||
|
|
||||||
@ -166,15 +195,3 @@ def validate_extension(extension):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
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()
|
|
||||||
|
|||||||
1
setup.py
1
setup.py
@ -43,7 +43,6 @@ setup(
|
|||||||
'mopidy.ext': [
|
'mopidy.ext': [
|
||||||
'http = mopidy.http:Extension [http]',
|
'http = mopidy.http:Extension [http]',
|
||||||
'local = mopidy.backends.local:Extension',
|
'local = mopidy.backends.local:Extension',
|
||||||
'local-json = mopidy.backends.local.json:Extension',
|
|
||||||
'mpd = mopidy.mpd:Extension',
|
'mpd = mopidy.mpd:Extension',
|
||||||
'stream = mopidy.backends.stream:Extension',
|
'stream = mopidy.backends.stream:Extension',
|
||||||
],
|
],
|
||||||
|
|||||||
@ -17,7 +17,9 @@ class LocalBackendEventsTest(unittest.TestCase):
|
|||||||
config = {
|
config = {
|
||||||
'local': {
|
'local': {
|
||||||
'media_dir': path_to_data_dir(''),
|
'media_dir': path_to_data_dir(''),
|
||||||
|
'data_dir': path_to_data_dir(''),
|
||||||
'playlists_dir': b'',
|
'playlists_dir': b'',
|
||||||
|
'library': 'json',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import copy
|
import os
|
||||||
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import pykka
|
import pykka
|
||||||
|
|
||||||
from mopidy import core
|
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 mopidy.models import Track, Album, Artist
|
||||||
|
|
||||||
from tests import path_to_data_dir
|
from tests import path_to_data_dir
|
||||||
@ -61,21 +62,22 @@ class LocalLibraryProviderTest(unittest.TestCase):
|
|||||||
config = {
|
config = {
|
||||||
'local': {
|
'local': {
|
||||||
'media_dir': path_to_data_dir(''),
|
'media_dir': path_to_data_dir(''),
|
||||||
|
'data_dir': path_to_data_dir(''),
|
||||||
'playlists_dir': b'',
|
'playlists_dir': b'',
|
||||||
},
|
'library': 'json',
|
||||||
'local-json': {
|
|
||||||
'json_file': path_to_data_dir('library.json.gz'),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.backend = actor.LocalJsonBackend.start(
|
actor.LocalBackend.libraries = [json.JsonLibrary]
|
||||||
|
self.backend = actor.LocalBackend.start(
|
||||||
config=self.config, audio=None).proxy()
|
config=self.config, audio=None).proxy()
|
||||||
self.core = core.Core(backends=[self.backend])
|
self.core = core.Core(backends=[self.backend])
|
||||||
self.library = self.core.library
|
self.library = self.core.library
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
pykka.ActorRegistry.stop_all()
|
pykka.ActorRegistry.stop_all()
|
||||||
|
actor.LocalBackend.libraries = []
|
||||||
|
|
||||||
def test_refresh(self):
|
def test_refresh(self):
|
||||||
self.library.refresh()
|
self.library.refresh()
|
||||||
@ -88,28 +90,30 @@ class LocalLibraryProviderTest(unittest.TestCase):
|
|||||||
# Verifies that https://github.com/mopidy/mopidy/issues/500
|
# Verifies that https://github.com/mopidy/mopidy/issues/500
|
||||||
# has been fixed.
|
# has been fixed.
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile() as library:
|
tmpdir = tempfile.mkdtemp()
|
||||||
with open(self.config['local-json']['json_file']) as fh:
|
try:
|
||||||
library.write(fh.read())
|
tmplib = os.path.join(tmpdir, 'library.json.gz')
|
||||||
library.flush()
|
shutil.copy(path_to_data_dir('library.json.gz'), tmplib)
|
||||||
|
|
||||||
config = copy.deepcopy(self.config)
|
config = {'local': self.config['local'].copy()}
|
||||||
config['local-json']['json_file'] = library.name
|
config['local']['data_dir'] = tmpdir
|
||||||
backend = actor.LocalJsonBackend(config=config, audio=None)
|
backend = actor.LocalBackend(config=config, audio=None)
|
||||||
|
|
||||||
# Sanity check that value is in the library
|
# Sanity check that value is in the library
|
||||||
result = backend.library.lookup(self.tracks[0].uri)
|
result = backend.library.lookup(self.tracks[0].uri)
|
||||||
self.assertEqual(result, self.tracks[0:1])
|
self.assertEqual(result, self.tracks[0:1])
|
||||||
|
|
||||||
# Clear library and refresh
|
# Clear and refresh.
|
||||||
library.seek(0)
|
open(tmplib, 'w').close()
|
||||||
library.truncate()
|
|
||||||
backend.library.refresh()
|
backend.library.refresh()
|
||||||
|
|
||||||
# Now it should be gone.
|
# Now it should be gone.
|
||||||
result = backend.library.lookup(self.tracks[0].uri)
|
result = backend.library.lookup(self.tracks[0].uri)
|
||||||
self.assertEqual(result, [])
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmpdir)
|
||||||
|
|
||||||
def test_lookup(self):
|
def test_lookup(self):
|
||||||
tracks = self.library.lookup(self.tracks[0].uri)
|
tracks = self.library.lookup(self.tracks[0].uri)
|
||||||
self.assertEqual(tracks, self.tracks[0:1])
|
self.assertEqual(tracks, self.tracks[0:1])
|
||||||
|
|||||||
@ -22,7 +22,9 @@ class LocalPlaybackProviderTest(unittest.TestCase):
|
|||||||
config = {
|
config = {
|
||||||
'local': {
|
'local': {
|
||||||
'media_dir': path_to_data_dir(''),
|
'media_dir': path_to_data_dir(''),
|
||||||
|
'data_dir': path_to_data_dir(''),
|
||||||
'playlists_dir': b'',
|
'playlists_dir': b'',
|
||||||
|
'library': 'json',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,8 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
|
|||||||
config = {
|
config = {
|
||||||
'local': {
|
'local': {
|
||||||
'media_dir': path_to_data_dir(''),
|
'media_dir': path_to_data_dir(''),
|
||||||
|
'data_dir': path_to_data_dir(''),
|
||||||
|
'library': 'json',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,9 @@ class LocalTracklistProviderTest(unittest.TestCase):
|
|||||||
config = {
|
config = {
|
||||||
'local': {
|
'local': {
|
||||||
'media_dir': path_to_data_dir(''),
|
'media_dir': path_to_data_dir(''),
|
||||||
|
'data_dir': path_to_data_dir(''),
|
||||||
'playlists_dir': b'',
|
'playlists_dir': b'',
|
||||||
|
'library': 'json',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tracks = [
|
tracks = [
|
||||||
|
|||||||
@ -13,9 +13,11 @@ class CoreActorTest(unittest.TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.backend1 = mock.Mock()
|
self.backend1 = mock.Mock()
|
||||||
self.backend1.uri_schemes.get.return_value = ['dummy1']
|
self.backend1.uri_schemes.get.return_value = ['dummy1']
|
||||||
|
self.backend1.actor_ref.actor_class.__name__ = b'B1'
|
||||||
|
|
||||||
self.backend2 = mock.Mock()
|
self.backend2 = mock.Mock()
|
||||||
self.backend2.uri_schemes.get.return_value = ['dummy2']
|
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])
|
self.core = Core(audio=None, backends=[self.backend1, self.backend2])
|
||||||
|
|
||||||
@ -29,32 +31,12 @@ class CoreActorTest(unittest.TestCase):
|
|||||||
self.assertIn('dummy2', result)
|
self.assertIn('dummy2', result)
|
||||||
|
|
||||||
def test_backends_with_colliding_uri_schemes_fails(self):
|
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.backend2.uri_schemes.get.return_value = ['dummy1', 'dummy2']
|
||||||
|
|
||||||
self.assertRaisesRegexp(
|
self.assertRaisesRegexp(
|
||||||
AssertionError,
|
AssertionError,
|
||||||
'Cannot add URI scheme dummy1 for B2, it is already handled by B1',
|
'Cannot add URI scheme dummy1 for B2, it is already handled by B1',
|
||||||
Core, audio=None, backends=[self.backend1, self.backend2])
|
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):
|
def test_version(self):
|
||||||
self.assertEqual(self.core.version, versioning.get_version())
|
self.assertEqual(self.core.version, versioning.get_version())
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user