diff --git a/docs/development/roadmap.rst b/docs/development/roadmap.rst index ee074eee..b3188a40 100644 --- a/docs/development/roadmap.rst +++ b/docs/development/roadmap.rst @@ -38,6 +38,9 @@ Stuff we really want to do, but just not right now stuff into the various distros) to make Debian/Ubuntu installation a breeze. - Create `Homebrew `_ recipies for all our dependencies and Mopidy itself to make OS X installation a breeze. +- Run frontend tests against a real MPD server to ensure we are in sync. +- Start working with MPD-client maintainers to get rid of weird assumptions + like only searching for first two letters etc. Crazy stuff we had to write down somewhere diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 44d82545..fc4eb6f4 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -9,6 +9,10 @@ from mopidy.utils import get_class logger = logging.getLogger('mopidy.backends.base') +__all__ = ['BaseBackend', 'BasePlaybackController', + 'BaseCurrentPlaylistController', 'BaseStoredPlaylistsController', + 'BaseLibraryController'] + class BaseBackend(object): def __init__(self, core_queue=None, mixer=None): self.core_queue = core_queue @@ -42,6 +46,11 @@ class BaseBackend(object): uri_handlers = [] def destroy(self): + ''' + Call destroy on all sub-components in backend so that they can cleanup + after themselves. + ''' + if self.current_playlist: self.current_playlist.destroy() @@ -218,6 +227,7 @@ class BaseCurrentPlaylistController(object): self.playlist = self.playlist.with_(tracks=before+shuffled+after) def destroy(self): + '''Cleanup after component''' pass @@ -274,6 +284,7 @@ class BaseLibraryController(object): raise NotImplementedError def destroy(self): + '''Cleanup after component''' pass @@ -593,6 +604,7 @@ class BasePlaybackController(object): raise NotImplementedError def destroy(self): + '''Cleanup after component''' pass @@ -708,4 +720,5 @@ class BaseStoredPlaylistsController(object): return filter(lambda p: query in p.name, self._playlists) def destroy(self): + '''Cleanup after component''' pass diff --git a/mopidy/backends/gstreamer.py b/mopidy/backends/gstreamer.py index e39593af..567e7acf 100644 --- a/mopidy/backends/gstreamer.py +++ b/mopidy/backends/gstreamer.py @@ -8,11 +8,13 @@ pygst.require('0.10') import gst import logging +import os +import shutil import threading -from mopidy.backends import (BaseBackend, - BasePlaybackController, - BaseCurrentPlaylistController) +from mopidy.backends import * +from mopidy.models import Playlist +from mopidy import settings logger = logging.getLogger(u'backends.gstreamer') @@ -35,7 +37,9 @@ class GStreamerBackend(BaseBackend): super(GStreamerBackend, self).__init__(*args, **kwargs) self.playback = GStreamerPlaybackController(self) + self.stored_playlists = GStreamerStoredPlaylistsController(self) self.current_playlist = BaseCurrentPlaylistController(self) + self.uri_handlers = [u'file:'] class GStreamerPlaybackController(BasePlaybackController): @@ -108,3 +112,43 @@ class GStreamerPlaybackController(BasePlaybackController): del bus del bin + + +class GStreamerStoredPlaylistsController(BaseStoredPlaylistsController): + def __init__(self, *args): + super(GStreamerStoredPlaylistsController, self).__init__(*args) + # FIXME need test that ensures that folder is created + self._folder = os.path.expanduser(settings.PLAYLIST_FOLDER) + + def create(self, name): + playlist = Playlist(name=name) + self.save(playlist) + return playlist + + def delete(self, playlist): + if playlist not in self._playlists: + return + + self._playlists.remove(playlist) + file = os.path.join(self._folder, playlist.name + '.m3u') + + if os.path.exists(file): + os.remove(file) + + def rename(self, playlist, name): + if playlist not in self._playlists: + return + + src = os.path.join(self._folder, playlist.name + '.m3u') + dst = os.path.join(self._folder, name + '.m3u') + + renamed = playlist.with_(name=name) + index = self._playlists.index(playlist) + self._playlists[index] = renamed + + shutil.move(src, dst) + + def save(self, playlist): + file = os.path.join(self._folder, playlist.name + '.m3u') + open(file, 'w').close() + self._playlists.append(playlist) diff --git a/mopidy/settings.py b/mopidy/settings.py index 6cb2928e..ce132ba7 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -101,6 +101,9 @@ SPOTIFY_LIB_APPKEY = u'~/.mopidy/spotify_appkey.key' #: Path to the libspotify cache. Used by LibspotifyBackend. SPOTIFY_LIB_CACHE = u'~/.mopidy/libspotify_cache' +#: Path to playlist folder with m3u files. +PLAYLIST_FOLDER = u'~/.mopidy/playlists' + # Import user specific settings dotdir = os.path.expanduser(u'~/.mopidy/') settings_file = os.path.join(dotdir, u'settings.py') diff --git a/mopidy/utils.py b/mopidy/utils.py index 0142e15c..0275e055 100644 --- a/mopidy/utils.py +++ b/mopidy/utils.py @@ -2,6 +2,7 @@ import logging from multiprocessing.reduction import reduce_connection import os import pickle +import urllib logger = logging.getLogger('mopidy.utils') @@ -91,3 +92,43 @@ def spotify_uri_to_int(uri, output_bits=31): compressed_id ^= (full_id & (2 ** output_bits - 1)) full_id >>= output_bits return int(compressed_id) + +def m3u_to_uris(file_path): + """ + Convert M3U file list of uris + + 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 + + - Relative paths of songs should be with respect to location of M3U. + - Paths are normaly platform specific. + - Lines starting with # should be ignored. + - m3u files are latin-1. + - This function does not bother with Extended M3U directives. + """ + + uris = [] + folder = os.path.dirname(file_path) + + with open(file_path) as m3u: + for line in m3u.readlines(): + line = line.strip().decode('latin1') + + if line.startswith('#'): + continue + + if line.startswith('file:'): + uris.append(line) + else: + file = os.path.join(folder, line) + path = urllib.pathname2url(file.encode('utf-8')) + uris.append('file:' + path) + + return uris diff --git a/tests/backends/base.py b/tests/backends/base.py index 0d62554d..a03ea95d 100644 --- a/tests/backends/base.py +++ b/tests/backends/base.py @@ -1,12 +1,20 @@ +import os import random -import time +import shutil +import tempfile import threading +import time +from mopidy import settings from mopidy.mixers.dummy import DummyMixer from mopidy.models import Playlist, Track from tests import SkipTest +__all__ = ['BaseCurrentPlaylistControllerTest', + 'BasePlaybackControllerTest', + 'BaseStoredPlaylistsControllerTest'] + def populate_playlist(func): def wrapper(self): for track in self.tracks: @@ -872,3 +880,86 @@ class BasePlaybackControllerTest(object): def test_playing_track_that_isnt_in_playlist(self): test = lambda: self.playback.play(self.tracks[0]) self.assertRaises(AssertionError, test) + + +class BaseStoredPlaylistsControllerTest(object): + backend_class = None + + def setUp(self): + self.original_folder = settings.PLAYLIST_FOLDER + settings.PLAYLIST_FOLDER = tempfile.mkdtemp() + self.backend = self.backend_class(mixer=DummyMixer()) + self.stored = self.backend.stored_playlists + + def tearDown(self): + self.backend.destroy() + if os.path.exists(settings.PLAYLIST_FOLDER): + shutil.rmtree(settings.PLAYLIST_FOLDER) + settings.PLAYLIST_FOLDER = self.original_folder + + def test_create(self): + playlist = self.stored.create('test') + self.assertEqual(playlist.name, 'test') + + def test_create_in_playlists(self): + playlist = self.stored.create('test') + self.assert_(self.stored.playlists) + + def test_playlists_empty_to_start_with(self): + self.assert_(not self.stored.playlists) + + def test_delete_non_existant_playlist(self): + self.stored.delete(Playlist()) + + def test_delete_playlist(self): + playlist = self.stored.create('test') + self.stored.delete(playlist) + self.assert_(not self.stored.playlists) + + def test_get_without_criteria(self): + test = lambda: self.stored.get() + self.assertRaises(LookupError, test) + + def test_get_with_wrong_cirteria(self): + test = lambda: self.stored.get(name='foo') + self.assertRaises(LookupError, test) + + def test_get_with_right_criteria(self): + playlist1 = self.stored.create('test') + playlist2 = self.stored.get(name='test') + self.assertEqual(playlist1, playlist2) + + def test_search_returns_empty_list(self): + self.assertEqual([], self.stored.search('test')) + + def test_search_returns_playlist(self): + playlist = self.stored.create('test') + playlists = self.stored.search('test') + self.assert_(playlist in playlists) + + def test_search_returns_mulitple_playlists(self): + playlist1 = self.stored.create('test') + playlist2 = self.stored.create('test2') + playlists = self.stored.search('test') + self.assert_(playlist1 in playlists) + self.assert_(playlist2 in playlists) + + def test_lookup(self): + raise SkipTest + + def test_refresh(self): + raise SkipTest + + def test_rename(self): + playlist = self.stored.create('test') + self.stored.rename(playlist, 'test2') + self.stored.get(name='test2') + + def test_rename_unknown_playlist(self): + self.stored.rename(Playlist(), 'test2') + + def test_save(self): + # FIXME should we handle playlists without names? + playlist = Playlist(name='test') + self.stored.save(playlist) + self.assert_(playlist in self.stored.playlists) diff --git a/tests/backends/gstreamer_test.py b/tests/backends/gstreamer_test.py index 6672dd96..78b1f7cb 100644 --- a/tests/backends/gstreamer_test.py +++ b/tests/backends/gstreamer_test.py @@ -1,35 +1,38 @@ import unittest import os +import urllib from mopidy.models import Playlist, Track from mopidy.backends.gstreamer import GStreamerBackend +from mopidy import settings -from tests.backends.base import (BasePlaybackControllerTest, - BaseCurrentPlaylistControllerTest) +from tests.backends.base import * folder = os.path.dirname(__file__) folder = os.path.join(folder, '..', 'data') folder = os.path.abspath(folder) song = os.path.join(folder, 'song%s.wav') -song = 'file://' + song +generate_song = lambda i: 'file:' + urllib.pathname2url(song % i) # FIXME can be switched to generic test -class GStreamerCurrentPlaylistHandlerTest(BaseCurrentPlaylistControllerTest, - unittest.TestCase): - tracks = [Track(uri=song % i, id=i, length=4464) for i in range(1, 4)] +class GStreamerCurrentPlaylistHandlerTest(BaseCurrentPlaylistControllerTest, unittest.TestCase): + tracks = [Track(uri=generate_song(i), id=i, length=4464) for i in range(1, 4)] + backend_class = GStreamerBackend -class GStreamerPlaybackControllerTest(BasePlaybackControllerTest, - unittest.TestCase): - tracks = [Track(uri=song % i, id=i, length=4464) for i in range(1, 4)] +class GStreamerPlaybackControllerTest(BasePlaybackControllerTest, unittest.TestCase): + tracks = [Track(uri=generate_song(i), id=i, length=4464) for i in range(1, 4)] backend_class = GStreamerBackend def add_track(self, file): - uri = 'file://' + os.path.join(folder, file) + uri = 'file:' + urllib.pathname2url(os.path.join(folder, file)) track = Track(uri=uri, id=1, length=4464) self.backend.current_playlist.add(track) + def test_uri_handler(self): + self.assert_('file:' in self.backend.uri_handlers) + def test_play_mp3(self): self.add_track('blank.mp3') self.playback.play() @@ -44,3 +47,37 @@ class GStreamerPlaybackControllerTest(BasePlaybackControllerTest, self.add_track('blank.flac') self.playback.play() self.assertEqual(self.playback.state, self.playback.PLAYING) + + +class GStreamerBackendStoredPlaylistsControllerTest(BaseStoredPlaylistsControllerTest, + unittest.TestCase): + + backend_class = GStreamerBackend + + def test_created_playlist_is_persisted(self): + self.stored.create('test') + file = os.path.join(settings.PLAYLIST_FOLDER, 'test.m3u') + self.assert_(os.path.exists(file)) + + def test_saved_playlist_is_persisted(self): + self.stored.save(Playlist(name='test2')) + file = os.path.join(settings.PLAYLIST_FOLDER, 'test2.m3u') + self.assert_(os.path.exists(file)) + + def test_deleted_playlist_get_removed(self): + playlist = self.stored.create('test') + self.stored.delete(playlist) + file = os.path.join(settings.PLAYLIST_FOLDER, 'test.m3u') + self.assert_(not os.path.exists(file)) + + def test_renamed_playlist_gets_moved(self): + playlist = self.stored.create('test') + self.stored.rename(playlist, 'test2') + file1 = os.path.join(settings.PLAYLIST_FOLDER, 'test.m3u') + file2 = os.path.join(settings.PLAYLIST_FOLDER, 'test2.m3u') + self.assert_(not os.path.exists(file1)) + self.assert_(os.path.exists(file2)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/data/comment.m3u b/tests/data/comment.m3u new file mode 100644 index 00000000..af37f706 --- /dev/null +++ b/tests/data/comment.m3u @@ -0,0 +1,2 @@ +# test +song1.mp3 diff --git a/tests/data/empty.m3u b/tests/data/empty.m3u new file mode 100644 index 00000000..e69de29b diff --git a/tests/data/encoding.m3u b/tests/data/encoding.m3u new file mode 100644 index 00000000..383aa526 --- /dev/null +++ b/tests/data/encoding.m3u @@ -0,0 +1 @@ +æøå.mp3 diff --git a/tests/data/one.m3u b/tests/data/one.m3u new file mode 100644 index 00000000..c7a3d081 --- /dev/null +++ b/tests/data/one.m3u @@ -0,0 +1 @@ +song1.mp3 diff --git a/tests/utils_test.py b/tests/utils_test.py new file mode 100644 index 00000000..4531745d --- /dev/null +++ b/tests/utils_test.py @@ -0,0 +1,66 @@ +#encoding: utf-8 + +import os +import tempfile +import unittest +import urllib + +from mopidy.utils import m3u_to_uris + +def data(name): + folder = os.path.dirname(__file__) + folder = os.path.join(folder, 'data') + folder = os.path.abspath(folder) + return os.path.join(folder, name) + + +song1_path = data('song1.mp3') +song2_path = data('song2.mp3') +encoded_path = data(u'æøå.mp3') +song1_uri = 'file:' + urllib.pathname2url(song1_path) +song2_uri = 'file:' + urllib.pathname2url(song2_path) +encoded_uri = 'file:' + urllib.pathname2url(encoded_path.encode('utf-8')) + + +class M3UToUriTest(unittest.TestCase): + def test_empty_file(self): + uris = m3u_to_uris(data('empty.m3u')) + self.assertEqual([], uris) + + def test_basic_file(self): + uris = m3u_to_uris(data('one.m3u')) + self.assertEqual([song1_uri], uris) + + def test_file_with_comment(self): + uris = m3u_to_uris(data('comment.m3u')) + self.assertEqual([song1_uri], uris) + + def test_file_with_absolute_files(self): + with tempfile.NamedTemporaryFile() as file: + file.write(song1_path) + file.flush() + + uris = m3u_to_uris(file.name) + self.assertEqual([song1_uri], uris) + + def test_file_with_multiple_absolute_files(self): + with tempfile.NamedTemporaryFile() as file: + file.write(song1_path+'\n') + file.write('# comment \n') + file.write(song2_path) + file.flush() + + uris = m3u_to_uris(file.name) + self.assertEqual([song1_uri, song2_uri], uris) + + def test_file_with_uri(self): + with tempfile.NamedTemporaryFile() as file: + file.write(song1_uri) + file.flush() + + uris = m3u_to_uris(file.name) + self.assertEqual([song1_uri], uris) + + def test_encoding_is_latin1(self): + uris = m3u_to_uris(data('encoding.m3u')) + self.assertEqual([encoded_uri], uris)