parent
8977f71411
commit
b2f60bc338
@ -126,6 +126,11 @@ v1.0.0 (UNRELEASED)
|
|||||||
|
|
||||||
- Sort local playlists by name. (Fixes: :issue:`1026`, PR: :issue:`1028`)
|
- Sort local playlists by name. (Fixes: :issue:`1026`, PR: :issue:`1028`)
|
||||||
|
|
||||||
|
- Moved playlist support out to a new extension, :ref:`ext-m3u`.
|
||||||
|
|
||||||
|
- *Deprecated:* The config value :confval:`local/playlists_dir` is no longer in
|
||||||
|
use and can be removed from your config.
|
||||||
|
|
||||||
**Local library API**
|
**Local library API**
|
||||||
|
|
||||||
- Implementors of :meth:`mopidy.local.Library.lookup` should now return a list
|
- Implementors of :meth:`mopidy.local.Library.lookup` should now return a list
|
||||||
@ -139,6 +144,12 @@ v1.0.0 (UNRELEASED)
|
|||||||
- Add :meth:`mopidy.local.Library.get_images` for looking up images
|
- Add :meth:`mopidy.local.Library.get_images` for looking up images
|
||||||
for local URIs. (Fixes: :issue:`1031`, PR: :issue:`1032` and :issue:`1037`)
|
for local URIs. (Fixes: :issue:`1031`, PR: :issue:`1032` and :issue:`1037`)
|
||||||
|
|
||||||
|
**M3U backend**
|
||||||
|
|
||||||
|
- Split the M3U playlist handling out of the local backend. See
|
||||||
|
:ref:`m3u-migration` for how to migrate your local playlists. (Fixes:
|
||||||
|
:issue:`1054`, PR: :issue:`1066`)
|
||||||
|
|
||||||
**MPD frontend**
|
**MPD frontend**
|
||||||
|
|
||||||
- In stored playlist names, replace "/", which are illegal, with "|" instead of
|
- In stored playlist names, replace "/", which are illegal, with "|" instead of
|
||||||
|
|||||||
55
docs/ext/m3u.rst
Normal file
55
docs/ext/m3u.rst
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
.. _ext-m3u:
|
||||||
|
|
||||||
|
**********
|
||||||
|
Mopidy-M3U
|
||||||
|
**********
|
||||||
|
|
||||||
|
Mopidy-M3U is an extension for reading and writing M3U playlists stored
|
||||||
|
on disk. It is bundled with Mopidy and enabled by default.
|
||||||
|
|
||||||
|
This backend handles URIs starting with ``m3u:``.
|
||||||
|
|
||||||
|
|
||||||
|
.. _m3u-migration:
|
||||||
|
|
||||||
|
Migrating from Mopidy-Local playlists
|
||||||
|
=====================================
|
||||||
|
|
||||||
|
Mopidy-M3U was split out of the Mopidy-Local extension in Mopidy 1.0. To
|
||||||
|
migrate your playlists from Mopidy-Local, simply move them from the
|
||||||
|
:confval:`local/playlists_dir` directory to the :confval:`m3u/playlists_dir`
|
||||||
|
directory. Assuming you have not changed the default config, run the following
|
||||||
|
commands to migrate::
|
||||||
|
|
||||||
|
mkdir -p ~/.local/share/mopidy/m3u/
|
||||||
|
mv ~/.local/share/mopidy/local/playlists/* ~/.local/share/mopidy/m3u/
|
||||||
|
|
||||||
|
|
||||||
|
Editing playlists
|
||||||
|
=================
|
||||||
|
|
||||||
|
There is a core playlist API in place for editing playlists. This is supported
|
||||||
|
by a few Mopidy clients, but not through Mopidy's MPD server yet.
|
||||||
|
|
||||||
|
It is possible to edit playlists by editing the M3U files located in the
|
||||||
|
:confval:`m3u/playlists_dir` directory, usually
|
||||||
|
:file:`~/.local/share/mopidy/m3u/`, by hand with a text editor. See `Wikipedia
|
||||||
|
<https://en.wikipedia.org/wiki/M3U>`__ for a short description of the quite
|
||||||
|
simple M3U playlist format.
|
||||||
|
|
||||||
|
|
||||||
|
Configuration
|
||||||
|
=============
|
||||||
|
|
||||||
|
See :ref:`config` for general help on configuring Mopidy.
|
||||||
|
|
||||||
|
.. literalinclude:: ../../mopidy/m3u/ext.conf
|
||||||
|
:language: ini
|
||||||
|
|
||||||
|
.. confval:: m3u/enabled
|
||||||
|
|
||||||
|
If the M3U extension should be enabled or not.
|
||||||
|
|
||||||
|
.. confval:: m3u/playlists_dir
|
||||||
|
|
||||||
|
Path to directory with M3U files.
|
||||||
@ -94,6 +94,7 @@ Extensions
|
|||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
|
|
||||||
ext/local
|
ext/local
|
||||||
|
ext/m3u
|
||||||
ext/stream
|
ext/stream
|
||||||
ext/http
|
ext/http
|
||||||
ext/mpd
|
ext/mpd
|
||||||
|
|||||||
@ -24,7 +24,7 @@ class Extension(ext.Extension):
|
|||||||
schema['library'] = config.String()
|
schema['library'] = config.String()
|
||||||
schema['media_dir'] = config.Path()
|
schema['media_dir'] = config.Path()
|
||||||
schema['data_dir'] = config.Path()
|
schema['data_dir'] = config.Path()
|
||||||
schema['playlists_dir'] = config.Path()
|
schema['playlists_dir'] = config.Deprecated()
|
||||||
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)
|
||||||
|
|||||||
@ -8,7 +8,6 @@ from mopidy import backend
|
|||||||
from mopidy.local import storage
|
from mopidy.local import storage
|
||||||
from mopidy.local.library import LocalLibraryProvider
|
from mopidy.local.library import LocalLibraryProvider
|
||||||
from mopidy.local.playback import LocalPlaybackProvider
|
from mopidy.local.playback import LocalPlaybackProvider
|
||||||
from mopidy.local.playlists import LocalPlaylistsProvider
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -36,5 +35,4 @@ class LocalBackend(pykka.ThreadingActor, backend.Backend):
|
|||||||
logger.warning('Local library %s not found', library_name)
|
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.library = LocalLibraryProvider(backend=self, library=library)
|
self.library = LocalLibraryProvider(backend=self, library=library)
|
||||||
|
|||||||
@ -3,7 +3,6 @@ enabled = true
|
|||||||
library = json
|
library = json
|
||||||
media_dir = $XDG_MUSIC_DIR
|
media_dir = $XDG_MUSIC_DIR
|
||||||
data_dir = $XDG_DATA_DIR/mopidy/local
|
data_dir = $XDG_DATA_DIR/mopidy/local
|
||||||
playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists
|
|
||||||
scan_timeout = 1000
|
scan_timeout = 1000
|
||||||
scan_flush_threshold = 1000
|
scan_flush_threshold = 1000
|
||||||
scan_follow_symlinks = false
|
scan_follow_symlinks = false
|
||||||
|
|||||||
@ -20,11 +20,3 @@ def check_dirs_and_files(config):
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
'Could not create local data dir: %s',
|
'Could not create local data dir: %s',
|
||||||
encoding.locale_decode(error))
|
encoding.locale_decode(error))
|
||||||
|
|
||||||
# TODO: replace with data dir?
|
|
||||||
try:
|
|
||||||
path.get_or_create_dir(config['local']['playlists_dir'])
|
|
||||||
except EnvironmentError as error:
|
|
||||||
logger.warning(
|
|
||||||
'Could not create local playlists dir: %s',
|
|
||||||
encoding.locale_decode(error))
|
|
||||||
|
|||||||
@ -2,18 +2,12 @@ from __future__ import absolute_import, unicode_literals
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import urllib
|
import urllib
|
||||||
import urlparse
|
|
||||||
|
|
||||||
from mopidy import compat
|
from mopidy import compat
|
||||||
from mopidy.models import Track
|
|
||||||
from mopidy.utils.encoding import locale_decode
|
|
||||||
from mopidy.utils.path import path_to_uri, uri_to_path
|
from mopidy.utils.path import path_to_uri, uri_to_path
|
||||||
|
|
||||||
|
|
||||||
M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)')
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -28,13 +22,6 @@ def local_track_uri_to_path(uri, media_dir):
|
|||||||
return os.path.join(media_dir, file_path)
|
return os.path.join(media_dir, file_path)
|
||||||
|
|
||||||
|
|
||||||
def local_playlist_uri_to_path(uri, playlists_dir):
|
|
||||||
if not uri.startswith('local:playlist:'):
|
|
||||||
raise ValueError('Invalid URI %s' % uri)
|
|
||||||
file_path = uri_to_path(uri).split(b':', 1)[1]
|
|
||||||
return os.path.join(playlists_dir, file_path)
|
|
||||||
|
|
||||||
|
|
||||||
def path_to_local_track_uri(relpath):
|
def path_to_local_track_uri(relpath):
|
||||||
"""Convert path relative to media_dir to local track URI."""
|
"""Convert path relative to media_dir to local track URI."""
|
||||||
if isinstance(relpath, compat.text_type):
|
if isinstance(relpath, compat.text_type):
|
||||||
@ -47,89 +34,3 @@ def path_to_local_directory_uri(relpath):
|
|||||||
if isinstance(relpath, compat.text_type):
|
if isinstance(relpath, compat.text_type):
|
||||||
relpath = relpath.encode('utf-8')
|
relpath = relpath.encode('utf-8')
|
||||||
return b'local:directory:%s' % urllib.quote(relpath)
|
return b'local:directory:%s' % urllib.quote(relpath)
|
||||||
|
|
||||||
|
|
||||||
def path_to_local_playlist_uri(relpath):
|
|
||||||
"""Convert path relative to playlists_dir to local playlist URI."""
|
|
||||||
if isinstance(relpath, compat.text_type):
|
|
||||||
relpath = relpath.encode('utf-8')
|
|
||||||
return b'local:playlist:%s' % urllib.quote(relpath)
|
|
||||||
|
|
||||||
|
|
||||||
def m3u_extinf_to_track(line):
|
|
||||||
"""Convert extended M3U directive to track template."""
|
|
||||||
m = M3U_EXTINF_RE.match(line)
|
|
||||||
if not m:
|
|
||||||
logger.warning('Invalid extended M3U directive: %s', line)
|
|
||||||
return Track()
|
|
||||||
(runtime, title) = m.groups()
|
|
||||||
if int(runtime) > 0:
|
|
||||||
return Track(name=title, length=1000 * int(runtime))
|
|
||||||
else:
|
|
||||||
return Track(name=title)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_m3u(file_path, media_dir):
|
|
||||||
r"""
|
|
||||||
Convert M3U file list to list of tracks
|
|
||||||
|
|
||||||
Example M3U data::
|
|
||||||
|
|
||||||
# This is a comment
|
|
||||||
Alternative\Band - Song.mp3
|
|
||||||
Classical\Other Band - New Song.mp3
|
|
||||||
Stuff.mp3
|
|
||||||
D:\More Music\Foo.mp3
|
|
||||||
http://www.example.com:8000/Listen.pls
|
|
||||||
http://www.example.com/~user/Mine.mp3
|
|
||||||
|
|
||||||
Example extended M3U data::
|
|
||||||
|
|
||||||
#EXTM3U
|
|
||||||
#EXTINF:123, Sample artist - Sample title
|
|
||||||
Sample.mp3
|
|
||||||
#EXTINF:321,Example Artist - Example title
|
|
||||||
Greatest Hits\Example.ogg
|
|
||||||
#EXTINF:-1,Radio XMP
|
|
||||||
http://mp3stream.example.com:8000/
|
|
||||||
|
|
||||||
- Relative paths of songs should be with respect to location of M3U.
|
|
||||||
- Paths are normally platform specific.
|
|
||||||
- Lines starting with # are ignored, except for extended M3U directives.
|
|
||||||
- Track.name and Track.length are set from extended M3U directives.
|
|
||||||
- m3u files are latin-1.
|
|
||||||
"""
|
|
||||||
# TODO: uris as bytes
|
|
||||||
tracks = []
|
|
||||||
try:
|
|
||||||
with open(file_path) as m3u:
|
|
||||||
contents = m3u.readlines()
|
|
||||||
except IOError as error:
|
|
||||||
logger.warning('Couldn\'t open m3u: %s', locale_decode(error))
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
if not contents:
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
extended = contents[0].decode('latin1').startswith('#EXTM3U')
|
|
||||||
|
|
||||||
track = Track()
|
|
||||||
for line in contents:
|
|
||||||
line = line.strip().decode('latin1')
|
|
||||||
|
|
||||||
if line.startswith('#'):
|
|
||||||
if extended and line.startswith('#EXTINF'):
|
|
||||||
track = m3u_extinf_to_track(line)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if urlparse.urlsplit(line).scheme:
|
|
||||||
tracks.append(track.copy(uri=line))
|
|
||||||
elif os.path.normpath(line) == os.path.abspath(line):
|
|
||||||
path = path_to_uri(line)
|
|
||||||
tracks.append(track.copy(uri=path))
|
|
||||||
else:
|
|
||||||
path = path_to_uri(os.path.join(media_dir, line))
|
|
||||||
tracks.append(track.copy(uri=path))
|
|
||||||
|
|
||||||
track = Track()
|
|
||||||
return tracks
|
|
||||||
|
|||||||
30
mopidy/m3u/__init__.py
Normal file
30
mopidy/m3u/__init__.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
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-M3U'
|
||||||
|
ext_name = 'm3u'
|
||||||
|
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['playlists_dir'] = config.Path()
|
||||||
|
return schema
|
||||||
|
|
||||||
|
def setup(self, registry):
|
||||||
|
from .actor import M3UBackend
|
||||||
|
|
||||||
|
registry.add('backend', M3UBackend)
|
||||||
32
mopidy/m3u/actor.py
Normal file
32
mopidy/m3u/actor.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pykka
|
||||||
|
|
||||||
|
from mopidy import backend
|
||||||
|
from mopidy.m3u.library import M3ULibraryProvider
|
||||||
|
from mopidy.m3u.playlists import M3UPlaylistsProvider
|
||||||
|
from mopidy.utils import encoding, path
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class M3UBackend(pykka.ThreadingActor, backend.Backend):
|
||||||
|
uri_schemes = ['m3u']
|
||||||
|
|
||||||
|
def __init__(self, config, audio):
|
||||||
|
super(M3UBackend, self).__init__()
|
||||||
|
|
||||||
|
self._config = config
|
||||||
|
|
||||||
|
try:
|
||||||
|
path.get_or_create_dir(config['m3u']['playlists_dir'])
|
||||||
|
except EnvironmentError as error:
|
||||||
|
logger.warning(
|
||||||
|
'Could not create M3U playlists dir: %s',
|
||||||
|
encoding.locale_decode(error))
|
||||||
|
|
||||||
|
self.playlists = M3UPlaylistsProvider(backend=self)
|
||||||
|
self.library = M3ULibraryProvider(backend=self)
|
||||||
3
mopidy/m3u/ext.conf
Normal file
3
mopidy/m3u/ext.conf
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[m3u]
|
||||||
|
enabled = true
|
||||||
|
playlists_dir = $XDG_DATA_DIR/mopidy/m3u
|
||||||
18
mopidy/m3u/library.py
Normal file
18
mopidy/m3u/library.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mopidy import backend
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class M3ULibraryProvider(backend.LibraryProvider):
|
||||||
|
"""Library for looking up M3U playlists."""
|
||||||
|
|
||||||
|
def __init__(self, backend):
|
||||||
|
super(M3ULibraryProvider, self).__init__(backend)
|
||||||
|
|
||||||
|
def lookup(self, uri):
|
||||||
|
# TODO Lookup tracks in M3U playlist
|
||||||
|
return []
|
||||||
@ -8,19 +8,18 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from mopidy import backend
|
from mopidy import backend
|
||||||
|
from mopidy.m3u import translator
|
||||||
from mopidy.models import Playlist
|
from mopidy.models import Playlist
|
||||||
|
|
||||||
from .translator import local_playlist_uri_to_path, path_to_local_playlist_uri
|
|
||||||
from .translator import parse_m3u
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class LocalPlaylistsProvider(backend.PlaylistsProvider):
|
class M3UPlaylistsProvider(backend.PlaylistsProvider):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(LocalPlaylistsProvider, self).__init__(*args, **kwargs)
|
super(M3UPlaylistsProvider, self).__init__(*args, **kwargs)
|
||||||
self._media_dir = self.backend.config['local']['media_dir']
|
|
||||||
self._playlists_dir = self.backend.config['local']['playlists_dir']
|
self._playlists_dir = self.backend._config['m3u']['playlists_dir']
|
||||||
self._playlists = []
|
self._playlists = []
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
@ -49,7 +48,7 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider):
|
|||||||
if not playlist:
|
if not playlist:
|
||||||
logger.warn('Trying to delete unknown playlist %s', uri)
|
logger.warn('Trying to delete unknown playlist %s', uri)
|
||||||
return
|
return
|
||||||
path = local_playlist_uri_to_path(uri, self._playlists_dir)
|
path = translator.playlist_uri_to_path(uri, self._playlists_dir)
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
else:
|
else:
|
||||||
@ -70,10 +69,10 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider):
|
|||||||
for path in glob.glob(os.path.join(self._playlists_dir, b'*.m3u')):
|
for path in glob.glob(os.path.join(self._playlists_dir, b'*.m3u')):
|
||||||
relpath = os.path.basename(path)
|
relpath = os.path.basename(path)
|
||||||
name = os.path.splitext(relpath)[0].decode(encoding)
|
name = os.path.splitext(relpath)[0].decode(encoding)
|
||||||
uri = path_to_local_playlist_uri(relpath)
|
uri = translator.path_to_playlist_uri(relpath)
|
||||||
|
|
||||||
tracks = []
|
tracks = []
|
||||||
for track in parse_m3u(path, self._media_dir):
|
for track in translator.parse_m3u(path):
|
||||||
tracks.append(track)
|
tracks.append(track)
|
||||||
|
|
||||||
playlist = Playlist(uri=uri, name=name, tracks=tracks)
|
playlist = Playlist(uri=uri, name=name, tracks=tracks)
|
||||||
@ -82,7 +81,7 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider):
|
|||||||
self.playlists = sorted(playlists, key=operator.attrgetter('name'))
|
self.playlists = sorted(playlists, key=operator.attrgetter('name'))
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
'Loaded %d local playlists from %s',
|
'Loaded %d M3U playlists from %s',
|
||||||
len(playlists), self._playlists_dir)
|
len(playlists), self._playlists_dir)
|
||||||
|
|
||||||
def save(self, playlist):
|
def save(self, playlist):
|
||||||
@ -99,7 +98,7 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider):
|
|||||||
|
|
||||||
playlist = self._save_m3u(playlist)
|
playlist = self._save_m3u(playlist)
|
||||||
if index >= 0 and uri != playlist.uri:
|
if index >= 0 and uri != playlist.uri:
|
||||||
path = local_playlist_uri_to_path(uri, self._playlists_dir)
|
path = translator.playlist_uri_to_path(uri, self._playlists_dir)
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
else:
|
else:
|
||||||
@ -125,11 +124,12 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider):
|
|||||||
def _save_m3u(self, playlist, encoding=sys.getfilesystemencoding()):
|
def _save_m3u(self, playlist, encoding=sys.getfilesystemencoding()):
|
||||||
if playlist.name:
|
if playlist.name:
|
||||||
name = self._sanitize_m3u_name(playlist.name, encoding)
|
name = self._sanitize_m3u_name(playlist.name, encoding)
|
||||||
uri = path_to_local_playlist_uri(name.encode(encoding) + b'.m3u')
|
uri = translator.path_to_playlist_uri(
|
||||||
path = local_playlist_uri_to_path(uri, self._playlists_dir)
|
name.encode(encoding) + b'.m3u')
|
||||||
|
path = translator.playlist_uri_to_path(uri, self._playlists_dir)
|
||||||
elif playlist.uri:
|
elif playlist.uri:
|
||||||
uri = playlist.uri
|
uri = playlist.uri
|
||||||
path = local_playlist_uri_to_path(uri, self._playlists_dir)
|
path = translator.playlist_uri_to_path(uri, self._playlists_dir)
|
||||||
name, _ = os.path.splitext(os.path.basename(path).decode(encoding))
|
name, _ = os.path.splitext(os.path.basename(path).decode(encoding))
|
||||||
else:
|
else:
|
||||||
raise ValueError('M3U playlist needs name or URI')
|
raise ValueError('M3U playlist needs name or URI')
|
||||||
110
mopidy/m3u/translator.py
Normal file
110
mopidy/m3u/translator.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import urllib
|
||||||
|
import urlparse
|
||||||
|
|
||||||
|
from mopidy import compat
|
||||||
|
from mopidy.models import Track
|
||||||
|
from mopidy.utils.encoding import locale_decode
|
||||||
|
from mopidy.utils.path import path_to_uri, uri_to_path
|
||||||
|
|
||||||
|
|
||||||
|
M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)')
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def playlist_uri_to_path(uri, playlists_dir):
|
||||||
|
if not uri.startswith('m3u:'):
|
||||||
|
raise ValueError('Invalid URI %s' % uri)
|
||||||
|
file_path = uri_to_path(uri)
|
||||||
|
return os.path.join(playlists_dir, file_path)
|
||||||
|
|
||||||
|
|
||||||
|
def path_to_playlist_uri(relpath):
|
||||||
|
"""Convert path relative to playlists_dir to M3U URI."""
|
||||||
|
if isinstance(relpath, compat.text_type):
|
||||||
|
relpath = relpath.encode('utf-8')
|
||||||
|
return b'm3u:%s' % urllib.quote(relpath)
|
||||||
|
|
||||||
|
|
||||||
|
def m3u_extinf_to_track(line):
|
||||||
|
"""Convert extended M3U directive to track template."""
|
||||||
|
m = M3U_EXTINF_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
logger.warning('Invalid extended M3U directive: %s', line)
|
||||||
|
return Track()
|
||||||
|
(runtime, title) = m.groups()
|
||||||
|
if int(runtime) > 0:
|
||||||
|
return Track(name=title, length=1000 * int(runtime))
|
||||||
|
else:
|
||||||
|
return Track(name=title)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_m3u(file_path, media_dir=None):
|
||||||
|
r"""
|
||||||
|
Convert M3U file list to list of tracks
|
||||||
|
|
||||||
|
Example M3U data::
|
||||||
|
|
||||||
|
# This is a comment
|
||||||
|
Alternative\Band - Song.mp3
|
||||||
|
Classical\Other Band - New Song.mp3
|
||||||
|
Stuff.mp3
|
||||||
|
D:\More Music\Foo.mp3
|
||||||
|
http://www.example.com:8000/Listen.pls
|
||||||
|
http://www.example.com/~user/Mine.mp3
|
||||||
|
|
||||||
|
Example extended M3U data::
|
||||||
|
|
||||||
|
#EXTM3U
|
||||||
|
#EXTINF:123, Sample artist - Sample title
|
||||||
|
Sample.mp3
|
||||||
|
#EXTINF:321,Example Artist - Example title
|
||||||
|
Greatest Hits\Example.ogg
|
||||||
|
#EXTINF:-1,Radio XMP
|
||||||
|
http://mp3stream.example.com:8000/
|
||||||
|
|
||||||
|
- Relative paths of songs should be with respect to location of M3U.
|
||||||
|
- Paths are normally platform specific.
|
||||||
|
- Lines starting with # are ignored, except for extended M3U directives.
|
||||||
|
- Track.name and Track.length are set from extended M3U directives.
|
||||||
|
- m3u files are latin-1.
|
||||||
|
"""
|
||||||
|
# TODO: uris as bytes
|
||||||
|
tracks = []
|
||||||
|
try:
|
||||||
|
with open(file_path) as m3u:
|
||||||
|
contents = m3u.readlines()
|
||||||
|
except IOError as error:
|
||||||
|
logger.warning('Couldn\'t open m3u: %s', locale_decode(error))
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
if not contents:
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
extended = contents[0].decode('latin1').startswith('#EXTM3U')
|
||||||
|
|
||||||
|
track = Track()
|
||||||
|
for line in contents:
|
||||||
|
line = line.strip().decode('latin1')
|
||||||
|
|
||||||
|
if line.startswith('#'):
|
||||||
|
if extended and line.startswith('#EXTINF'):
|
||||||
|
track = m3u_extinf_to_track(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if urlparse.urlsplit(line).scheme:
|
||||||
|
tracks.append(track.copy(uri=line))
|
||||||
|
elif os.path.normpath(line) == os.path.abspath(line):
|
||||||
|
path = path_to_uri(line)
|
||||||
|
tracks.append(track.copy(uri=path))
|
||||||
|
elif media_dir is not None:
|
||||||
|
path = path_to_uri(os.path.join(media_dir, line))
|
||||||
|
tracks.append(track.copy(uri=path))
|
||||||
|
|
||||||
|
track = Track()
|
||||||
|
return tracks
|
||||||
1
setup.py
1
setup.py
@ -36,6 +36,7 @@ setup(
|
|||||||
'mopidy.ext': [
|
'mopidy.ext': [
|
||||||
'http = mopidy.http:Extension',
|
'http = mopidy.http:Extension',
|
||||||
'local = mopidy.local:Extension',
|
'local = mopidy.local:Extension',
|
||||||
|
'm3u = mopidy.m3u:Extension',
|
||||||
'mpd = mopidy.mpd:Extension',
|
'mpd = mopidy.mpd:Extension',
|
||||||
'softwaremixer = mopidy.softwaremixer:Extension',
|
'softwaremixer = mopidy.softwaremixer:Extension',
|
||||||
'stream = mopidy.stream:Extension',
|
'stream = mopidy.stream:Extension',
|
||||||
|
|||||||
5
tests/m3u/__init__.py
Normal file
5
tests/m3u/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
|
|
||||||
|
def generate_song(i):
|
||||||
|
return 'dummy:track:song%s' % i
|
||||||
@ -8,30 +8,28 @@ import unittest
|
|||||||
import pykka
|
import pykka
|
||||||
|
|
||||||
from mopidy import core
|
from mopidy import core
|
||||||
from mopidy.local import actor
|
from mopidy.m3u import actor
|
||||||
from mopidy.local.translator import local_playlist_uri_to_path
|
from mopidy.m3u.translator import playlist_uri_to_path
|
||||||
from mopidy.models import Playlist, Track
|
from mopidy.models import Playlist, Track
|
||||||
|
|
||||||
from tests import dummy_audio, path_to_data_dir
|
from tests import dummy_audio, path_to_data_dir
|
||||||
from tests.local import generate_song
|
from tests.m3u import generate_song
|
||||||
|
|
||||||
|
|
||||||
class LocalPlaylistsProviderTest(unittest.TestCase):
|
class M3UPlaylistsProviderTest(unittest.TestCase):
|
||||||
backend_class = actor.LocalBackend
|
backend_class = actor.M3UBackend
|
||||||
config = {
|
config = {
|
||||||
'local': {
|
'm3u': {
|
||||||
'media_dir': path_to_data_dir(''),
|
'playlists_dir': path_to_data_dir(''),
|
||||||
'data_dir': path_to_data_dir(''),
|
|
||||||
'library': 'json',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def setUp(self): # noqa: N802
|
def setUp(self): # noqa: N802
|
||||||
self.config['local']['playlists_dir'] = tempfile.mkdtemp()
|
self.config['m3u']['playlists_dir'] = tempfile.mkdtemp()
|
||||||
self.playlists_dir = self.config['local']['playlists_dir']
|
self.playlists_dir = self.config['m3u']['playlists_dir']
|
||||||
|
|
||||||
self.audio = dummy_audio.create_proxy()
|
self.audio = dummy_audio.create_proxy()
|
||||||
self.backend = actor.LocalBackend.start(
|
self.backend = actor.M3UBackend.start(
|
||||||
config=self.config, audio=self.audio).proxy()
|
config=self.config, audio=self.audio).proxy()
|
||||||
self.core = core.Core(backends=[self.backend])
|
self.core = core.Core(backends=[self.backend])
|
||||||
|
|
||||||
@ -42,8 +40,8 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
|
|||||||
shutil.rmtree(self.playlists_dir)
|
shutil.rmtree(self.playlists_dir)
|
||||||
|
|
||||||
def test_created_playlist_is_persisted(self):
|
def test_created_playlist_is_persisted(self):
|
||||||
uri = 'local:playlist:test.m3u'
|
uri = 'm3u:test.m3u'
|
||||||
path = local_playlist_uri_to_path(uri, self.playlists_dir)
|
path = playlist_uri_to_path(uri, self.playlists_dir)
|
||||||
self.assertFalse(os.path.exists(path))
|
self.assertFalse(os.path.exists(path))
|
||||||
|
|
||||||
playlist = self.core.playlists.create('test')
|
playlist = self.core.playlists.create('test')
|
||||||
@ -54,16 +52,16 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
|
|||||||
def test_create_sanitizes_playlist_name(self):
|
def test_create_sanitizes_playlist_name(self):
|
||||||
playlist = self.core.playlists.create('../../test FOO baR')
|
playlist = self.core.playlists.create('../../test FOO baR')
|
||||||
self.assertEqual('test FOO baR', playlist.name)
|
self.assertEqual('test FOO baR', playlist.name)
|
||||||
path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir)
|
path = playlist_uri_to_path(playlist.uri, self.playlists_dir)
|
||||||
self.assertEqual(self.playlists_dir, os.path.dirname(path))
|
self.assertEqual(self.playlists_dir, os.path.dirname(path))
|
||||||
self.assertTrue(os.path.exists(path))
|
self.assertTrue(os.path.exists(path))
|
||||||
|
|
||||||
def test_saved_playlist_is_persisted(self):
|
def test_saved_playlist_is_persisted(self):
|
||||||
uri1 = 'local:playlist:test1.m3u'
|
uri1 = 'm3u:test1.m3u'
|
||||||
uri2 = 'local:playlist:test2.m3u'
|
uri2 = 'm3u:test2.m3u'
|
||||||
|
|
||||||
path1 = local_playlist_uri_to_path(uri1, self.playlists_dir)
|
path1 = playlist_uri_to_path(uri1, self.playlists_dir)
|
||||||
path2 = local_playlist_uri_to_path(uri2, self.playlists_dir)
|
path2 = playlist_uri_to_path(uri2, self.playlists_dir)
|
||||||
|
|
||||||
playlist = self.core.playlists.create('test1')
|
playlist = self.core.playlists.create('test1')
|
||||||
self.assertEqual('test1', playlist.name)
|
self.assertEqual('test1', playlist.name)
|
||||||
@ -78,8 +76,8 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
|
|||||||
self.assertTrue(os.path.exists(path2))
|
self.assertTrue(os.path.exists(path2))
|
||||||
|
|
||||||
def test_deleted_playlist_is_removed(self):
|
def test_deleted_playlist_is_removed(self):
|
||||||
uri = 'local:playlist:test.m3u'
|
uri = 'm3u:test.m3u'
|
||||||
path = local_playlist_uri_to_path(uri, self.playlists_dir)
|
path = playlist_uri_to_path(uri, self.playlists_dir)
|
||||||
|
|
||||||
self.assertFalse(os.path.exists(path))
|
self.assertFalse(os.path.exists(path))
|
||||||
|
|
||||||
@ -95,7 +93,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
|
|||||||
track = Track(uri=generate_song(1))
|
track = Track(uri=generate_song(1))
|
||||||
playlist = self.core.playlists.create('test')
|
playlist = self.core.playlists.create('test')
|
||||||
playlist = self.core.playlists.save(playlist.copy(tracks=[track]))
|
playlist = self.core.playlists.save(playlist.copy(tracks=[track]))
|
||||||
path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir)
|
path = playlist_uri_to_path(playlist.uri, self.playlists_dir)
|
||||||
|
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
contents = f.read()
|
contents = f.read()
|
||||||
@ -106,7 +104,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
|
|||||||
track = Track(uri=generate_song(1), name='Test', length=60000)
|
track = Track(uri=generate_song(1), name='Test', length=60000)
|
||||||
playlist = self.core.playlists.create('test')
|
playlist = self.core.playlists.create('test')
|
||||||
playlist = self.core.playlists.save(playlist.copy(tracks=[track]))
|
playlist = self.core.playlists.save(playlist.copy(tracks=[track]))
|
||||||
path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir)
|
path = playlist_uri_to_path(playlist.uri, self.playlists_dir)
|
||||||
|
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
contents = f.read().splitlines()
|
contents = f.read().splitlines()
|
||||||
@ -114,7 +112,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
|
|||||||
self.assertEqual(contents, ['#EXTM3U', '#EXTINF:60,Test', track.uri])
|
self.assertEqual(contents, ['#EXTM3U', '#EXTINF:60,Test', track.uri])
|
||||||
|
|
||||||
def test_playlists_are_loaded_at_startup(self):
|
def test_playlists_are_loaded_at_startup(self):
|
||||||
track = Track(uri='local:track:path2')
|
track = Track(uri='dummy:track:path2')
|
||||||
playlist = self.core.playlists.create('test')
|
playlist = self.core.playlists.create('test')
|
||||||
playlist = playlist.copy(tracks=[track])
|
playlist = playlist.copy(tracks=[track])
|
||||||
playlist = self.core.playlists.save(playlist)
|
playlist = self.core.playlists.save(playlist)
|
||||||
@ -134,7 +132,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@unittest.SkipTest
|
@unittest.SkipTest
|
||||||
def test_playlist_dir_is_created(self):
|
def test_playlists_dir_is_created(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def test_create_returns_playlist_with_name_set(self):
|
def test_create_returns_playlist_with_name_set(self):
|
||||||
@ -154,7 +152,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
|
|||||||
self.assert_(not self.core.playlists.playlists)
|
self.assert_(not self.core.playlists.playlists)
|
||||||
|
|
||||||
def test_delete_non_existant_playlist(self):
|
def test_delete_non_existant_playlist(self):
|
||||||
self.core.playlists.delete('local:playlist:unknown')
|
self.core.playlists.delete('m3u:unknown')
|
||||||
|
|
||||||
def test_delete_playlist_removes_it_from_the_collection(self):
|
def test_delete_playlist_removes_it_from_the_collection(self):
|
||||||
playlist = self.core.playlists.create('test')
|
playlist = self.core.playlists.create('test')
|
||||||
@ -168,7 +166,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
|
|||||||
playlist = self.core.playlists.create('test')
|
playlist = self.core.playlists.create('test')
|
||||||
self.assertIn(playlist, self.core.playlists.playlists)
|
self.assertIn(playlist, self.core.playlists.playlists)
|
||||||
|
|
||||||
path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir)
|
path = playlist_uri_to_path(playlist.uri, self.playlists_dir)
|
||||||
self.assertTrue(os.path.exists(path))
|
self.assertTrue(os.path.exists(path))
|
||||||
|
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
@ -244,12 +242,12 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_save_playlist_with_new_uri(self):
|
def test_save_playlist_with_new_uri(self):
|
||||||
# you *should* not do this
|
# you *should* not do this
|
||||||
uri = 'local:playlist:test.m3u'
|
uri = 'm3u:test.m3u'
|
||||||
playlist = self.core.playlists.save(Playlist(uri=uri))
|
playlist = self.core.playlists.save(Playlist(uri=uri))
|
||||||
self.assertIn(playlist, self.core.playlists.playlists)
|
self.assertIn(playlist, self.core.playlists.playlists)
|
||||||
self.assertEqual(uri, playlist.uri)
|
self.assertEqual(uri, playlist.uri)
|
||||||
self.assertEqual('test', playlist.name)
|
self.assertEqual('test', playlist.name)
|
||||||
path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir)
|
path = playlist_uri_to_path(playlist.uri, self.playlists_dir)
|
||||||
self.assertTrue(os.path.exists(path))
|
self.assertTrue(os.path.exists(path))
|
||||||
|
|
||||||
def test_playlist_with_unknown_track(self):
|
def test_playlist_with_unknown_track(self):
|
||||||
@ -261,8 +259,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
|
|||||||
backend = self.backend_class(config=self.config, audio=self.audio)
|
backend = self.backend_class(config=self.config, audio=self.audio)
|
||||||
|
|
||||||
self.assert_(backend.playlists.playlists)
|
self.assert_(backend.playlists.playlists)
|
||||||
self.assertEqual(
|
self.assertEqual('m3u:test.m3u', backend.playlists.playlists[0].uri)
|
||||||
'local:playlist:test.m3u', backend.playlists.playlists[0].uri)
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
playlist.name, backend.playlists.playlists[0].name)
|
playlist.name, backend.playlists.playlists[0].name)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@ -282,12 +279,12 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
|
|||||||
|
|
||||||
check_order(self.core.playlists.playlists, ['a', 'b', 'c'])
|
check_order(self.core.playlists.playlists, ['a', 'b', 'c'])
|
||||||
|
|
||||||
playlist = self.core.playlists.lookup('local:playlist:a.m3u')
|
playlist = self.core.playlists.lookup('m3u:a.m3u')
|
||||||
playlist = playlist.copy(name='d')
|
playlist = playlist.copy(name='d')
|
||||||
playlist = self.core.playlists.save(playlist)
|
playlist = self.core.playlists.save(playlist)
|
||||||
|
|
||||||
check_order(self.core.playlists.playlists, ['b', 'c', 'd'])
|
check_order(self.core.playlists.playlists, ['b', 'c', 'd'])
|
||||||
|
|
||||||
self.core.playlists.delete('local:playlist:c.m3u')
|
self.core.playlists.delete('m3u:c.m3u')
|
||||||
|
|
||||||
check_order(self.core.playlists.playlists, ['b', 'd'])
|
check_order(self.core.playlists.playlists, ['b', 'd'])
|
||||||
@ -6,7 +6,7 @@ import os
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from mopidy.local import translator
|
from mopidy.m3u import translator
|
||||||
from mopidy.models import Track
|
from mopidy.models import Track
|
||||||
from mopidy.utils import path
|
from mopidy.utils import path
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user