diff --git a/MANIFEST.in b/MANIFEST.in
index 38819adb..33d7dc71 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,4 +1,4 @@
-include LICENSE pylintrc *.rst *.txt
+include LICENSE pylintrc *.rst *.txt data/mopidy.desktop
include mopidy/backends/libspotify/spotify_appkey.key
recursive-include docs *
prune docs/_build
diff --git a/bin/mopidy-scan b/bin/mopidy-scan
new file mode 100755
index 00000000..8534372c
--- /dev/null
+++ b/bin/mopidy-scan
@@ -0,0 +1,31 @@
+#!/usr/bin/env python
+
+if __name__ == '__main__':
+ import sys
+
+ from mopidy import settings
+ from mopidy.scanner import Scanner, translator
+ from mopidy.frontends.mpd.translator import tracks_to_tag_cache_format
+
+ tracks = []
+
+ def store(data):
+ track = translator(data)
+ tracks.append(track)
+ print >> sys.stderr, 'Added %s' % track.uri
+
+ def debug(uri, error):
+ print >> sys.stderr, 'Failed %s: %s' % (uri, error)
+
+ print >> sys.stderr, 'Scanning %s' % settings.LOCAL_MUSIC_FOLDER
+
+ scanner = Scanner(settings.LOCAL_MUSIC_FOLDER, store, debug)
+ scanner.start()
+
+ print >> sys.stderr, 'Done'
+
+ for a in tracks_to_tag_cache_format(tracks):
+ if len(a) == 1:
+ print a[0]
+ else:
+ print u': '.join([unicode(b) for b in a]).encode('utf-8')
diff --git a/data/mopidy.desktop b/data/mopidy.desktop
new file mode 100644
index 00000000..f5ca43bb
--- /dev/null
+++ b/data/mopidy.desktop
@@ -0,0 +1,10 @@
+[Desktop Entry]
+Type=Application
+Version=1.0
+Name=Mopidy Music Server
+Comment=MPD music server with Spotify support
+Icon=audio-x-generic
+TryExec=mopidy
+Exec=mopidy
+Terminal=true
+Categories=AudioVideo;Audio;Player;ConsoleOnly
diff --git a/docs/api/backends/dummy.rst b/docs/api/backends/dummy.rst
new file mode 100644
index 00000000..03b2e6ce
--- /dev/null
+++ b/docs/api/backends/dummy.rst
@@ -0,0 +1,7 @@
+*********************************************************
+:mod:`mopidy.backends.dummy` -- Dummy backend for testing
+*********************************************************
+
+.. automodule:: mopidy.backends.dummy
+ :synopsis: Dummy backend used for testing
+ :members:
diff --git a/docs/api/backends.rst b/docs/api/backends/index.rst
similarity index 74%
rename from docs/api/backends.rst
rename to docs/api/backends/index.rst
index f675541a..100f6f0d 100644
--- a/docs/api/backends.rst
+++ b/docs/api/backends/index.rst
@@ -82,25 +82,9 @@ Manages the music library, e.g. searching for tracks to be added to a playlist.
:undoc-members:
-:mod:`mopidy.backends.dummy` -- Dummy backend for testing
-=========================================================
+Backends
+========
-.. automodule:: mopidy.backends.dummy
- :synopsis: Dummy backend used for testing
- :members:
-
-
-:mod:`mopidy.backends.libspotify` -- Libspotify backend
-=======================================================
-
-.. automodule:: mopidy.backends.libspotify
- :synopsis: Spotify backend using the libspotify library
- :members:
-
-
-:mod:`mopidy.backends.local` -- Local backend
-=====================================================
-
-.. automodule:: mopidy.backends.local
- :synopsis: Backend for playing music files on local storage
- :members:
+* :mod:`mopidy.backends.dummy`
+* :mod:`mopidy.backends.libspotify`
+* :mod:`mopidy.backends.local`
diff --git a/docs/api/backends/libspotify.rst b/docs/api/backends/libspotify.rst
new file mode 100644
index 00000000..e7528757
--- /dev/null
+++ b/docs/api/backends/libspotify.rst
@@ -0,0 +1,7 @@
+*******************************************************
+:mod:`mopidy.backends.libspotify` -- Libspotify backend
+*******************************************************
+
+.. automodule:: mopidy.backends.libspotify
+ :synopsis: Spotify backend using the libspotify library
+ :members:
diff --git a/docs/api/backends/local.rst b/docs/api/backends/local.rst
new file mode 100644
index 00000000..892f5a87
--- /dev/null
+++ b/docs/api/backends/local.rst
@@ -0,0 +1,7 @@
+*********************************************
+:mod:`mopidy.backends.local` -- Local backend
+*********************************************
+
+.. automodule:: mopidy.backends.local
+ :synopsis: Backend for playing music files on local storage
+ :members:
diff --git a/docs/api/frontends/index.rst b/docs/api/frontends/index.rst
index 05595418..b01bac3d 100644
--- a/docs/api/frontends/index.rst
+++ b/docs/api/frontends/index.rst
@@ -2,7 +2,16 @@
:mod:`mopidy.frontends`
***********************
-A frontend is responsible for exposing Mopidy for a type of clients.
+A frontend may do whatever it wants to, including creating threads, opening TCP
+ports and exposing Mopidy for a type of clients.
+
+Frontends got one main limitation: they are restricted to passing messages
+through the ``core_queue`` for all communication with the rest of Mopidy. Thus,
+the frontend API is very small and reveals little of what a frontend may do.
+
+.. automodule:: mopidy.frontends
+ :synopsis: Frontend API
+ :members:
Frontend API
diff --git a/docs/changes.rst b/docs/changes.rst
index cb34993e..c3df7d85 100644
--- a/docs/changes.rst
+++ b/docs/changes.rst
@@ -12,7 +12,10 @@ No description yet.
**Changes**
-- None so far.
+- Install ``mopidy.desktop`` file that makes Mopidy available from e.g. Gnome
+ application menus.
+- Add :command:`mopidy-scan` command to generate ``tag_cache`` files without
+ any help from the original MPD server.
0.2.0 (2010-10-24)
diff --git a/docs/index.rst b/docs/index.rst
index 7a4dc27d..f53373dc 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -6,11 +6,11 @@ User documentation
.. toctree::
:maxdepth: 3
+ changes
installation/index
settings
running
clients/index
- changes
authors
licenses
diff --git a/docs/installation/index.rst b/docs/installation/index.rst
index 9577c383..580ecd6d 100644
--- a/docs/installation/index.rst
+++ b/docs/installation/index.rst
@@ -68,11 +68,11 @@ To install the currently latest release of Mopidy using ``pip``::
sudo aptitude install python-setuptools python-pip # On Ubuntu/Debian
sudo brew install pip # On OS X
- sudo pip install Mopidy
+ sudo pip install mopidy
To later upgrade to the latest release::
- sudo pip install -U Mopidy
+ sudo pip install -U mopidy
If you for some reason can't use ``pip``, try ``easy_install``.
@@ -80,26 +80,38 @@ Next, you need to set a couple of :doc:`settings `, and then you're
ready to :doc:`run Mopidy `.
-Install development version
-===========================
+Install development snapshot
+============================
-If you want to follow Mopidy development closer, you may install the
-development version of Mopidy::
+If you want to follow Mopidy development closer, you may install a snapshot of
+Mopidy's ``develop`` branch::
+
+ sudo aptitude install python-setuptools python-pip # On Ubuntu/Debian
+ sudo brew install pip # On OS X
+ sudo pip install mopidy==dev
+
+Next, you need to set a couple of :doc:`settings `, and then you're
+ready to :doc:`run Mopidy `.
+
+
+Run from source code checkout
+=============================
+
+If you may want to contribute to Mopidy, and want access to other branches as
+well, you can checkout the Mopidy source from Git and run it directly from the
+ckeckout::
sudo aptitude install git-core # On Ubuntu/Debian
sudo brew install git # On OS X
git clone git://github.com/jodal/mopidy.git
cd mopidy/
- sudo python setup.py install
+ python mopidy # Yes, 'mopidy' is a dir
To later update to the very latest version::
cd mopidy/
git pull
- sudo python setup.py install
For an introduction to ``git``, please visit `git-scm.com
-`_.
-
-Next, you need to set a couple of :doc:`settings `, and then you're
-ready to :doc:`run Mopidy `.
+`_. Also, please read our :doc:`developer documentation
+`.
diff --git a/docs/settings.rst b/docs/settings.rst
index afdd39dc..a7638b4e 100644
--- a/docs/settings.rst
+++ b/docs/settings.rst
@@ -34,6 +34,41 @@ file::
You may also want to change some of the ``LOCAL_*`` settings. See
:mod:`mopidy.settings`, for a full list of available settings.
+.. note::
+
+ Currently, Mopidy supports using Spotify *or* local storage as a music
+ source. We're working on using both sources simultaneously, and will
+ hopefully have support for this in the 0.3 release.
+
+
+Generating a tag cache
+----------------------
+
+Previously the local storage backend relied purely on ``tag_cache`` files
+generated by the original MPD server. To remedy this the command
+:command:`mopidy-scan` has been created. The program will scan your current
+:attr:`mopidy.settings.LOCAL_MUSIC_FOLDER` and build a MPD compatible
+``tag_cache``.
+
+To make a ``tag_cache`` of your local music available for Mopidy:
+
+#. Ensure that :attr:`mopidy.settings.LOCAL_MUSIC_FOLDER` points to where your
+ music is located. Check the current setting by running::
+
+ mopidy --list-settings
+
+#. Scan your music library. Currently the command outputs the ``tag_cache`` to
+ ``stdout``, which means that you will need to redirect the output to a file
+ yourself::
+
+ mopidy-scan > tag_cache
+
+#. Move the ``tag_cache`` file to the location
+ :attr:`mopidy.settings.LOCAL_TAG_CACHE` is set to, or change the setting to
+ point to where your ``tag_cache`` file is.
+
+#. Start Mopidy, find the music library in a client, and play some local music!
+
Connecting from other machines on the network
=============================================
diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py
index 4ad8947b..9796414d 100644
--- a/mopidy/backends/local/__init__.py
+++ b/mopidy/backends/local/__init__.py
@@ -154,11 +154,12 @@ class LocalLibraryController(BaseLibraryController):
self.refresh()
def refresh(self, uri=None):
- tracks = parse_mpd_tag_cache(settings.LOCAL_TAG_CACHE,
- settings.LOCAL_MUSIC_FOLDER)
+ tag_cache = os.path.expanduser(settings.LOCAL_TAG_CACHE)
+ music_folder = os.path.expanduser(settings.LOCAL_MUSIC_FOLDER)
- logger.info('Loading songs in %s from %s',
- settings.LOCAL_MUSIC_FOLDER, settings.LOCAL_TAG_CACHE)
+ tracks = parse_mpd_tag_cache(tag_cache, music_folder)
+
+ logger.info('Loading songs in %s from %s', music_folder, tag_cache)
for track in tracks:
self._uri_mapping[track.uri] = track
diff --git a/mopidy/core.py b/mopidy/core.py
index 69760094..0be6b96f 100644
--- a/mopidy/core.py
+++ b/mopidy/core.py
@@ -7,7 +7,7 @@ from mopidy import get_version, settings, OptionalDependencyError
from mopidy.utils import get_class
from mopidy.utils.log import setup_logging
from mopidy.utils.path import get_or_create_folder, get_or_create_file
-from mopidy.utils.process import BaseThread
+from mopidy.utils.process import BaseThread, GObjectEventThread
from mopidy.utils.settings import list_settings_optparse_callback
logger = logging.getLogger('mopidy.core')
@@ -47,6 +47,7 @@ class CoreProcess(BaseThread):
def setup(self):
self.setup_logging()
self.setup_settings()
+ self.gobject_loop = self.setup_gobject_loop(self.core_queue)
self.output = self.setup_output(self.core_queue)
self.backend = self.setup_backend(self.core_queue, self.output)
self.frontends = self.setup_frontends(self.core_queue, self.backend)
@@ -61,6 +62,11 @@ class CoreProcess(BaseThread):
get_or_create_file('~/.mopidy/settings.py')
settings.validate()
+ def setup_gobject_loop(self, core_queue):
+ gobject_loop = GObjectEventThread(core_queue)
+ gobject_loop.start()
+ return gobject_loop
+
def setup_output(self, core_queue):
output = get_class(settings.OUTPUT)(core_queue)
output.start()
diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py
index 756aa3c3..6689f627 100644
--- a/mopidy/frontends/mpd/protocol/__init__.py
+++ b/mopidy/frontends/mpd/protocol/__init__.py
@@ -13,7 +13,7 @@ implement our own MPD server which is compatible with the numerous existing
import re
#: The MPD protocol uses UTF-8 for encoding all data.
-ENCODING = u'utf-8'
+ENCODING = u'UTF-8'
#: The MPD protocol uses ``\n`` as line terminator.
LINE_TERMINATOR = u'\n'
diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py
index 07a58dd3..2b1adf50 100644
--- a/mopidy/frontends/mpd/translator.py
+++ b/mopidy/frontends/mpd/translator.py
@@ -1,4 +1,12 @@
-def track_to_mpd_format(track, position=None, cpid=None):
+import os
+import re
+
+from mopidy import settings
+from mopidy.utils.path import mtime as get_mtime
+from mopidy.frontends.mpd import protocol
+from mopidy.utils.path import path_to_uri, uri_to_path, split_path
+
+def track_to_mpd_format(track, position=None, cpid=None, key=False, mtime=False):
"""
Format track for output to MPD client.
@@ -8,12 +16,16 @@ def track_to_mpd_format(track, position=None, cpid=None):
:type position: integer
:param cpid: track's CPID (current playlist ID)
:type cpid: integer
+ :param key: if we should set key
+ :type key: boolean
+ :param mtime: if we should set mtime
+ :type mtime: boolean
:rtype: list of two-tuples
"""
result = [
('file', track.uri or ''),
('Time', track.length and (track.length // 1000) or 0),
- ('Artist', track_artists_to_mpd_format(track)),
+ ('Artist', artists_to_mpd_format(track.artists)),
('Title', track.name or ''),
('Album', track.album and track.album.name or ''),
('Date', track.date or ''),
@@ -23,20 +35,43 @@ def track_to_mpd_format(track, position=None, cpid=None):
track.track_no, track.album.num_tracks)))
else:
result.append(('Track', track.track_no))
+ if track.album is not None and track.album.artists:
+ artists = artists_to_mpd_format(track.album.artists)
+ result.append(('AlbumArtist', artists))
if position is not None and cpid is not None:
result.append(('Pos', position))
result.append(('Id', cpid))
+ if key and track.uri:
+ result.insert(0, ('key', os.path.basename(uri_to_path(track.uri))))
+ if mtime and track.uri:
+ result.append(('mtime', get_mtime(uri_to_path(track.uri))))
return result
-def track_artists_to_mpd_format(track):
+MPD_KEY_ORDER = '''
+ key file Time Artist AlbumArtist Title Album Track Date MUSICBRAINZ_ALBUMID
+ MUSICBRAINZ_ALBUMARTISTID MUSICBRAINZ_ARTISTID MUSICBRAINZ_TRACKID mtime
+'''.split()
+
+def order_mpd_track_info(result):
+ """
+ Order results from :func:`mopidy.frontends.mpd.translator.track_to_mpd_format`
+ so that it matches MPD's ordering. Simply a cosmetic fix for easier
+ diffing of tag_caches.
+
+ :param result: the track info
+ :type result: list of tuples
+ :rtype: list of tuples
+ """
+ return sorted(result, key=lambda i: MPD_KEY_ORDER.index(i[0]))
+
+def artists_to_mpd_format(artists):
"""
Format track artists for output to MPD client.
- :param track: the track
- :type track: :class:`mopidy.models.Track`
+ :param artists: the artists
+ :type track: array of :class:`mopidy.models.Artist`
:rtype: string
"""
- artists = track.artists
artists.sort(key=lambda a: a.name)
return u', '.join([a.name for a in artists])
@@ -72,3 +107,58 @@ def playlist_to_mpd_format(playlist, *args, **kwargs):
Arguments as for :func:`tracks_to_mpd_format`, except the first one.
"""
return tracks_to_mpd_format(playlist.tracks, *args, **kwargs)
+
+def tracks_to_tag_cache_format(tracks):
+ """
+ Format list of tracks for output to MPD tag cache
+
+ :param tracks: the tracks
+ :type tracks: list of :class:`mopidy.models.Track`
+ :rtype: list of lists of two-tuples
+ """
+ result = [
+ ('info_begin',),
+ ('mpd_version', protocol.VERSION),
+ ('fs_charset', protocol.ENCODING),
+ ('info_end',)
+ ]
+ tracks.sort(key=lambda t: t.uri)
+ _add_to_tag_cache(result, *tracks_to_directory_tree(tracks))
+ return result
+
+def _add_to_tag_cache(result, folders, files):
+ for path, entry in folders.items():
+ name = os.path.split(path)[1]
+ music_folder = os.path.expanduser(settings.LOCAL_MUSIC_FOLDER)
+ mtime = get_mtime(os.path.join(music_folder, path))
+ result.append(('directory', path))
+ result.append(('mtime', mtime))
+ result.append(('begin', name))
+ _add_to_tag_cache(result, *entry)
+ result.append(('end', name))
+
+ result.append(('songList begin',))
+ for track in files:
+ track_result = track_to_mpd_format(track, key=True, mtime=True)
+ track_result = order_mpd_track_info(track_result)
+ result.extend(track_result)
+ result.append(('songList end',))
+
+def tracks_to_directory_tree(tracks):
+ directories = ({}, [])
+ for track in tracks:
+ path = u''
+ current = directories
+
+ local_folder = os.path.expanduser(settings.LOCAL_MUSIC_FOLDER)
+ track_path = uri_to_path(track.uri)
+ track_path = re.sub('^' + re.escape(local_folder), '', track_path)
+ track_dir = os.path.dirname(track_path)
+
+ for part in split_path(track_dir):
+ path = os.path.join(path, part)
+ if path not in current[0]:
+ current[0][path] = ({}, [])
+ current = current[0][path]
+ current[1].append(track)
+ return directories
diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py
index 52bd302d..3b037f62 100644
--- a/mopidy/outputs/gstreamer.py
+++ b/mopidy/outputs/gstreamer.py
@@ -1,6 +1,3 @@
-import gobject
-gobject.threads_init()
-
import pygst
pygst.require('0.10')
import gst
@@ -28,20 +25,14 @@ class GStreamerOutput(BaseOutput):
def __init__(self, *args, **kwargs):
super(GStreamerOutput, self).__init__(*args, **kwargs)
- # Start a helper thread that can run the gobject.MainLoop
- self.messages_thread = GStreamerMessagesThread(self.core_queue)
-
- # Start a helper thread that can process the output_queue
self.output_queue = multiprocessing.Queue()
self.player_thread = GStreamerPlayerThread(self.core_queue,
self.output_queue)
def start(self):
- self.messages_thread.start()
self.player_thread.start()
def destroy(self):
- self.messages_thread.destroy()
self.player_thread.destroy()
def process_message(self, message):
@@ -91,21 +82,15 @@ class GStreamerOutput(BaseOutput):
return self._send_recv({'command': 'set_volume', 'volume': volume})
-class GStreamerMessagesThread(BaseThread):
- def __init__(self, core_queue):
- super(GStreamerMessagesThread, self).__init__(core_queue)
- self.name = u'GStreamerMessagesThread'
-
- def run_inside_try(self):
- gobject.MainLoop().run()
-
-
class GStreamerPlayerThread(BaseThread):
"""
A process for all work related to GStreamer.
The main loop processes events from both Mopidy and GStreamer.
+ This thread requires :class:`mopidy.utils.process.GObjectEventThread` to be
+ running too. This is not enforced in any way by the code.
+
Make sure this subprocess is started by the MainThread in the top-most
parent process, and not some other thread. If not, we can get into the
problems described at
diff --git a/mopidy/scanner.py b/mopidy/scanner.py
new file mode 100644
index 00000000..436598bd
--- /dev/null
+++ b/mopidy/scanner.py
@@ -0,0 +1,122 @@
+import gobject
+gobject.threads_init()
+
+import pygst
+pygst.require('0.10')
+import gst
+
+from os.path import abspath
+import datetime
+import sys
+import threading
+
+from mopidy.utils.path import path_to_uri, find_files
+from mopidy.models import Track, Artist, Album
+
+def translator(data):
+ albumartist_kwargs = {}
+ album_kwargs = {}
+ artist_kwargs = {}
+ track_kwargs = {}
+
+ if 'album' in data:
+ album_kwargs['name'] = data['album']
+
+ if 'track-count' in data:
+ album_kwargs['num_tracks'] = data['track-count']
+
+ if 'artist' in data:
+ artist_kwargs['name'] =data['artist']
+
+ if 'date' in data:
+ date = data['date']
+ date = datetime.date(date.year, date.month, date.day)
+ track_kwargs['date'] = date
+
+ if 'title' in data:
+ track_kwargs['name'] = data['title']
+
+ if 'track-number' in data:
+ track_kwargs['track_no'] = data['track-number']
+
+ if 'album-artist' in data:
+ albumartist_kwargs['name'] = data['album-artist']
+
+ if albumartist_kwargs:
+ album_kwargs['artists'] = [Artist(**albumartist_kwargs)]
+
+ track_kwargs['uri'] = data['uri']
+ track_kwargs['length'] = data['duration']
+ track_kwargs['album'] = Album(**album_kwargs)
+ track_kwargs['artists'] = [Artist(**artist_kwargs)]
+
+ return Track(**track_kwargs)
+
+
+class Scanner(object):
+ def __init__(self, folder, data_callback, error_callback=None):
+ self.uris = [path_to_uri(f) for f in find_files(folder)]
+ self.data_callback = data_callback
+ self.error_callback = error_callback
+ self.loop = gobject.MainLoop()
+
+ caps = gst.Caps('audio/x-raw-int')
+ fakesink = gst.element_factory_make('fakesink')
+ pad = fakesink.get_pad('sink')
+
+ self.uribin = gst.element_factory_make('uridecodebin')
+ self.uribin.connect('pad-added', self.process_new_pad, pad)
+ self.uribin.set_property('caps', caps)
+
+ self.pipe = gst.element_factory_make('pipeline')
+ self.pipe.add(fakesink)
+ self.pipe.add(self.uribin)
+
+ bus = self.pipe.get_bus()
+ bus.add_signal_watch()
+ bus.connect('message::tag', self.process_tags)
+ bus.connect('message::error', self.process_error)
+
+ def process_new_pad(self, source, pad, target_pad):
+ pad.link(target_pad)
+
+ def process_tags(self, bus, message):
+ data = message.parse_tag()
+ data = dict([(k, data[k]) for k in data.keys()])
+ data['uri'] = unicode(self.uribin.get_property('uri'))
+ data['duration'] = self.get_duration()
+ self.data_callback(data)
+ self.next_uri()
+
+ def process_error(self, bus, message):
+ if self.error_callback:
+ uri = self.uribin.get_property('uri')
+ errors = message.parse_error()
+ self.error_callback(uri, errors)
+ self.next_uri()
+
+ def get_duration(self):
+ self.pipe.get_state()
+ try:
+ return self.pipe.query_duration(
+ gst.FORMAT_TIME, None)[0] // gst.MSECOND
+ except gst.QueryError:
+ return None
+
+ def next_uri(self):
+ if not self.uris:
+ return self.stop()
+
+ self.pipe.set_state(gst.STATE_NULL)
+ self.uribin.set_property('uri', self.uris.pop())
+ self.pipe.set_state(gst.STATE_PAUSED)
+
+ def start(self):
+ if not self.uris:
+ return
+ self.next_uri()
+ self.loop.run()
+
+ def stop(self):
+ self.pipe.set_state(gst.STATE_NULL)
+ self.loop.quit()
diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py
index 0dd163ec..b3669e38 100644
--- a/mopidy/utils/path.py
+++ b/mopidy/utils/path.py
@@ -1,6 +1,7 @@
import logging
import os
import sys
+import re
import urllib
logger = logging.getLogger('mopidy.utils.path')
@@ -26,3 +27,53 @@ def path_to_uri(*paths):
if sys.platform == 'win32':
return 'file:' + urllib.pathname2url(path)
return 'file://' + urllib.pathname2url(path)
+
+def uri_to_path(uri):
+ if sys.platform == 'win32':
+ path = urllib.url2pathname(re.sub('^file:', '', uri))
+ else:
+ path = urllib.url2pathname(re.sub('^file://', '', uri))
+ return path.encode('latin1').decode('utf-8') # Undo double encoding
+
+def split_path(path):
+ parts = []
+ while True:
+ path, part = os.path.split(path)
+ if part:
+ parts.insert(0, part)
+ if not path or path == '/':
+ break
+ return parts
+
+def find_files(path):
+ path = os.path.expanduser(path)
+ if os.path.isfile(path):
+ filename = os.path.abspath(path)
+ if not isinstance(filename, unicode):
+ filename = filename.decode('utf-8')
+ yield filename
+ else:
+ for dirpath, dirnames, filenames in os.walk(path):
+ for filename in filenames:
+ dirpath = os.path.abspath(dirpath)
+ filename = os.path.join(dirpath, filename)
+ if not isinstance(filename, unicode):
+ filename = filename.decode('utf-8')
+ yield filename
+
+class Mtime(object):
+ def __init__(self):
+ self.fake = None
+
+ def __call__(self, path):
+ if self.fake is not None:
+ return self.fake
+ return int(os.stat(path).st_mtime)
+
+ def set_fake_time(self, time):
+ self.fake = time
+
+ def undo_fake(self):
+ self.fake = None
+
+mtime = Mtime()
diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py
index c34d018c..11dafa8a 100644
--- a/mopidy/utils/process.py
+++ b/mopidy/utils/process.py
@@ -4,6 +4,9 @@ import multiprocessing.dummy
from multiprocessing.reduction import reduce_connection
import pickle
+import gobject
+gobject.threads_init()
+
from mopidy import SettingsError
logger = logging.getLogger('mopidy.utils.process')
@@ -84,3 +87,25 @@ class BaseThread(multiprocessing.dummy.Process):
self.core_queue.put({'to': 'core', 'command': 'exit',
'status': status, 'reason': reason})
self.destroy()
+
+
+class GObjectEventThread(BaseThread):
+ """
+ A GObject event loop which is shared by all Mopidy components that uses
+ libraries that need a GObject event loop, like GStreamer and D-Bus.
+
+ Should be started by Mopidy's core and used by
+ :mod:`mopidy.output.gstreamer`, :mod:`mopidy.frontend.mpris`, etc.
+ """
+
+ def __init__(self, core_queue):
+ super(GObjectEventThread, self).__init__(core_queue)
+ self.name = u'GObjectEventThread'
+ self.loop = None
+
+ def run_inside_try(self):
+ self.loop = gobject.MainLoop().run()
+
+ def destroy(self):
+ self.loop.quit()
+ super(GObjectEventThread, self).destroy()
diff --git a/setup.py b/setup.py
index fabc8353..d77be3cd 100644
--- a/setup.py
+++ b/setup.py
@@ -69,6 +69,8 @@ for dirpath, dirnames, filenames in os.walk(project_dir):
data_files.append([dirpath,
[os.path.join(dirpath, f) for f in filenames]])
+data_files.append(('/usr/local/share/applications', ['data/mopidy.desktop']))
+
setup(
name='Mopidy',
version=get_version(),
@@ -78,7 +80,7 @@ setup(
package_data={'mopidy': ['backends/libspotify/spotify_appkey.key']},
cmdclass=cmdclasses,
data_files=data_files,
- scripts=['bin/mopidy'],
+ scripts=['bin/mopidy', 'bin/mopidy-scan'],
url='http://www.mopidy.com/',
license='Apache License, Version 2.0',
description='MPD server with Spotify support',
diff --git a/tests/data/blank.mp3 b/tests/data/blank.mp3
index 6aa48cd8..ef159a70 100644
Binary files a/tests/data/blank.mp3 and b/tests/data/blank.mp3 differ
diff --git a/tests/data/scanner/advanced/song1.mp3 b/tests/data/scanner/advanced/song1.mp3
new file mode 120000
index 00000000..6896a7a2
--- /dev/null
+++ b/tests/data/scanner/advanced/song1.mp3
@@ -0,0 +1 @@
+../sample.mp3
\ No newline at end of file
diff --git a/tests/data/scanner/advanced/song2.mp3 b/tests/data/scanner/advanced/song2.mp3
new file mode 120000
index 00000000..6896a7a2
--- /dev/null
+++ b/tests/data/scanner/advanced/song2.mp3
@@ -0,0 +1 @@
+../sample.mp3
\ No newline at end of file
diff --git a/tests/data/scanner/advanced/song3.mp3 b/tests/data/scanner/advanced/song3.mp3
new file mode 120000
index 00000000..6896a7a2
--- /dev/null
+++ b/tests/data/scanner/advanced/song3.mp3
@@ -0,0 +1 @@
+../sample.mp3
\ No newline at end of file
diff --git a/tests/data/scanner/advanced/subdir1/song4.mp3 b/tests/data/scanner/advanced/subdir1/song4.mp3
new file mode 120000
index 00000000..45812ac5
--- /dev/null
+++ b/tests/data/scanner/advanced/subdir1/song4.mp3
@@ -0,0 +1 @@
+../../sample.mp3
\ No newline at end of file
diff --git a/tests/data/scanner/advanced/subdir1/song5.mp3 b/tests/data/scanner/advanced/subdir1/song5.mp3
new file mode 120000
index 00000000..45812ac5
--- /dev/null
+++ b/tests/data/scanner/advanced/subdir1/song5.mp3
@@ -0,0 +1 @@
+../../sample.mp3
\ No newline at end of file
diff --git a/tests/data/scanner/advanced/subdir1/subsubdir/song8.mp3 b/tests/data/scanner/advanced/subdir1/subsubdir/song8.mp3
new file mode 120000
index 00000000..45812ac5
--- /dev/null
+++ b/tests/data/scanner/advanced/subdir1/subsubdir/song8.mp3
@@ -0,0 +1 @@
+../../sample.mp3
\ No newline at end of file
diff --git a/tests/data/scanner/advanced/subdir1/subsubdir/song9.mp3 b/tests/data/scanner/advanced/subdir1/subsubdir/song9.mp3
new file mode 120000
index 00000000..45812ac5
--- /dev/null
+++ b/tests/data/scanner/advanced/subdir1/subsubdir/song9.mp3
@@ -0,0 +1 @@
+../../sample.mp3
\ No newline at end of file
diff --git a/tests/data/scanner/advanced/subdir2/song6.mp3 b/tests/data/scanner/advanced/subdir2/song6.mp3
new file mode 120000
index 00000000..45812ac5
--- /dev/null
+++ b/tests/data/scanner/advanced/subdir2/song6.mp3
@@ -0,0 +1 @@
+../../sample.mp3
\ No newline at end of file
diff --git a/tests/data/scanner/advanced/subdir2/song7.mp3 b/tests/data/scanner/advanced/subdir2/song7.mp3
new file mode 120000
index 00000000..45812ac5
--- /dev/null
+++ b/tests/data/scanner/advanced/subdir2/song7.mp3
@@ -0,0 +1 @@
+../../sample.mp3
\ No newline at end of file
diff --git a/tests/data/scanner/advanced_cache b/tests/data/scanner/advanced_cache
new file mode 100644
index 00000000..60f7fca6
--- /dev/null
+++ b/tests/data/scanner/advanced_cache
@@ -0,0 +1,81 @@
+info_begin
+mpd_version: 0.15.4
+fs_charset: UTF-8
+info_end
+directory: subdir1
+mtime: 1288121499
+begin: subdir1
+songList begin
+key: song4.mp3
+file: subdir1/song4.mp3
+Time: 5
+Artist: name
+Title: trackname
+Album: albumname
+Track: 01/02
+Date: 2006
+mtime: 1288121370
+key: song5.mp3
+file: subdir1/song5.mp3
+Time: 5
+Artist: name
+Title: trackname
+Album: albumname
+Track: 01/02
+Date: 2006
+mtime: 1288121370
+songList end
+end: subdir1
+directory: subdir2
+mtime: 1288121499
+begin: subdir2
+songList begin
+key: song6.mp3
+file: subdir2/song6.mp3
+Time: 5
+Artist: name
+Title: trackname
+Album: albumname
+Track: 01/02
+Date: 2006
+mtime: 1288121370
+key: song7.mp3
+file: subdir2/song7.mp3
+Time: 5
+Artist: name
+Title: trackname
+Album: albumname
+Track: 01/02
+Date: 2006
+mtime: 1288121370
+songList end
+end: subdir2
+songList begin
+key: song1.mp3
+file: /song1.mp3
+Time: 5
+Artist: name
+Title: trackname
+Album: albumname
+Track: 01/02
+Date: 2006
+mtime: 1288121370
+key: song2.mp3
+file: /song2.mp3
+Time: 5
+Artist: name
+Title: trackname
+Album: albumname
+Track: 01/02
+Date: 2006
+mtime: 1288121370
+key: song3.mp3
+file: /song3.mp3
+Time: 5
+Artist: name
+Title: trackname
+Album: albumname
+Track: 01/02
+Date: 2006
+mtime: 1288121370
+songList end
diff --git a/tests/data/scanner/empty/.gitignore b/tests/data/scanner/empty/.gitignore
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/data/scanner/empty_cache b/tests/data/scanner/empty_cache
new file mode 100644
index 00000000..3c466a32
--- /dev/null
+++ b/tests/data/scanner/empty_cache
@@ -0,0 +1,6 @@
+info_begin
+mpd_version: 0.15.4
+fs_charset: UTF-8
+info_end
+songList begin
+songList end
diff --git a/tests/data/scanner/image/test.png b/tests/data/scanner/image/test.png
new file mode 100644
index 00000000..2aaf9c3d
Binary files /dev/null and b/tests/data/scanner/image/test.png differ
diff --git a/tests/data/scanner/sample.mp3 b/tests/data/scanner/sample.mp3
new file mode 100644
index 00000000..ad5aa37a
Binary files /dev/null and b/tests/data/scanner/sample.mp3 differ
diff --git a/tests/data/scanner/simple/song1.mp3 b/tests/data/scanner/simple/song1.mp3
new file mode 120000
index 00000000..6896a7a2
--- /dev/null
+++ b/tests/data/scanner/simple/song1.mp3
@@ -0,0 +1 @@
+../sample.mp3
\ No newline at end of file
diff --git a/tests/data/scanner/simple_cache b/tests/data/scanner/simple_cache
new file mode 100644
index 00000000..db11c324
--- /dev/null
+++ b/tests/data/scanner/simple_cache
@@ -0,0 +1,15 @@
+info_begin
+mpd_version: 0.15.4
+fs_charset: UTF-8
+info_end
+songList begin
+key: song1.mp3
+file: /song1.mp3
+Time: 5
+Artist: name
+Title: trackname
+Album: albumname
+Track: 01/02
+Date: 2006
+mtime: 1288121370
+songList end
diff --git a/tests/frontends/mpd/serializer_test.py b/tests/frontends/mpd/serializer_test.py
index 0e0f8183..8e8a5d21 100644
--- a/tests/frontends/mpd/serializer_test.py
+++ b/tests/frontends/mpd/serializer_test.py
@@ -1,11 +1,24 @@
import datetime as dt
+import os
import unittest
-from mopidy.frontends.mpd import translator
+from mopidy import settings
+from mopidy.utils.path import mtime
+from mopidy.frontends.mpd import translator, protocol
from mopidy.models import Album, Artist, Playlist, Track
+from tests import data_folder, SkipTest
+
class TrackMpdFormatTest(unittest.TestCase):
- def test_mpd_format_for_empty_track(self):
+ def setUp(self):
+ settings.LOCAL_MUSIC_FOLDER = '/dir/subdir'
+ mtime.set_fake_time(1234567)
+
+ def tearDown(self):
+ settings.runtime.clear()
+ mtime.undo_fake()
+
+ def test_track_to_mpd_format_for_empty_track(self):
result = translator.track_to_mpd_format(Track())
self.assert_(('file', '') in result)
self.assert_(('Time', 0) in result)
@@ -14,13 +27,43 @@ class TrackMpdFormatTest(unittest.TestCase):
self.assert_(('Album', '') in result)
self.assert_(('Track', 0) in result)
self.assert_(('Date', '') in result)
+ self.assertEqual(len(result), 7)
- def test_mpd_format_for_nonempty_track(self):
+ def test_track_to_mpd_format_with_position(self):
+ result = translator.track_to_mpd_format(Track(), position=1)
+ self.assert_(('Pos', 1) not in result)
+
+ def test_track_to_mpd_format_with_cpid(self):
+ result = translator.track_to_mpd_format(Track(), cpid=1)
+ self.assert_(('Id', 1) not in result)
+
+ def test_track_to_mpd_format_with_position_and_cpid(self):
+ result = translator.track_to_mpd_format(Track(), position=1, cpid=2)
+ self.assert_(('Pos', 1) in result)
+ self.assert_(('Id', 2) in result)
+
+ def test_track_to_mpd_format_with_key(self):
+ track = Track(uri='file:///dir/subdir/file.mp3')
+ result = translator.track_to_mpd_format(track, key=True)
+ self.assert_(('key', 'file.mp3') in result)
+
+ def test_track_to_mpd_format_with_key_not_uri_encoded(self):
+ track = Track(uri='file:///dir/subdir/file%20test.mp3')
+ result = translator.track_to_mpd_format(track, key=True)
+ self.assert_(('key', 'file test.mp3') in result)
+
+ def test_track_to_mpd_format_with_mtime(self):
+ uri = translator.path_to_uri(data_folder('blank.mp3'))
+ result = translator.track_to_mpd_format(Track(uri=uri), mtime=True)
+ self.assert_(('mtime', 1234567) in result)
+
+ def test_track_to_mpd_format_for_nonempty_track(self):
track = Track(
uri=u'a uri',
artists=[Artist(name=u'an artist')],
name=u'a name',
- album=Album(name=u'an album', num_tracks=13),
+ album=Album(name=u'an album', num_tracks=13,
+ artists=[Artist(name=u'an other artist')]),
track_no=7,
date=dt.date(1977, 1, 1),
length=137000,
@@ -31,15 +74,17 @@ class TrackMpdFormatTest(unittest.TestCase):
self.assert_(('Artist', 'an artist') in result)
self.assert_(('Title', 'a name') in result)
self.assert_(('Album', 'an album') in result)
+ self.assert_(('AlbumArtist', 'an other artist') in result)
self.assert_(('Track', '7/13') in result)
self.assert_(('Date', dt.date(1977, 1, 1)) in result)
self.assert_(('Pos', 9) in result)
self.assert_(('Id', 122) in result)
+ self.assertEqual(len(result), 10)
- def test_mpd_format_artists(self):
- track = Track(artists=[Artist(name=u'ABBA'), Artist(name=u'Beatles')])
- self.assertEqual(translator.track_artists_to_mpd_format(track),
- u'ABBA, Beatles')
+ def test_artists_to_mpd_format(self):
+ artists = [Artist(name=u'ABBA'), Artist(name=u'Beatles')]
+ translated = translator.artists_to_mpd_format(artists)
+ self.assertEqual(translated, u'ABBA, Beatles')
class PlaylistMpdFormatTest(unittest.TestCase):
@@ -55,3 +100,234 @@ class PlaylistMpdFormatTest(unittest.TestCase):
result = translator.playlist_to_mpd_format(playlist, 1, 2)
self.assertEqual(len(result), 1)
self.assertEqual(dict(result[0])['Track'], 2)
+
+
+class TracksToTagCacheFormatTest(unittest.TestCase):
+ def setUp(self):
+ settings.LOCAL_MUSIC_FOLDER = '/dir/subdir'
+ mtime.set_fake_time(1234567)
+
+ def tearDown(self):
+ settings.runtime.clear()
+ mtime.undo_fake()
+
+ def translate(self, track):
+ result = translator.track_to_mpd_format(track, key=True, mtime=True)
+ return translator.order_mpd_track_info(result)
+
+ def consume_headers(self, result):
+ self.assertEqual(('info_begin',), result[0])
+ self.assertEqual(('mpd_version', protocol.VERSION), result[1])
+ self.assertEqual(('fs_charset', protocol.ENCODING), result[2])
+ self.assertEqual(('info_end',), result[3])
+ return result[4:]
+
+ def consume_song_list(self, result):
+ self.assertEqual(('songList begin',), result[0])
+ for i, row in enumerate(result):
+ if row == ('songList end',):
+ return result[1:i], result[i+1:]
+ self.fail("Couldn't find songList end in result")
+
+ def consume_directory(self, result):
+ self.assertEqual('directory', result[0][0])
+ self.assertEqual(('mtime', mtime('.')), result[1])
+ self.assertEqual(('begin', os.path.split(result[0][1])[1]), result[2])
+ directory = result[2][1]
+ for i, row in enumerate(result):
+ if row == ('end', directory):
+ return result[3:i], result[i+1:]
+ self.fail("Couldn't find end %s in result" % directory)
+
+ def test_empty_tag_cache_has_header(self):
+ result = translator.tracks_to_tag_cache_format([])
+ result = self.consume_headers(result)
+
+ def test_empty_tag_cache_has_song_list(self):
+ result = translator.tracks_to_tag_cache_format([])
+ result = self.consume_headers(result)
+ song_list, result = self.consume_song_list(result)
+
+ self.assertEqual(len(song_list), 0)
+ self.assertEqual(len(result), 0)
+
+ def test_tag_cache_has_header(self):
+ track = Track(uri='file:///dir/subdir/song.mp3')
+ result = translator.tracks_to_tag_cache_format([track])
+ result = self.consume_headers(result)
+
+ def test_tag_cache_has_song_list(self):
+ track = Track(uri='file:///dir/subdir/song.mp3')
+ result = translator.tracks_to_tag_cache_format([track])
+ result = self.consume_headers(result)
+ song_list, result = self.consume_song_list(result)
+
+ self.assert_(song_list)
+ self.assertEqual(len(result), 0)
+
+ def test_tag_cache_has_formated_track(self):
+ track = Track(uri='file:///dir/subdir/song.mp3')
+ formated = self.translate(track)
+ result = translator.tracks_to_tag_cache_format([track])
+
+ result = self.consume_headers(result)
+ song_list, result = self.consume_song_list(result)
+
+ self.assertEqual(song_list, formated)
+ self.assertEqual(len(result), 0)
+
+ def test_tag_cache_has_formated_track_with_key_and_mtime(self):
+ track = Track(uri='file:///dir/subdir/song.mp3')
+ formated = self.translate(track)
+ result = translator.tracks_to_tag_cache_format([track])
+
+ result = self.consume_headers(result)
+ song_list, result = self.consume_song_list(result)
+
+ self.assertEqual(song_list, formated)
+ self.assertEqual(len(result), 0)
+
+ def test_tag_cache_suports_directories(self):
+ track = Track(uri='file:///dir/subdir/folder/song.mp3')
+ formated = self.translate(track)
+ result = translator.tracks_to_tag_cache_format([track])
+
+ result = self.consume_headers(result)
+ folder, result = self.consume_directory(result)
+ song_list, result = self.consume_song_list(result)
+ self.assertEqual(len(song_list), 0)
+ self.assertEqual(len(result), 0)
+
+ song_list, result = self.consume_song_list(folder)
+ self.assertEqual(len(result), 0)
+ self.assertEqual(song_list, formated)
+
+ def test_tag_cache_diretory_header_is_right(self):
+ track = Track(uri='file:///dir/subdir/folder/sub/song.mp3')
+ formated = self.translate(track)
+ result = translator.tracks_to_tag_cache_format([track])
+
+ result = self.consume_headers(result)
+ folder, result = self.consume_directory(result)
+
+ self.assertEqual(('directory', 'folder/sub'), folder[0])
+ self.assertEqual(('mtime', mtime('.')), folder[1])
+ self.assertEqual(('begin', 'sub'), folder[2])
+
+ def test_tag_cache_suports_sub_directories(self):
+ track = Track(uri='file:///dir/subdir/folder/sub/song.mp3')
+ formated = self.translate(track)
+ result = translator.tracks_to_tag_cache_format([track])
+
+ result = self.consume_headers(result)
+
+ folder, result = self.consume_directory(result)
+ song_list, result = self.consume_song_list(result)
+ self.assertEqual(len(song_list), 0)
+ self.assertEqual(len(result), 0)
+
+ folder, result = self.consume_directory(folder)
+ song_list, result = self.consume_song_list(result)
+ self.assertEqual(len(result), 0)
+ self.assertEqual(len(song_list), 0)
+
+ song_list, result = self.consume_song_list(folder)
+ self.assertEqual(len(result), 0)
+ self.assertEqual(song_list, formated)
+
+ def test_tag_cache_supports_multiple_tracks(self):
+ tracks = [
+ Track(uri='file:///dir/subdir/song1.mp3'),
+ Track(uri='file:///dir/subdir/song2.mp3'),
+ ]
+
+ formated = []
+ formated.extend(self.translate(tracks[0]))
+ formated.extend(self.translate(tracks[1]))
+
+ result = translator.tracks_to_tag_cache_format(tracks)
+
+ result = self.consume_headers(result)
+ song_list, result = self.consume_song_list(result)
+
+ self.assertEqual(song_list, formated)
+ self.assertEqual(len(result), 0)
+
+ def test_tag_cache_supports_multiple_tracks_in_dirs(self):
+ tracks = [
+ Track(uri='file:///dir/subdir/song1.mp3'),
+ Track(uri='file:///dir/subdir/folder/song2.mp3'),
+ ]
+
+ formated = []
+ formated.append(self.translate(tracks[0]))
+ formated.append(self.translate(tracks[1]))
+
+ result = translator.tracks_to_tag_cache_format(tracks)
+
+ result = self.consume_headers(result)
+ folder, result = self.consume_directory(result)
+ song_list, song_result = self.consume_song_list(folder)
+
+ self.assertEqual(song_list, formated[1])
+ self.assertEqual(len(song_result), 0)
+
+ song_list, result = self.consume_song_list(result)
+ self.assertEqual(len(result), 0)
+ self.assertEqual(song_list, formated[0])
+
+
+class TracksToDirectoryTreeTest(unittest.TestCase):
+ def setUp(self):
+ settings.LOCAL_MUSIC_FOLDER = '/root/'
+
+ def tearDown(self):
+ settings.runtime.clear()
+
+ def test_no_tracks_gives_emtpy_tree(self):
+ tree = translator.tracks_to_directory_tree([])
+ self.assertEqual(tree, ({}, []))
+
+ def test_top_level_files(self):
+ tracks = [
+ Track(uri='file:///root/file1.mp3'),
+ Track(uri='file:///root/file2.mp3'),
+ Track(uri='file:///root/file3.mp3'),
+ ]
+ tree = translator.tracks_to_directory_tree(tracks)
+ self.assertEqual(tree, ({}, tracks))
+
+ def test_single_file_in_subdir(self):
+ tracks = [Track(uri='file:///root/dir/file1.mp3')]
+ tree = translator.tracks_to_directory_tree(tracks)
+ expected = ({'dir': ({}, tracks)}, [])
+ self.assertEqual(tree, expected)
+
+ def test_single_file_in_sub_subdir(self):
+ tracks = [Track(uri='file:///root/dir1/dir2/file1.mp3')]
+ tree = translator.tracks_to_directory_tree(tracks)
+ expected = ({'dir1': ({'dir1/dir2': ({}, tracks)}, [])}, [])
+ self.assertEqual(tree, expected)
+
+ def test_complex_file_structure(self):
+ tracks = [
+ Track(uri='file:///root/file1.mp3'),
+ Track(uri='file:///root/dir1/file2.mp3'),
+ Track(uri='file:///root/dir1/file3.mp3'),
+ Track(uri='file:///root/dir2/file4.mp3'),
+ Track(uri='file:///root/dir2/sub/file5.mp3'),
+ ]
+ tree = translator.tracks_to_directory_tree(tracks)
+ expected = (
+ {
+ 'dir1': ({}, [tracks[1], tracks[2]]),
+ 'dir2': (
+ {
+ 'dir2/sub': ({}, [tracks[4]])
+ },
+ [tracks[3]]
+ ),
+ },
+ [tracks[0]]
+ )
+ self.assertEqual(tree, expected)
diff --git a/tests/scanner_test.py b/tests/scanner_test.py
new file mode 100644
index 00000000..141f2ceb
--- /dev/null
+++ b/tests/scanner_test.py
@@ -0,0 +1,158 @@
+import unittest
+from datetime import date
+
+from mopidy.scanner import Scanner, translator
+from mopidy.models import Track, Artist, Album
+
+from tests import data_folder
+
+class FakeGstDate(object):
+ def __init__(self, year, month, day):
+ self.year = year
+ self.month = month
+ self.day = day
+
+class TranslatorTest(unittest.TestCase):
+ def setUp(self):
+ self.data = {
+ 'uri': 'uri',
+ 'album': u'albumname',
+ 'track-number': 1,
+ 'artist': u'name',
+ 'album-artist': 'albumartistname',
+ 'title': u'trackname',
+ 'track-count': 2,
+ 'date': FakeGstDate(2006, 1, 1,),
+ 'container-format': u'ID3 tag',
+ 'duration': 4531,
+ }
+
+ self.album = {
+ 'name': 'albumname',
+ 'num_tracks': 2,
+ }
+
+ self.artist = {
+ 'name': 'name',
+ }
+
+ self.albumartist = {
+ 'name': 'albumartistname',
+ }
+
+ self.track = {
+ 'uri': 'uri',
+ 'name': 'trackname',
+ 'date': date(2006, 1, 1),
+ 'track_no': 1,
+ 'length': 4531,
+ }
+
+ def build_track(self):
+ if self.albumartist:
+ self.album['artists'] = [Artist(**self.albumartist)]
+ self.track['album'] = Album(**self.album)
+ self.track['artists'] = [Artist(**self.artist)]
+ return Track(**self.track)
+
+ def check(self):
+ expected = self.build_track()
+ actual = translator(self.data)
+ self.assertEqual(expected, actual)
+
+ def test_basic_data(self):
+ self.check()
+
+ def test_missing_track_number(self):
+ del self.data['track-number']
+ del self.track['track_no']
+ self.check()
+
+ def test_missing_track_count(self):
+ del self.data['track-count']
+ del self.album['num_tracks']
+ self.check()
+
+ def test_missing_track_name(self):
+ del self.data['title']
+ del self.track['name']
+ self.check()
+
+ def test_missing_album_name(self):
+ del self.data['album']
+ del self.album['name']
+ self.check()
+
+ def test_missing_artist_name(self):
+ del self.data['artist']
+ del self.artist['name']
+ self.check()
+
+ def test_missing_album_artist(self):
+ del self.data['album-artist']
+ del self.albumartist['name']
+ self.check()
+
+ def test_missing_date(self):
+ del self.data['date']
+ del self.track['date']
+ self.check()
+
+class ScannerTest(unittest.TestCase):
+ def setUp(self):
+ self.errors = {}
+ self.data = {}
+
+ def scan(self, path):
+ scanner = Scanner(data_folder(path),
+ self.data_callback, self.error_callback)
+ scanner.start()
+
+ def check(self, name, key, value):
+ name = data_folder(name)
+ self.assertEqual(self.data[name][key], value)
+
+ def data_callback(self, data):
+ uri = data['uri'][len('file://'):]
+ self.data[uri] = data
+
+ def error_callback(self, uri, errors):
+ uri = uri[len('file://'):]
+ self.errors[uri] = errors
+
+ def test_data_is_set(self):
+ self.scan('scanner/simple')
+ self.assert_(self.data)
+
+ def test_errors_is_not_set(self):
+ self.scan('scanner/simple')
+ self.assert_(not self.errors)
+
+ def test_uri_is_set(self):
+ self.scan('scanner/simple')
+ self.check('scanner/simple/song1.mp3', 'uri', 'file://'
+ + data_folder('scanner/simple/song1.mp3'))
+
+ def test_duration_is_set(self):
+ self.scan('scanner/simple')
+ self.check('scanner/simple/song1.mp3', 'duration', 4680)
+
+ def test_artist_is_set(self):
+ self.scan('scanner/simple')
+ self.check('scanner/simple/song1.mp3', 'artist', 'name')
+
+ def test_album_is_set(self):
+ self.scan('scanner/simple')
+ self.check('scanner/simple/song1.mp3', 'album', 'albumname')
+
+ def test_track_is_set(self):
+ self.scan('scanner/simple')
+ self.check('scanner/simple/song1.mp3', 'title', 'trackname')
+
+ def test_nonexistant_folder_does_not_fail(self):
+ self.scan('scanner/does-not-exist')
+ self.assert_(not self.errors)
+
+ def test_other_media_is_ignored(self):
+ self.scan('scanner/image')
+ self.assert_(self.errors)
diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py
index ae63d5c0..758a09ab 100644
--- a/tests/utils/path_test.py
+++ b/tests/utils/path_test.py
@@ -6,9 +6,10 @@ import sys
import tempfile
import unittest
-from mopidy.utils.path import get_or_create_folder, path_to_uri
+from mopidy.utils.path import (get_or_create_folder, mtime,
+ path_to_uri, uri_to_path, split_path, find_files)
-from tests import SkipTest
+from tests import SkipTest, data_folder
class GetOrCreateFolderTest(unittest.TestCase):
def setUp(self):
@@ -69,3 +70,87 @@ class PathToFileURITest(unittest.TestCase):
else:
result = path_to_uri(u'/tmp/æøå')
self.assertEqual(result, u'file:///tmp/%C3%A6%C3%B8%C3%A5')
+
+
+class UriToPathTest(unittest.TestCase):
+ def test_simple_uri(self):
+ if sys.platform == 'win32':
+ result = uri_to_path('file:///C://WINDOWS/clock.avi')
+ self.assertEqual(result, u'C:/WINDOWS/clock.avi')
+ else:
+ result = uri_to_path('file:///etc/fstab')
+ self.assertEqual(result, u'/etc/fstab')
+
+ def test_space_in_uri(self):
+ if sys.platform == 'win32':
+ result = uri_to_path('file:///C://test%20this')
+ self.assertEqual(result, u'C:/test this')
+ else:
+ result = uri_to_path(u'file:///tmp/test%20this')
+ self.assertEqual(result, u'/tmp/test this')
+
+ def test_unicode_in_uri(self):
+ if sys.platform == 'win32':
+ result = uri_to_path( 'file:///C://%C3%A6%C3%B8%C3%A5')
+ self.assertEqual(result, u'C:/æøå')
+ else:
+ result = uri_to_path(u'file:///tmp/%C3%A6%C3%B8%C3%A5')
+ self.assertEqual(result, u'/tmp/æøå')
+
+
+class SplitPathTest(unittest.TestCase):
+ def test_empty_path(self):
+ self.assertEqual([], split_path(''))
+
+ def test_single_folder(self):
+ self.assertEqual(['foo'], split_path('foo'))
+
+ def test_folders(self):
+ self.assertEqual(['foo', 'bar', 'baz'], split_path('foo/bar/baz'))
+
+ def test_folders(self):
+ self.assertEqual(['foo', 'bar', 'baz'], split_path('foo/bar/baz'))
+
+ def test_initial_slash_is_ignored(self):
+ self.assertEqual(['foo', 'bar', 'baz'], split_path('/foo/bar/baz'))
+
+ def test_only_slash(self):
+ self.assertEqual([], split_path('/'))
+
+
+class FindFilesTest(unittest.TestCase):
+ def find(self, path):
+ return list(find_files(data_folder(path)))
+
+ def test_basic_folder(self):
+ self.assert_(self.find(''))
+
+ def test_nonexistant_folder(self):
+ self.assertEqual(self.find('does-not-exist'), [])
+
+ def test_file(self):
+ files = self.find('blank.mp3')
+ self.assertEqual(len(files), 1)
+ self.assert_(files[0], data_folder('blank.mp3'))
+
+ def test_names_are_unicode(self):
+ is_unicode = lambda f: isinstance(f, unicode)
+ for name in self.find(''):
+ self.assert_(is_unicode(name),
+ '%s is not unicode object' % repr(name))
+
+ def test_expanduser(self):
+ raise SkipTest
+
+
+class MtimeTest(unittest.TestCase):
+ def tearDown(self):
+ mtime.undo_fake()
+
+ def test_mtime_of_current_dir(self):
+ mtime_dir = int(os.stat('.').st_mtime)
+ self.assertEqual(mtime_dir, mtime('.'))
+
+ def test_fake_time_is_returned(self):
+ mtime.set_fake_time(123456)
+ self.assertEqual(mtime('.'), 123456)