Merge branch 'rawdlite/feature/file-browsing' into develop

This commit is contained in:
Stein Magnus Jodal 2015-07-09 13:10:36 +02:00
commit 2b58948f56
10 changed files with 273 additions and 13 deletions

View File

@ -65,6 +65,10 @@ https://github.com/tkem/mopidy-dleyna
Provides a backend for playing music from Digital Media Servers using
the `dLeyna <http://01.org/dleyna>`_ D-Bus interface.
Mopidy-File
===========
Bundled with Mopidy. See :ref:`ext-file`.
Mopidy-Grooveshark
==================

47
docs/ext/file.rst Normal file
View File

@ -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.

View File

@ -96,6 +96,7 @@ Extensions
:maxdepth: 2
ext/local
ext/file
ext/m3u
ext/stream
ext/http

32
mopidy/file/__init__.py Normal file
View File

@ -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)

22
mopidy/file/backend.py Normal file
View File

@ -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

8
mopidy/file/ext.conf Normal file
View File

@ -0,0 +1,8 @@
[file]
enabled = true
media_dirs =
$XDG_MUSIC_DIR|Music
~/|Home
show_dotfiles = false
follow_symlinks = false
metadata_timeout = 1000

146
mopidy/file/library.py Normal file
View File

@ -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)

View File

@ -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.

View File

@ -1,7 +1,6 @@
[stream]
enabled = true
protocols =
file
http
https
mms

View File

@ -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',