diff --git a/docs/ext/backends.rst b/docs/ext/backends.rst index 1ab00005..5f578e6f 100644 --- a/docs/ext/backends.rst +++ b/docs/ext/backends.rst @@ -65,6 +65,10 @@ https://github.com/tkem/mopidy-dleyna Provides a backend for playing music from Digital Media Servers using the `dLeyna `_ D-Bus interface. +Mopidy-File +=========== + +Bundled with Mopidy. See :ref:`ext-file`. Mopidy-Grooveshark ================== diff --git a/docs/ext/file.rst b/docs/ext/file.rst new file mode 100644 index 00000000..d31f53fd --- /dev/null +++ b/docs/ext/file.rst @@ -0,0 +1,47 @@ +.. _ext-file: + +************ +Mopidy-File +************ + +Mopidy-File is an extension for playing music from your local music archive. +It is bundled with Mopidy and enabled by default. +It allows you to browse through your local file system. +Only files that are considered playable will be shown. + +This backend handles URIs starting with ``file:``. + + +Configuration +============= + +See :ref:`config` for general help on configuring Mopidy. + +.. literalinclude:: ../../mopidy/file/ext.conf + :language: ini + +.. confval:: file/enabled + + If the file extension should be enabled or not. + +.. confval:: file/media_dirs + + A list of directories to be browsable. + Optionally the path can be followed by ``|`` and a name that will be shown for that path. + +.. confval:: file/show_dotfiles + + Whether to show hidden files and directories that start with a dot. + Default is false. + +.. confval:: file/follow_symlinks + + Whether to follow symbolic links found in :confval:`files/media_dir`. + Directories and files that are outside the configured directories will not be shown. + Default is false. + +.. confval:: file/metadata_timeout + + Number of milliseconds before giving up scanning a file and moving on to + the next file. Reducing the value might speed up the directory listing, + but can lead to some tracks not being shown. diff --git a/docs/index.rst b/docs/index.rst index 3a2998d5..9085024a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -96,6 +96,7 @@ Extensions :maxdepth: 2 ext/local + ext/file ext/m3u ext/stream ext/http diff --git a/mopidy/file/__init__.py b/mopidy/file/__init__.py new file mode 100644 index 00000000..089cf6e6 --- /dev/null +++ b/mopidy/file/__init__.py @@ -0,0 +1,32 @@ +from __future__ import absolute_import, unicode_literals + +import logging +import os + +import mopidy +from mopidy import config, ext + +logger = logging.getLogger(__name__) + + +class Extension(ext.Extension): + + dist_name = 'Mopidy-File' + ext_name = 'file' + 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['media_dirs'] = config.List(optional=True) + schema['show_dotfiles'] = config.Boolean(optional=True) + schema['follow_symlinks'] = config.Boolean(optional=True) + schema['metadata_timeout'] = config.Integer(optional=True) + return schema + + def setup(self, registry): + from .backend import FilesBackend + registry.add('backend', FilesBackend) diff --git a/mopidy/file/backend.py b/mopidy/file/backend.py new file mode 100644 index 00000000..74b029e5 --- /dev/null +++ b/mopidy/file/backend.py @@ -0,0 +1,22 @@ +from __future__ import absolute_import, unicode_literals + +import logging + +import pykka + +from mopidy import backend +from mopidy.file import library + + +logger = logging.getLogger(__name__) + + +class FilesBackend(pykka.ThreadingActor, backend.Backend): + uri_schemes = ['file'] + + def __init__(self, config, audio): + super(FilesBackend, self).__init__() + self.library = library.FilesLibraryProvider(backend=self, + config=config) + self.playback = backend.PlaybackProvider(audio=audio, backend=self) + self.playlists = None diff --git a/mopidy/file/ext.conf b/mopidy/file/ext.conf new file mode 100644 index 00000000..486619a1 --- /dev/null +++ b/mopidy/file/ext.conf @@ -0,0 +1,8 @@ +[file] +enabled = true +media_dirs = + $XDG_MUSIC_DIR|Music + ~/|Home +show_dotfiles = false +follow_symlinks = false +metadata_timeout = 1000 diff --git a/mopidy/file/library.py b/mopidy/file/library.py new file mode 100644 index 00000000..f9c4ad97 --- /dev/null +++ b/mopidy/file/library.py @@ -0,0 +1,146 @@ +from __future__ import unicode_literals + +import logging +import operator +import os +import sys +import urllib2 + +from mopidy import backend, exceptions, models +from mopidy.audio import scan, utils +from mopidy.internal import path + +logger = logging.getLogger(__name__) +FS_ENCODING = sys.getfilesystemencoding() + + +class FilesLibraryProvider(backend.LibraryProvider): + """Library for browsing local files.""" + # TODO: get_images that can pull from metadata and/or .folder.png etc? + # TODO: handle playlists? + + @property + def root_directory(self): + if not self._media_dirs: + return None + elif len(self._media_dirs) == 1: + local_path = self._media_dirs[0]['path'] + uri = path.path_to_uri(local_path) + else: + uri = 'file:root' + return models.Ref.directory(name='Files', uri=uri) + + def __init__(self, backend, config): + super(FilesLibraryProvider, self).__init__(backend) + self._media_dirs = list(self._get_media_dirs(config)) + self._follow_symlinks = config['file']['follow_symlinks'] + self._show_dotfiles = config['file']['show_dotfiles'] + self._scanner = scan.Scanner( + timeout=config['file']['metadata_timeout']) + + def browse(self, uri): + logger.debug('Browsing files at: %s', uri) + result = [] + local_path = path.uri_to_path(uri) + if local_path == 'root': + return list(self._get_media_dirs_refs()) + if not self._is_in_basedir(os.path.realpath(local_path)): + logger.warning( + 'Rejected attempt to browse path (%s) outside dirs defined ' + 'in file/media_dirs config.', + local_path.decode(FS_ENCODING, 'replace')) + return [] + for dir_entry in os.listdir(local_path): + child_path = os.path.join(local_path, dir_entry) + uri = path.path_to_uri(child_path) + printable_path = child_path.decode(FS_ENCODING, + 'replace') + + if os.path.islink(child_path) and not self._follow_symlinks: + logger.debug('Ignoring symlink: %s', printable_path) + continue + + if not self._is_in_basedir(os.path.realpath(child_path)): + logger.debug('Ignoring symlink to outside base dir: %s', + printable_path) + continue + + if not self._show_dotfiles and dir_entry.startswith(b'.'): + continue + + dir_entry = dir_entry.decode(FS_ENCODING, + 'replace') + if os.path.isdir(child_path): + result.append(models.Ref.directory(name=dir_entry, uri=uri)) + elif os.path.isfile(child_path): + if self._is_audiofile(uri): + result.append(models.Ref.track(name=dir_entry, uri=uri)) + else: + logger.debug('Ignoring non-audiofile: %s', printable_path) + + result.sort(key=operator.attrgetter('name')) + return result + + def lookup(self, uri): + logger.debug('Looking up file URI: %s', uri) + local_path = path.uri_to_path(uri) + if not self._is_in_basedir(local_path): + logger.warning('Ignoring URI outside base dir: %s', local_path) + return [] + try: + result = self._scanner.scan(uri) + track = utils.convert_tags_to_track(result.tags).copy( + uri=uri, length=result.duration) + except exceptions.ScannerError as e: + logger.warning('Failed looking up %s: %s', uri, e) + track = models.Track(uri=uri) + if not track.name: + filename = os.path.basename(local_path) + name = urllib2.unquote(filename).decode( + FS_ENCODING, 'replace') + track = track.copy(name=name) + return [track] + + def _get_media_dirs(self, config): + for entry in config['file']['media_dirs']: + media_dir = {} + media_dir_split = entry.split('|', 1) + local_path = path.expand_path( + media_dir_split[0].encode(FS_ENCODING)) + if not local_path: + logger.warning('Failed expanding path (%s) from' + 'file/media_dirs config value.', + media_dir_split[0]) + continue + elif not os.path.isdir(local_path): + logger.warning('%s is not a directory', local_path) + continue + media_dir['path'] = local_path + if len(media_dir_split) == 2: + media_dir['name'] = media_dir_split[1] + else: + # TODO Mpd client should accept / in dir name + media_dir['name'] = media_dir_split[0].replace(os.sep, '+') + yield media_dir + + def _get_media_dirs_refs(self): + for media_dir in self._media_dirs: + yield models.Ref.directory( + name=media_dir['name'], + uri=path.path_to_uri(media_dir['path'])) + + def _is_audiofile(self, uri): + try: + result = self._scanner.scan(uri) + logger.debug( + 'Scan indicates that file %s is %s.', + result.uri, result.playable and 'playable' or 'unplayable') + return result.playable + except exceptions.ScannerError as e: + logger.debug("Failed scanning %s: %s", uri, e) + return False + + def _is_in_basedir(self, local_path): + return any( + path.is_path_inside_base_dir(local_path, media_dir['path']) + for media_dir in self._media_dirs) diff --git a/mopidy/internal/path.py b/mopidy/internal/path.py index 3a41d930..f56520f0 100644 --- a/mopidy/internal/path.py +++ b/mopidy/internal/path.py @@ -196,23 +196,23 @@ def find_mtimes(root, follow=False): return mtimes, errors -def check_file_path_is_inside_base_dir(file_path, base_path): - assert not file_path.endswith(os.sep), ( - 'File path %s cannot end with a path separator' % file_path) - +def is_path_inside_base_dir(path, base_path): + if path.endswith(os.sep): + raise ValueError('Path %s cannot end with a path separator' + % path) # Expand symlinks real_base_path = os.path.realpath(base_path) - real_file_path = os.path.realpath(file_path) + real_path = os.path.realpath(path) - # Use dir of file for prefix comparision, so we don't accept - # /tmp/foo.m3u as being inside /tmp/foo, simply because they have a - # common prefix, /tmp/foo, which matches the base path, /tmp/foo. - real_dir_path = os.path.dirname(real_file_path) + if os.path.isfile(path): + # Use dir of file for prefix comparision, so we don't accept + # /tmp/foo.m3u as being inside /tmp/foo, simply because they have a + # common prefix, /tmp/foo, which matches the base path, /tmp/foo. + real_path = os.path.dirname(real_path) # Check if dir of file is the base path or a subdir - common_prefix = os.path.commonprefix([real_base_path, real_dir_path]) - assert common_prefix == real_base_path, ( - 'File path %s must be in %s' % (real_file_path, real_base_path)) + common_prefix = os.path.commonprefix([real_base_path, real_path]) + return common_prefix == real_base_path # FIXME replace with mock usage in tests. diff --git a/mopidy/stream/ext.conf b/mopidy/stream/ext.conf index cedb3085..928ccc63 100644 --- a/mopidy/stream/ext.conf +++ b/mopidy/stream/ext.conf @@ -1,7 +1,6 @@ [stream] enabled = true protocols = - file http https mms diff --git a/setup.py b/setup.py index 9f33236f..ca121f74 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,7 @@ setup( 'mopidy.ext': [ 'http = mopidy.http:Extension', 'local = mopidy.local:Extension', + 'file = mopidy.file:Extension', 'm3u = mopidy.m3u:Extension', 'mpd = mopidy.mpd:Extension', 'softwaremixer = mopidy.softwaremixer:Extension',