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
|
||||
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`)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
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',
|
||||
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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
1
setup.py
1
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',
|
||||
],
|
||||
|
||||
@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -20,6 +20,8 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
|
||||
config = {
|
||||
'local': {
|
||||
'media_dir': path_to_data_dir(''),
|
||||
'data_dir': path_to_data_dir(''),
|
||||
'library': 'json',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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())
|
||||
|
||||
Loading…
Reference in New Issue
Block a user