m3u: Extract new M3U backend from local

Fixes #1054
This commit is contained in:
Stein Magnus Jodal 2015-03-19 00:02:52 +01:00
parent 8977f71411
commit b2f60bc338
18 changed files with 312 additions and 159 deletions

View File

@ -126,6 +126,11 @@ v1.0.0 (UNRELEASED)
- 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**
- 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
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**
- In stored playlist names, replace "/", which are illegal, with "|" instead of

55
docs/ext/m3u.rst Normal file
View 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.

View File

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

View File

@ -24,7 +24,7 @@ class Extension(ext.Extension):
schema['library'] = config.String()
schema['media_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['scan_timeout'] = config.Integer(
minimum=1000, maximum=1000 * 60 * 60)

View File

@ -8,7 +8,6 @@ from mopidy import backend
from mopidy.local import storage
from mopidy.local.library import LocalLibraryProvider
from mopidy.local.playback import LocalPlaybackProvider
from mopidy.local.playlists import LocalPlaylistsProvider
logger = logging.getLogger(__name__)
@ -36,5 +35,4 @@ class LocalBackend(pykka.ThreadingActor, backend.Backend):
logger.warning('Local library %s not found', library_name)
self.playback = LocalPlaybackProvider(audio=audio, backend=self)
self.playlists = LocalPlaylistsProvider(backend=self)
self.library = LocalLibraryProvider(backend=self, library=library)

View File

@ -3,7 +3,6 @@ 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
scan_follow_symlinks = false

View File

@ -20,11 +20,3 @@ def check_dirs_and_files(config):
logger.warning(
'Could not create local data dir: %s',
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))

View File

@ -2,18 +2,12 @@ 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__)
@ -28,13 +22,6 @@ def local_track_uri_to_path(uri, media_dir):
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):
"""Convert path relative to media_dir to local track URI."""
if isinstance(relpath, compat.text_type):
@ -47,89 +34,3 @@ def path_to_local_directory_uri(relpath):
if isinstance(relpath, compat.text_type):
relpath = relpath.encode('utf-8')
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
View 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
View 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
View File

@ -0,0 +1,3 @@
[m3u]
enabled = true
playlists_dir = $XDG_DATA_DIR/mopidy/m3u

18
mopidy/m3u/library.py Normal file
View 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 []

View File

@ -8,19 +8,18 @@ import os
import sys
from mopidy import backend
from mopidy.m3u import translator
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__)
class LocalPlaylistsProvider(backend.PlaylistsProvider):
class M3UPlaylistsProvider(backend.PlaylistsProvider):
def __init__(self, *args, **kwargs):
super(LocalPlaylistsProvider, self).__init__(*args, **kwargs)
self._media_dir = self.backend.config['local']['media_dir']
self._playlists_dir = self.backend.config['local']['playlists_dir']
super(M3UPlaylistsProvider, self).__init__(*args, **kwargs)
self._playlists_dir = self.backend._config['m3u']['playlists_dir']
self._playlists = []
self.refresh()
@ -49,7 +48,7 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider):
if not playlist:
logger.warn('Trying to delete unknown playlist %s', uri)
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):
os.remove(path)
else:
@ -70,10 +69,10 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider):
for path in glob.glob(os.path.join(self._playlists_dir, b'*.m3u')):
relpath = os.path.basename(path)
name = os.path.splitext(relpath)[0].decode(encoding)
uri = path_to_local_playlist_uri(relpath)
uri = translator.path_to_playlist_uri(relpath)
tracks = []
for track in parse_m3u(path, self._media_dir):
for track in translator.parse_m3u(path):
tracks.append(track)
playlist = Playlist(uri=uri, name=name, tracks=tracks)
@ -82,7 +81,7 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider):
self.playlists = sorted(playlists, key=operator.attrgetter('name'))
logger.info(
'Loaded %d local playlists from %s',
'Loaded %d M3U playlists from %s',
len(playlists), self._playlists_dir)
def save(self, playlist):
@ -99,7 +98,7 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider):
playlist = self._save_m3u(playlist)
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):
os.remove(path)
else:
@ -125,11 +124,12 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider):
def _save_m3u(self, playlist, encoding=sys.getfilesystemencoding()):
if playlist.name:
name = self._sanitize_m3u_name(playlist.name, encoding)
uri = path_to_local_playlist_uri(name.encode(encoding) + b'.m3u')
path = local_playlist_uri_to_path(uri, self._playlists_dir)
uri = translator.path_to_playlist_uri(
name.encode(encoding) + b'.m3u')
path = translator.playlist_uri_to_path(uri, self._playlists_dir)
elif 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))
else:
raise ValueError('M3U playlist needs name or URI')

110
mopidy/m3u/translator.py Normal file
View 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

View File

@ -36,6 +36,7 @@ setup(
'mopidy.ext': [
'http = mopidy.http:Extension',
'local = mopidy.local:Extension',
'm3u = mopidy.m3u:Extension',
'mpd = mopidy.mpd:Extension',
'softwaremixer = mopidy.softwaremixer:Extension',
'stream = mopidy.stream:Extension',

5
tests/m3u/__init__.py Normal file
View File

@ -0,0 +1,5 @@
from __future__ import absolute_import, unicode_literals
def generate_song(i):
return 'dummy:track:song%s' % i

View File

@ -8,30 +8,28 @@ import unittest
import pykka
from mopidy import core
from mopidy.local import actor
from mopidy.local.translator import local_playlist_uri_to_path
from mopidy.m3u import actor
from mopidy.m3u.translator import playlist_uri_to_path
from mopidy.models import Playlist, Track
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):
backend_class = actor.LocalBackend
class M3UPlaylistsProviderTest(unittest.TestCase):
backend_class = actor.M3UBackend
config = {
'local': {
'media_dir': path_to_data_dir(''),
'data_dir': path_to_data_dir(''),
'library': 'json',
'm3u': {
'playlists_dir': path_to_data_dir(''),
}
}
def setUp(self): # noqa: N802
self.config['local']['playlists_dir'] = tempfile.mkdtemp()
self.playlists_dir = self.config['local']['playlists_dir']
self.config['m3u']['playlists_dir'] = tempfile.mkdtemp()
self.playlists_dir = self.config['m3u']['playlists_dir']
self.audio = dummy_audio.create_proxy()
self.backend = actor.LocalBackend.start(
self.backend = actor.M3UBackend.start(
config=self.config, audio=self.audio).proxy()
self.core = core.Core(backends=[self.backend])
@ -42,8 +40,8 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
shutil.rmtree(self.playlists_dir)
def test_created_playlist_is_persisted(self):
uri = 'local:playlist:test.m3u'
path = local_playlist_uri_to_path(uri, self.playlists_dir)
uri = 'm3u:test.m3u'
path = playlist_uri_to_path(uri, self.playlists_dir)
self.assertFalse(os.path.exists(path))
playlist = self.core.playlists.create('test')
@ -54,16 +52,16 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
def test_create_sanitizes_playlist_name(self):
playlist = self.core.playlists.create('../../test FOO baR')
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.assertTrue(os.path.exists(path))
def test_saved_playlist_is_persisted(self):
uri1 = 'local:playlist:test1.m3u'
uri2 = 'local:playlist:test2.m3u'
uri1 = 'm3u:test1.m3u'
uri2 = 'm3u:test2.m3u'
path1 = local_playlist_uri_to_path(uri1, self.playlists_dir)
path2 = local_playlist_uri_to_path(uri2, self.playlists_dir)
path1 = playlist_uri_to_path(uri1, self.playlists_dir)
path2 = playlist_uri_to_path(uri2, self.playlists_dir)
playlist = self.core.playlists.create('test1')
self.assertEqual('test1', playlist.name)
@ -78,8 +76,8 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
self.assertTrue(os.path.exists(path2))
def test_deleted_playlist_is_removed(self):
uri = 'local:playlist:test.m3u'
path = local_playlist_uri_to_path(uri, self.playlists_dir)
uri = 'm3u:test.m3u'
path = playlist_uri_to_path(uri, self.playlists_dir)
self.assertFalse(os.path.exists(path))
@ -95,7 +93,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
track = Track(uri=generate_song(1))
playlist = self.core.playlists.create('test')
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:
contents = f.read()
@ -106,7 +104,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
track = Track(uri=generate_song(1), name='Test', length=60000)
playlist = self.core.playlists.create('test')
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:
contents = f.read().splitlines()
@ -114,7 +112,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
self.assertEqual(contents, ['#EXTM3U', '#EXTINF:60,Test', track.uri])
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 = playlist.copy(tracks=[track])
playlist = self.core.playlists.save(playlist)
@ -134,7 +132,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
pass
@unittest.SkipTest
def test_playlist_dir_is_created(self):
def test_playlists_dir_is_created(self):
pass
def test_create_returns_playlist_with_name_set(self):
@ -154,7 +152,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
self.assert_(not self.core.playlists.playlists)
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):
playlist = self.core.playlists.create('test')
@ -168,7 +166,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
playlist = self.core.playlists.create('test')
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))
os.remove(path)
@ -244,12 +242,12 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
def test_save_playlist_with_new_uri(self):
# you *should* not do this
uri = 'local:playlist:test.m3u'
uri = 'm3u:test.m3u'
playlist = self.core.playlists.save(Playlist(uri=uri))
self.assertIn(playlist, self.core.playlists.playlists)
self.assertEqual(uri, playlist.uri)
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))
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)
self.assert_(backend.playlists.playlists)
self.assertEqual(
'local:playlist:test.m3u', backend.playlists.playlists[0].uri)
self.assertEqual('m3u:test.m3u', backend.playlists.playlists[0].uri)
self.assertEqual(
playlist.name, backend.playlists.playlists[0].name)
self.assertEqual(
@ -282,12 +279,12 @@ class LocalPlaylistsProviderTest(unittest.TestCase):
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 = self.core.playlists.save(playlist)
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'])

View File

@ -6,7 +6,7 @@ import os
import tempfile
import unittest
from mopidy.local import translator
from mopidy.m3u import translator
from mopidy.models import Track
from mopidy.utils import path