Merge branch 'rawdlite/feature/file-browsing' into develop
This commit is contained in:
commit
2b58948f56
@ -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
47
docs/ext/file.rst
Normal 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.
|
||||
@ -96,6 +96,7 @@ Extensions
|
||||
:maxdepth: 2
|
||||
|
||||
ext/local
|
||||
ext/file
|
||||
ext/m3u
|
||||
ext/stream
|
||||
ext/http
|
||||
|
||||
32
mopidy/file/__init__.py
Normal file
32
mopidy/file/__init__.py
Normal 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
22
mopidy/file/backend.py
Normal 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
8
mopidy/file/ext.conf
Normal 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
146
mopidy/file/library.py
Normal 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)
|
||||
@ -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.
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
[stream]
|
||||
enabled = true
|
||||
protocols =
|
||||
file
|
||||
http
|
||||
https
|
||||
mms
|
||||
|
||||
Loading…
Reference in New Issue
Block a user