diff --git a/docs/api/audio.rst b/docs/api/audio.rst index 550ca890..76389fb4 100644 --- a/docs/api/audio.rst +++ b/docs/api/audio.rst @@ -35,3 +35,9 @@ Audio scanner .. autoclass:: mopidy.audio.scan.Scanner :members: + +Audio utils +=========== + +.. automodule:: mopidy.audio.utils + :members: diff --git a/docs/changelog.rst b/docs/changelog.rst index c1428ce4..a4d5ee44 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -63,12 +63,35 @@ v0.20.0 (UNRELEASED) - Add foundation for trying to re-add multiple output support. + - Add internal helper for converting GStreamer data types to Python. + + - Move MusicBrainz coverart code out of audio and into local. + + - Reduce scope of audio scanner to just tags + duration. Mtime, uri and min + length handling are now outside of this class. + + - Update scanner to operate with milliseconds for duration. + +- Add :meth:`mopidy.audio.AudioListener.tags_changed`. Notifies core when new tags + are found. + +- Add :meth:`mopidy.audio.Audio.get_current_tags` for looking up the current + tags of the playing media. + +- Move and rename helper for converting tags to tracks. + + - Helper now ignores albums without a name. + - Kill support for visualizers. Feature was originally added as a workaround for all the people asking for ncmpcpp visualizer support. And since we could get it almost for free thanks to GStreamer. But this feature didn't really ever make sense for a server such as Mopidy. Currently the only way to find out if it is in use and will be missed is to go ahead and remove it. +**Stream backend** + +- Add basic tests for the stream library provider. + v0.19.5 (2014-12-23) ==================== diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 190895dc..63c6a80b 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -303,6 +303,8 @@ class _Handler(object): self.on_warning(*msg.parse_warning()) elif msg.type == gst.MESSAGE_ASYNC_DONE: self.on_async_done() + elif msg.type == gst.MESSAGE_TAG: + self.on_tag(msg.parse_tag()) elif msg.type == gst.MESSAGE_ELEMENT: if gst.pbutils.is_missing_plugin_message(msg): self.on_missing_plugin(_get_missing_description(msg), @@ -370,6 +372,7 @@ class _Handler(object): def on_end_of_stream(self): gst_logger.debug('Got end-of-stream message.') logger.debug('Audio event: reached_end_of_stream()') + self._audio._tags = {} AudioListener.send('reached_end_of_stream') def on_error(self, error, debug): @@ -387,6 +390,12 @@ class _Handler(object): def on_async_done(self): gst_logger.debug('Got async-done.') + def on_tag(self, taglist): + tags = utils.convert_taglist(taglist) + self._audio._tags.update(tags) + logger.debug('Audio event: tags_changed(tags=%r)', tags.keys()) + AudioListener.send('tags_changed', tags=tags.keys()) + def on_missing_plugin(self, msg): desc = gst.pbutils.missing_plugin_message_get_description(msg) debug = gst.pbutils.missing_plugin_message_get_installer_detail(msg) @@ -401,7 +410,7 @@ class _Handler(object): # required helper installed? def on_new_segment(self, update, rate, format_, start, stop, position): - gst_logger.debug('Got new-segment event: update=%s rate=%s format=%s' + gst_logger.debug('Got new-segment event: update=%s rate=%s format=%s ' 'start=%s stop=%s position=%s', update, rate, format_.value_name, start, stop, position) position_ms = position // gst.MSECOND @@ -432,6 +441,7 @@ class Audio(pykka.ThreadingActor): self._config = config self._target_state = gst.STATE_NULL self._buffering = False + self._tags = {} self._playbin = None self._outputs = None @@ -538,6 +548,7 @@ class Audio(pykka.ThreadingActor): :param uri: the URI to play :type uri: string """ + self._tags = {} # TODO: add test for this somehow self._playbin.set_property('uri', uri) def set_appsrc( @@ -725,6 +736,7 @@ class Audio(pykka.ThreadingActor): # of faking it in the message handling when result=OK return True + # TODO: bake this into setup appsrc perhaps? def set_metadata(self, track): """ Set track metadata for currently playing song. @@ -755,5 +767,22 @@ class Audio(pykka.ThreadingActor): taglist[gst.TAG_ALBUM] = track.album.name event = gst.event_new_tag(taglist) + # TODO: check if we get this back on our own bus? self._playbin.send_event(event) gst_logger.debug('Sent tag event: track=%s', track.uri) + + def get_current_tags(self): + """ + Get the currently playing media's tags. + + If no tags have been found, or nothing is playing this returns an empty + dictionary. For each set of tags we collect a tags_changed event is + emitted with the keys of the changes tags. After such calls users may + call this function to get the updated values. + + :rtype: {key: [values]} dict for the current media. + """ + # TODO: should this be a (deep) copy? most likely yes + # TODO: should we return None when stopped? + # TODO: support only fetching keys we care about? + return self._tags diff --git a/mopidy/audio/dummy.py b/mopidy/audio/dummy.py index f7fa9f0d..95b9d0fb 100644 --- a/mopidy/audio/dummy.py +++ b/mopidy/audio/dummy.py @@ -21,9 +21,11 @@ class DummyAudio(pykka.ThreadingActor): self._callback = None self._uri = None self._state_change_result = True + self._tags = {} def set_uri(self, uri): assert self._uri is None, 'prepare change not called before set' + self._tags = {} self._uri = uri def set_appsrc(self, *args, **kwargs): @@ -66,6 +68,9 @@ class DummyAudio(pykka.ThreadingActor): def set_metadata(self, track): pass + def get_current_tags(self): + return self._tags + def set_about_to_finish_callback(self, callback): self._callback = callback @@ -91,6 +96,10 @@ class DummyAudio(pykka.ThreadingActor): AudioListener.send('state_changed', old_state=old_state, new_state=new_state, target_state=None) + if new_state == PlaybackState.PLAYING: + self._tags['audio-codec'] = [u'fake info...'] + AudioListener.send('tags_changed', tags=['audio-codec']) + return self._state_change_result def trigger_fake_playback_failure(self): @@ -104,6 +113,7 @@ class DummyAudio(pykka.ThreadingActor): self._callback() if not self._uri or not self._callback: + self._tags = {} AudioListener.send('reached_end_of_stream') else: AudioListener.send('position_changed', position=0) diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py index 6beb4444..9472227f 100644 --- a/mopidy/audio/listener.py +++ b/mopidy/audio/listener.py @@ -75,3 +75,21 @@ class AudioListener(listener.Listener): field or :class:`None` if this is a final state. """ pass + + def tags_changed(self, tags): + """ + Called whenever the current audio stream's tags change. + + This event signals that some track metadata has been updated. This can + be metadata such as artists, titles, organization, or details about the + actual audio such as bit-rates, numbers of channels etc. + + For the available tag keys please refer to GStreamer documentation for + tags. + + *MAY* be implemented by actor. + + :param tags: The tags that have just been updated. + :type tags: :class:`set` of strings + """ + pass diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 55ad6426..2cf8f493 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -1,7 +1,5 @@ from __future__ import absolute_import, division, unicode_literals -import datetime -import os import time import pygst @@ -9,8 +7,8 @@ pygst.require('0.10') import gst # noqa from mopidy import exceptions -from mopidy.models import Album, Artist, Track -from mopidy.utils import encoding, path +from mopidy.audio import utils +from mopidy.utils import encoding class Scanner(object): @@ -19,13 +17,10 @@ class Scanner(object): :param timeout: timeout for scanning a URI in ms :type event: int - :param min_duration: minimum duration of scanned URI in ms, -1 for all. - :type event: int """ - def __init__(self, timeout=1000, min_duration=100): + def __init__(self, timeout=1000): self._timeout_ms = timeout - self._min_duration_ms = min_duration sink = gst.element_factory_make('fakesink') @@ -49,24 +44,19 @@ class Scanner(object): :param uri: URI of the resource to scan. :type event: string - :return: Dictionary of tags, duration, mtime and uri information. + :return: (tags, duration) pair. tags is a dictionary of lists for all + the tags we found and duration is the length of the URI in + milliseconds, or :class:`None` if the URI has no duration. """ + tags, duration = None, None try: self._setup(uri) - tags = self._collect() # Ensure collect before queries. - data = {'uri': uri, 'tags': tags, - 'mtime': self._query_mtime(uri), - 'duration': self._query_duration()} + tags = self._collect() + duration = self._query_duration() finally: self._reset() - if self._min_duration_ms is None: - return data - elif data['duration'] >= self._min_duration_ms * gst.MSECOND: - return data - - raise exceptions.ScannerError('Rejecting file with less than %dms ' - 'audio data.' % self._min_duration_ms) + return tags, duration def _setup(self, uri): """Primes the pipeline for collection.""" @@ -81,7 +71,7 @@ class Scanner(object): def _collect(self): """Polls for messages to collect data.""" start = time.time() - timeout_s = self._timeout_ms / 1000. + timeout_s = self._timeout_ms / 1000.0 tags = {} while time.time() - start < timeout_s: @@ -98,16 +88,9 @@ class Scanner(object): if message.src == self._pipe: return tags elif message.type == gst.MESSAGE_TAG: - # Taglists are not really dicts, hence the lack of .items() and - # explicit .keys. We only keep the last tag for each key, as we - # assume this is the best, some formats will produce multiple - # taglists. Lastly we force everything to lists for conformity. taglist = message.parse_tag() - for key in taglist.keys(): - value = taglist[key] - if not isinstance(value, list): - value = [value] - tags[key] = value + # Note that this will only keep the last tag. + tags.update(utils.convert_taglist(taglist)) raise exceptions.ScannerError('Timeout after %dms' % self._timeout_ms) @@ -118,93 +101,11 @@ class Scanner(object): def _query_duration(self): try: - return self._pipe.query_duration(gst.FORMAT_TIME, None)[0] + duration = self._pipe.query_duration(gst.FORMAT_TIME, None)[0] except gst.QueryError: return None - def _query_mtime(self, uri): - if not uri.startswith('file:'): + if duration < 0: return None - return os.path.getmtime(path.uri_to_path(uri)) - - -def _artists(tags, artist_name, artist_id=None): - # Name missing, don't set artist - if not tags.get(artist_name): - return None - # One artist name and id, provide artist with id. - if len(tags[artist_name]) == 1 and artist_id in tags: - return [Artist(name=tags[artist_name][0], - musicbrainz_id=tags[artist_id][0])] - # Multiple artist, provide artists without id. - return [Artist(name=name) for name in tags[artist_name]] - - -def _date(tags): - if not tags.get(gst.TAG_DATE): - return None - try: - date = tags[gst.TAG_DATE][0] - return datetime.date(date.year, date.month, date.day).isoformat() - except ValueError: - return None - - -def add_musicbrainz_cover_art(track): - if track.album and track.album.musicbrainz_id: - base = "http://coverartarchive.org/release" - images = frozenset( - ["{}/{}/front".format( - base, - track.album.musicbrainz_id)]) - album = track.album.copy(images=images) - track = track.copy(album=album) - return track - - -def audio_data_to_track(data): - """Convert taglist data + our extras to a track.""" - tags = data['tags'] - album_kwargs = {} - track_kwargs = {} - - track_kwargs['composers'] = _artists(tags, gst.TAG_COMPOSER) - track_kwargs['performers'] = _artists(tags, gst.TAG_PERFORMER) - track_kwargs['artists'] = _artists( - tags, gst.TAG_ARTIST, 'musicbrainz-artistid') - album_kwargs['artists'] = _artists( - tags, gst.TAG_ALBUM_ARTIST, 'musicbrainz-albumartistid') - - track_kwargs['genre'] = '; '.join(tags.get(gst.TAG_GENRE, [])) - track_kwargs['name'] = '; '.join(tags.get(gst.TAG_TITLE, [])) - if not track_kwargs['name']: - track_kwargs['name'] = '; '.join(tags.get(gst.TAG_ORGANIZATION, [])) - - track_kwargs['comment'] = '; '.join(tags.get('comment', [])) - if not track_kwargs['comment']: - track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_LOCATION, [])) - if not track_kwargs['comment']: - track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_COPYRIGHT, [])) - - track_kwargs['track_no'] = tags.get(gst.TAG_TRACK_NUMBER, [None])[0] - track_kwargs['disc_no'] = tags.get(gst.TAG_ALBUM_VOLUME_NUMBER, [None])[0] - track_kwargs['bitrate'] = tags.get(gst.TAG_BITRATE, [None])[0] - track_kwargs['musicbrainz_id'] = tags.get('musicbrainz-trackid', [None])[0] - - album_kwargs['name'] = tags.get(gst.TAG_ALBUM, [None])[0] - album_kwargs['num_tracks'] = tags.get(gst.TAG_TRACK_COUNT, [None])[0] - album_kwargs['num_discs'] = tags.get(gst.TAG_ALBUM_VOLUME_COUNT, [None])[0] - album_kwargs['musicbrainz_id'] = tags.get('musicbrainz-albumid', [None])[0] - - track_kwargs['date'] = _date(tags) - track_kwargs['last_modified'] = int(data.get('mtime') or 0) - track_kwargs['length'] = max( - 0, (data.get(gst.TAG_DURATION) or 0)) // gst.MSECOND - - # Clear out any empty values we found - track_kwargs = {k: v for k, v in track_kwargs.items() if v} - album_kwargs = {k: v for k, v in album_kwargs.items() if v} - - track_kwargs['uri'] = data['uri'] - track_kwargs['album'] = Album(**album_kwargs) - return Track(**track_kwargs) + else: + return duration // gst.MSECOND diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index a94e4551..8581fd61 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -1,10 +1,17 @@ from __future__ import absolute_import, unicode_literals +import datetime +import logging +import numbers + import pygst pygst.require('0.10') import gst # noqa from mopidy import compat +from mopidy.models import Album, Artist, Track + +logger = logging.getLogger(__name__) def calculate_duration(num_samples, sample_rate): @@ -56,3 +63,116 @@ def supported_uri_schemes(uri_schemes): supported_schemes.add(uri) return supported_schemes + + +def _artists(tags, artist_name, artist_id=None): + # Name missing, don't set artist + if not tags.get(artist_name): + return None + # One artist name and id, provide artist with id. + if len(tags[artist_name]) == 1 and artist_id in tags: + return [Artist(name=tags[artist_name][0], + musicbrainz_id=tags[artist_id][0])] + # Multiple artist, provide artists without id. + return [Artist(name=name) for name in tags[artist_name]] + + +# TODO: split based on "stream" and "track" based conversion? i.e. handle data +# from radios in it's own helper instead? +def convert_tags_to_track(tags): + """Convert our normalized tags to a track. + + :param :class:`dict` tags: dictionary of tag keys with a list of values + :rtype: :class:`mopidy.models.Track` + """ + album_kwargs = {} + track_kwargs = {} + + track_kwargs['composers'] = _artists(tags, gst.TAG_COMPOSER) + track_kwargs['performers'] = _artists(tags, gst.TAG_PERFORMER) + track_kwargs['artists'] = _artists( + tags, gst.TAG_ARTIST, 'musicbrainz-artistid') + album_kwargs['artists'] = _artists( + tags, gst.TAG_ALBUM_ARTIST, 'musicbrainz-albumartistid') + + track_kwargs['genre'] = '; '.join(tags.get(gst.TAG_GENRE, [])) + track_kwargs['name'] = '; '.join(tags.get(gst.TAG_TITLE, [])) + if not track_kwargs['name']: + track_kwargs['name'] = '; '.join(tags.get(gst.TAG_ORGANIZATION, [])) + + track_kwargs['comment'] = '; '.join(tags.get('comment', [])) + if not track_kwargs['comment']: + track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_LOCATION, [])) + if not track_kwargs['comment']: + track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_COPYRIGHT, [])) + + track_kwargs['track_no'] = tags.get(gst.TAG_TRACK_NUMBER, [None])[0] + track_kwargs['disc_no'] = tags.get(gst.TAG_ALBUM_VOLUME_NUMBER, [None])[0] + track_kwargs['bitrate'] = tags.get(gst.TAG_BITRATE, [None])[0] + track_kwargs['musicbrainz_id'] = tags.get('musicbrainz-trackid', [None])[0] + + album_kwargs['name'] = tags.get(gst.TAG_ALBUM, [None])[0] + album_kwargs['num_tracks'] = tags.get(gst.TAG_TRACK_COUNT, [None])[0] + album_kwargs['num_discs'] = tags.get(gst.TAG_ALBUM_VOLUME_COUNT, [None])[0] + album_kwargs['musicbrainz_id'] = tags.get('musicbrainz-albumid', [None])[0] + + if tags.get(gst.TAG_DATE) and tags.get(gst.TAG_DATE)[0]: + track_kwargs['date'] = tags[gst.TAG_DATE][0].isoformat() + + # Clear out any empty values we found + track_kwargs = {k: v for k, v in track_kwargs.items() if v} + album_kwargs = {k: v for k, v in album_kwargs.items() if v} + + # Only bother with album if we have a name to show. + if album_kwargs.get('name'): + track_kwargs['album'] = Album(**album_kwargs) + + return Track(**track_kwargs) + + +def convert_taglist(taglist): + """Convert a :class:`gst.Taglist` to plain Python types. + + Knows how to convert: + + - Dates + - Buffers + - Numbers + - Strings + - Booleans + + Unknown types will be ignored and debug logged. Tag keys are all strings + defined as part GStreamer under GstTagList_. + + .. _GstTagList: http://gstreamer.freedesktop.org/data/doc/gstreamer/\ +0.10.36/gstreamer/html/gstreamer-GstTagList.html + + :param gst.Taglist taglist: A GStreamer taglist to be converted. + :rtype: dictionary of tag keys with a list of values. + """ + result = {} + + # Taglists are not really dicts, hence the lack of .items() and + # explicit use of .keys() + for key in taglist.keys(): + result.setdefault(key, []) + + values = taglist[key] + if not isinstance(values, list): + values = [values] + + for value in values: + if isinstance(value, gst.Date): + try: + date = datetime.date(value.year, value.month, value.day) + result[key].append(date) + except ValueError: + logger.debug('Ignoring invalid date: %r = %r', key, value) + elif isinstance(value, gst.Buffer): + result[key].append(bytes(value)) + elif isinstance(value, (basestring, bool, numbers.Number)): + result[key].append(value) + else: + logger.debug('Ignoring unknown data: %r = %r', key, value) + + return result diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index de0990ef..d49ab8f8 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -6,13 +6,15 @@ import os import time from mopidy import commands, compat, exceptions -from mopidy.audio import scan +from mopidy.audio import scan, utils from mopidy.local import translator from mopidy.utils import path logger = logging.getLogger(__name__) +MIN_DURATION_MS = 100 # Shortest length of track to include. + def _get_library(args, config): libraries = dict((l.name, l) for l in args.registry['local:library']) @@ -71,9 +73,6 @@ class ScanCommand(commands.Command): library = _get_library(args, config) - uris_to_update = set() - uris_to_remove = set() - file_mtimes, file_errors = path.find_mtimes( media_dir, follow=config['local']['scan_follow_symlinks']) @@ -88,14 +87,19 @@ class ScanCommand(commands.Command): num_tracks = library.load() logger.info('Checking %d tracks from library.', num_tracks) + uris_to_update = set() + uris_to_remove = set() + uris_in_library = set() + for track in library.begin(): abspath = translator.local_track_uri_to_path(track.uri, media_dir) - mtime = file_mtimes.pop(abspath, None) + mtime = file_mtimes.get(abspath) if mtime is None: logger.debug('Missing file %s', track.uri) uris_to_remove.add(track.uri) elif mtime > track.last_modified: uris_to_update.add(track.uri) + uris_in_library.add(track.uri) logger.info('Removing %d missing tracks.', len(uris_to_remove)) for uri in uris_to_remove: @@ -105,12 +109,11 @@ class ScanCommand(commands.Command): relpath = os.path.relpath(abspath, media_dir) uri = translator.path_to_local_track_uri(relpath) - # TODO: move these to a "predicate" check in the finder? if b'/.' in relpath: logger.debug('Skipped %s: Hidden directory/file.', uri) elif relpath.lower().endswith(excluded_file_extensions): logger.debug('Skipped %s: File extension excluded.', uri) - else: + elif uri not in uris_in_library: uris_to_update.add(uri) logger.info( @@ -127,11 +130,18 @@ class ScanCommand(commands.Command): try: relpath = translator.local_track_uri_to_path(uri, media_dir) file_uri = path.path_to_uri(os.path.join(media_dir, relpath)) - data = scanner.scan(file_uri) - track = scan.add_musicbrainz_cover_art( - scan.audio_data_to_track(data).copy(uri=uri)).copy(uri=uri) - library.add(track) - logger.debug('Added %s', track.uri) + tags, duration = scanner.scan(file_uri) + if duration < MIN_DURATION_MS: + logger.warning('Failed %s: Track shorter than %dms', + uri, MIN_DURATION_MS) + else: + mtime = file_mtimes.get(os.path.join(media_dir, relpath)) + track = utils.convert_tags_to_track(tags).copy( + uri=uri, length=duration, last_modified=mtime) + track = translator.add_musicbrainz_coverart_to_track(track) + # TODO: add tags to call if library supports it. + library.add(track) + logger.debug('Added %s', track.uri) except exceptions.ScannerError as error: logger.warning('Failed %s: %s', uri, error) diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 7d7f0601..3cbe2066 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -13,10 +13,19 @@ from mopidy.utils.path import path_to_uri, uri_to_path M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)') +COVERART_BASE = 'http://coverartarchive.org/release/%s/front' logger = logging.getLogger(__name__) +def add_musicbrainz_coverart_to_track(track): + if track.album and track.album.musicbrainz_id: + images = [COVERART_BASE % track.album.musicbrainz_id] + album = track.album.copy(images=images) + track = track.copy(album=album) + return track + + def local_track_uri_to_file_uri(uri, media_dir): return path_to_uri(local_track_uri_to_path(uri, media_dir)) diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index b6336fbe..9599d9d3 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -8,7 +8,7 @@ import urlparse import pykka from mopidy import audio as audio_lib, backend, exceptions -from mopidy.audio import scan +from mopidy.audio import scan, utils from mopidy.models import Track logger = logging.getLogger(__name__) @@ -31,7 +31,7 @@ class StreamBackend(pykka.ThreadingActor, backend.Backend): class StreamLibraryProvider(backend.LibraryProvider): def __init__(self, backend, timeout, blacklist): super(StreamLibraryProvider, self).__init__(backend) - self._scanner = scan.Scanner(min_duration=None, timeout=timeout) + self._scanner = scan.Scanner(timeout=timeout) self._blacklist_re = re.compile( r'^(%s)$' % '|'.join(fnmatch.translate(u) for u in blacklist)) @@ -44,8 +44,9 @@ class StreamLibraryProvider(backend.LibraryProvider): return [Track(uri=uri)] try: - data = self._scanner.scan(uri) - track = scan.audio_data_to_track(data) + tags, duration = self._scanner.scan(uri) + track = utils.convert_tags_to_track(tags).copy( + uri=uri, length=duration) except exceptions.ScannerError as e: logger.warning('Problem looking up %s: %s', uri, e) track = Track(uri=uri) diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index ab897595..f77505b7 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -42,7 +42,7 @@ class BaseTest(unittest.TestCase): audio_class = audio.Audio - def setUp(self): + def setUp(self): # noqa config = { 'audio': { 'mixer': 'foomixer', @@ -57,7 +57,7 @@ class BaseTest(unittest.TestCase): self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) self.audio = self.audio_class.start(config=config, mixer=None).proxy() - def tearDown(self): + def tearDown(self): # noqa pykka.ActorRegistry.stop_all() def possibly_trigger_fake_playback_error(self): @@ -135,7 +135,7 @@ class AudioDummyTest(DummyMixin, AudioTest): @mock.patch.object(audio.AudioListener, 'send') class AudioEventTest(BaseTest): - def setUp(self): + def setUp(self): # noqa super(AudioEventTest, self).setUp() self.audio.enable_sync_handler().get() @@ -292,6 +292,14 @@ class AudioEventTest(BaseTest): call = mock.call('position_changed', position=2000) self.assertIn(call, send_mock.call_args_list) + def test_tags_changed_on_playback(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.start_playback() + self.audio.wait_for_state_change().get() + + send_mock.assert_any_call('tags_changed', tags=mock.ANY) + # Unlike the other events, having the state changed done is not # enough to ensure our event is called. So we setup a threading # event that we can wait for with a timeout while the track playback @@ -330,7 +338,7 @@ class AudioEventTest(BaseTest): if not event.wait(timeout=1.0): self.fail('End of stream not reached within deadline') - # Make sure that gapless really works: + self.assertFalse(self.audio.get_current_tags().get()) def test_gapless(self, send_mock): uris = self.uris[1:] @@ -361,20 +369,74 @@ class AudioEventTest(BaseTest): if not done.wait(timeout=1.0): self.fail('EOS not received') - excepted = [ - ('position_changed', {'position': 0}), - ('stream_changed', {'uri': self.uris[0]}), - ('state_changed', {'old_state': PlaybackState.STOPPED, - 'new_state': PlaybackState.PLAYING, - 'target_state': None}), - ('position_changed', {'position': 0}), - ('stream_changed', {'uri': self.uris[1]}), - ('reached_end_of_stream', {})] - self.assertEqual(excepted, events) + # Check that both uris got played + self.assertIn(('stream_changed', {'uri': self.uris[0]}), events) + self.assertIn(('stream_changed', {'uri': self.uris[1]}), events) + + # Check that events counts check out. + keys = [k for k, v in events] + self.assertEqual(2, keys.count('stream_changed')) + self.assertEqual(2, keys.count('position_changed')) + self.assertEqual(1, keys.count('state_changed')) + self.assertEqual(1, keys.count('reached_end_of_stream')) + + # TODO: test tag states within gaples + + def test_current_tags_are_blank_to_begin_with(self, send_mock): + self.assertFalse(self.audio.get_current_tags().get()) + + def test_current_tags_blank_after_end_of_stream(self, send_mock): + done = threading.Event() + + def send(name, **kwargs): + if name == 'reached_end_of_stream': + done.set() + + send_mock.side_effect = send + + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.start_playback() + + self.possibly_trigger_fake_about_to_finish() + self.audio.wait_for_state_change().get() + + if not done.wait(timeout=1.0): + self.fail('EOS not received') + + self.assertFalse(self.audio.get_current_tags().get()) + + def test_current_tags_stored(self, send_mock): + done = threading.Event() + tags = [] + + def callback(): + tags.append(self.audio.get_current_tags().get()) + + def send(name, **kwargs): + if name == 'reached_end_of_stream': + done.set() + + send_mock.side_effect = send + self.audio.set_about_to_finish_callback(callback).get() + + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.start_playback() + + self.possibly_trigger_fake_about_to_finish() + self.audio.wait_for_state_change().get() + + if not done.wait(timeout=1.0): + self.fail('EOS not received') + + self.assertTrue(tags[0]) + + # TODO: test that we reset when we expect between songs class AudioDummyEventTest(DummyMixin, AudioEventTest): - pass + """Exercise the AudioEventTest against our mock audio classes.""" # TODO: move to mixer tests... @@ -399,7 +461,7 @@ class MixerTest(BaseTest): class AudioStateTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa self.audio = audio.Audio(config=None, mixer=None) def test_state_starts_as_stopped(self): @@ -444,7 +506,7 @@ class AudioStateTest(unittest.TestCase): class AudioBufferingTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa self.audio = audio.Audio(config=None, mixer=None) self.audio._playbin = mock.Mock(spec=['set_state']) diff --git a/tests/audio/test_listener.py b/tests/audio/test_listener.py index 08b03e6c..6b78ecb0 100644 --- a/tests/audio/test_listener.py +++ b/tests/audio/test_listener.py @@ -8,7 +8,7 @@ from mopidy import audio class AudioListenerTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa self.listener = audio.AudioListener() def test_on_event_forwards_to_specific_handler(self): @@ -32,3 +32,6 @@ class AudioListenerTest(unittest.TestCase): def test_listener_has_default_impl_for_position_changed(self): self.listener.position_changed(None) + + def test_listener_has_default_impl_for_tags_changed(self): + self.listener.tags_changed([]) diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index 45a4aa6a..97406c41 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -8,290 +8,16 @@ gobject.threads_init() from mopidy import exceptions from mopidy.audio import scan -from mopidy.models import Album, Artist, Track from mopidy.utils import path as path_lib from tests import path_to_data_dir -class FakeGstDate(object): - def __init__(self, year, month, day): - self.year = year - self.month = month - self.day = day - - -# TODO: keep ids without name? -class TranslatorTest(unittest.TestCase): - def setUp(self): - self.data = { - 'uri': 'uri', - 'duration': 4531000000, - 'mtime': 1234, - 'tags': { - 'album': ['album'], - 'track-number': [1], - 'artist': ['artist'], - 'composer': ['composer'], - 'performer': ['performer'], - 'album-artist': ['albumartist'], - 'title': ['track'], - 'track-count': [2], - 'album-disc-number': [2], - 'album-disc-count': [3], - 'date': [FakeGstDate(2006, 1, 1,)], - 'container-format': ['ID3 tag'], - 'genre': ['genre'], - 'comment': ['comment'], - 'musicbrainz-trackid': ['trackid'], - 'musicbrainz-albumid': ['albumid'], - 'musicbrainz-artistid': ['artistid'], - 'musicbrainz-albumartistid': ['albumartistid'], - 'bitrate': [1000], - }, - } - - artist = Artist(name='artist', musicbrainz_id='artistid') - composer = Artist(name='composer') - performer = Artist(name='performer') - albumartist = Artist(name='albumartist', - musicbrainz_id='albumartistid') - - album = Album(name='album', num_tracks=2, num_discs=3, - musicbrainz_id='albumid', artists=[albumartist]) - - self.track = Track(uri='uri', name='track', date='2006-01-01', - genre='genre', track_no=1, disc_no=2, length=4531, - comment='comment', musicbrainz_id='trackid', - last_modified=1234, album=album, bitrate=1000, - artists=[artist], composers=[composer], - performers=[performer]) - - def check(self, expected): - actual = scan.audio_data_to_track(self.data) - self.assertEqual(expected, actual) - - def check_local(self, expected): - actual = scan.add_musicbrainz_cover_art( - scan.audio_data_to_track(self.data)) - self.assertEqual(expected, actual) - - def test_track(self): - self.check(self.track) - - def test_none_track_length(self): - self.data['duration'] = None - self.check(self.track.copy(length=None)) - - def test_none_track_last_modified(self): - self.data['mtime'] = None - self.check(self.track.copy(last_modified=None)) - - def test_missing_track_no(self): - del self.data['tags']['track-number'] - self.check(self.track.copy(track_no=None)) - - def test_multiple_track_no(self): - self.data['tags']['track-number'].append(9) - self.check(self.track) - - def test_missing_track_disc_no(self): - del self.data['tags']['album-disc-number'] - self.check(self.track.copy(disc_no=None)) - - def test_multiple_track_disc_no(self): - self.data['tags']['album-disc-number'].append(9) - self.check(self.track) - - def test_missing_track_name(self): - del self.data['tags']['title'] - self.check(self.track.copy(name=None)) - - def test_multiple_track_name(self): - self.data['tags']['title'] = ['name1', 'name2'] - self.check(self.track.copy(name='name1; name2')) - - def test_missing_track_musicbrainz_id(self): - del self.data['tags']['musicbrainz-trackid'] - self.check(self.track.copy(musicbrainz_id=None)) - - def test_multiple_track_musicbrainz_id(self): - self.data['tags']['musicbrainz-trackid'].append('id') - self.check(self.track) - - def test_missing_track_bitrate(self): - del self.data['tags']['bitrate'] - self.check(self.track.copy(bitrate=None)) - - def test_multiple_track_bitrate(self): - self.data['tags']['bitrate'].append(1234) - self.check(self.track) - - def test_missing_track_genre(self): - del self.data['tags']['genre'] - self.check(self.track.copy(genre=None)) - - def test_multiple_track_genre(self): - self.data['tags']['genre'] = ['genre1', 'genre2'] - self.check(self.track.copy(genre='genre1; genre2')) - - def test_missing_track_date(self): - del self.data['tags']['date'] - self.check(self.track.copy(date=None)) - - def test_multiple_track_date(self): - self.data['tags']['date'].append(FakeGstDate(2030, 1, 1)) - self.check(self.track) - - def test_invalid_track_date(self): - self.data['tags']['date'] = [FakeGstDate(65535, 1, 1)] - self.check(self.track.copy(date=None)) - - def test_missing_track_comment(self): - del self.data['tags']['comment'] - self.check(self.track.copy(comment=None)) - - def test_multiple_track_comment(self): - self.data['tags']['comment'] = ['comment1', 'comment2'] - self.check(self.track.copy(comment='comment1; comment2')) - - def test_missing_track_artist_name(self): - del self.data['tags']['artist'] - self.check(self.track.copy(artists=[])) - - def test_multiple_track_artist_name(self): - self.data['tags']['artist'] = ['name1', 'name2'] - artists = [Artist(name='name1'), Artist(name='name2')] - self.check(self.track.copy(artists=artists)) - - def test_missing_track_artist_musicbrainz_id(self): - del self.data['tags']['musicbrainz-artistid'] - artist = list(self.track.artists)[0].copy(musicbrainz_id=None) - self.check(self.track.copy(artists=[artist])) - - def test_multiple_track_artist_musicbrainz_id(self): - self.data['tags']['musicbrainz-artistid'].append('id') - self.check(self.track) - - def test_missing_track_composer_name(self): - del self.data['tags']['composer'] - self.check(self.track.copy(composers=[])) - - def test_multiple_track_composer_name(self): - self.data['tags']['composer'] = ['composer1', 'composer2'] - composers = [Artist(name='composer1'), Artist(name='composer2')] - self.check(self.track.copy(composers=composers)) - - def test_missing_track_performer_name(self): - del self.data['tags']['performer'] - self.check(self.track.copy(performers=[])) - - def test_multiple_track_performe_name(self): - self.data['tags']['performer'] = ['performer1', 'performer2'] - performers = [Artist(name='performer1'), Artist(name='performer2')] - self.check(self.track.copy(performers=performers)) - - def test_missing_album_name(self): - del self.data['tags']['album'] - album = self.track.album.copy(name=None) - self.check(self.track.copy(album=album)) - - def test_multiple_album_name(self): - self.data['tags']['album'].append('album2') - self.check(self.track) - - def test_missing_album_musicbrainz_id(self): - del self.data['tags']['musicbrainz-albumid'] - album = self.track.album.copy(musicbrainz_id=None, - images=[]) - self.check(self.track.copy(album=album)) - - def test_multiple_album_musicbrainz_id(self): - self.data['tags']['musicbrainz-albumid'].append('id') - self.check(self.track) - - def test_album_musicbrainz_id_cover(self): - album = self.track.album.copy( - images=frozenset( - ['http://coverartarchive.org/release/albumid/front'])) - self.check_local(self.track.copy(album=album)) - - def test_missing_album_num_tracks(self): - del self.data['tags']['track-count'] - album = self.track.album.copy(num_tracks=None) - self.check(self.track.copy(album=album)) - - def test_multiple_album_num_tracks(self): - self.data['tags']['track-count'].append(9) - self.check(self.track) - - def test_missing_album_num_discs(self): - del self.data['tags']['album-disc-count'] - album = self.track.album.copy(num_discs=None) - self.check(self.track.copy(album=album)) - - def test_multiple_album_num_discs(self): - self.data['tags']['album-disc-count'].append(9) - self.check(self.track) - - def test_missing_album_artist_name(self): - del self.data['tags']['album-artist'] - album = self.track.album.copy(artists=[]) - self.check(self.track.copy(album=album)) - - def test_multiple_album_artist_name(self): - self.data['tags']['album-artist'] = ['name1', 'name2'] - artists = [Artist(name='name1'), Artist(name='name2')] - album = self.track.album.copy(artists=artists) - self.check(self.track.copy(album=album)) - - def test_missing_album_artist_musicbrainz_id(self): - del self.data['tags']['musicbrainz-albumartistid'] - albumartist = list(self.track.album.artists)[0] - albumartist = albumartist.copy(musicbrainz_id=None) - album = self.track.album.copy(artists=[albumartist]) - self.check(self.track.copy(album=album)) - - def test_multiple_album_artist_musicbrainz_id(self): - self.data['tags']['musicbrainz-albumartistid'].append('id') - self.check(self.track) - - def test_stream_organization_track_name(self): - del self.data['tags']['title'] - self.data['tags']['organization'] = ['organization'] - self.check(self.track.copy(name='organization')) - - def test_multiple_organization_track_name(self): - del self.data['tags']['title'] - self.data['tags']['organization'] = ['organization1', 'organization2'] - self.check(self.track.copy(name='organization1; organization2')) - - # TODO: combine all comment types? - def test_stream_location_track_comment(self): - del self.data['tags']['comment'] - self.data['tags']['location'] = ['location'] - self.check(self.track.copy(comment='location')) - - def test_multiple_location_track_comment(self): - del self.data['tags']['comment'] - self.data['tags']['location'] = ['location1', 'location2'] - self.check(self.track.copy(comment='location1; location2')) - - def test_stream_copyright_track_comment(self): - del self.data['tags']['comment'] - self.data['tags']['copyright'] = ['copyright'] - self.check(self.track.copy(comment='copyright')) - - def test_multiple_copyright_track_comment(self): - del self.data['tags']['comment'] - self.data['tags']['copyright'] = ['copyright1', 'copyright2'] - self.check(self.track.copy(comment='copyright1; copyright2')) - - class ScannerTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa self.errors = {} - self.data = {} + self.tags = {} + self.durations = {} def find(self, path): media_dir = path_to_data_dir(path) @@ -305,54 +31,46 @@ class ScannerTest(unittest.TestCase): uri = path_lib.path_to_uri(path) key = uri[len('file://'):] try: - self.data[key] = scanner.scan(uri) + tags, duration = scanner.scan(uri) + self.tags[key] = tags + self.durations[key] = duration except exceptions.ScannerError as error: self.errors[key] = error def check(self, name, key, value): name = path_to_data_dir(name) - self.assertEqual(self.data[name][key], value) + self.assertEqual(self.tags[name][key], value) - def check_tag(self, name, key, value): - name = path_to_data_dir(name) - self.assertEqual(self.data[name]['tags'][key], value) - - def test_data_is_set(self): + def test_tags_is_set(self): self.scan(self.find('scanner/simple')) - self.assert_(self.data) + self.assert_(self.tags) def test_errors_is_not_set(self): self.scan(self.find('scanner/simple')) self.assert_(not self.errors) - def test_uri_is_set(self): - self.scan(self.find('scanner/simple')) - self.check( - 'scanner/simple/song1.mp3', 'uri', - 'file://%s' % path_to_data_dir('scanner/simple/song1.mp3')) - self.check( - 'scanner/simple/song1.ogg', 'uri', - 'file://%s' % path_to_data_dir('scanner/simple/song1.ogg')) - def test_duration_is_set(self): self.scan(self.find('scanner/simple')) - self.check('scanner/simple/song1.mp3', 'duration', 4680000000) - self.check('scanner/simple/song1.ogg', 'duration', 4680000000) + + self.assertEqual( + self.durations[path_to_data_dir('scanner/simple/song1.mp3')], 4680) + self.assertEqual( + self.durations[path_to_data_dir('scanner/simple/song1.ogg')], 4680) def test_artist_is_set(self): self.scan(self.find('scanner/simple')) - self.check_tag('scanner/simple/song1.mp3', 'artist', ['name']) - self.check_tag('scanner/simple/song1.ogg', 'artist', ['name']) + self.check('scanner/simple/song1.mp3', 'artist', ['name']) + self.check('scanner/simple/song1.ogg', 'artist', ['name']) def test_album_is_set(self): self.scan(self.find('scanner/simple')) - self.check_tag('scanner/simple/song1.mp3', 'album', ['albumname']) - self.check_tag('scanner/simple/song1.ogg', 'album', ['albumname']) + self.check('scanner/simple/song1.mp3', 'album', ['albumname']) + self.check('scanner/simple/song1.ogg', 'album', ['albumname']) def test_track_is_set(self): self.scan(self.find('scanner/simple')) - self.check_tag('scanner/simple/song1.mp3', 'title', ['trackname']) - self.check_tag('scanner/simple/song1.ogg', 'title', ['trackname']) + self.check('scanner/simple/song1.mp3', 'title', ['trackname']) + self.check('scanner/simple/song1.ogg', 'title', ['trackname']) def test_nonexistant_dir_does_not_fail(self): self.scan(self.find('scanner/does-not-exist')) @@ -364,11 +82,13 @@ class ScannerTest(unittest.TestCase): def test_log_file_that_gst_thinks_is_mpeg_1_is_ignored(self): self.scan([path_to_data_dir('scanner/example.log')]) - self.assert_(self.errors) + self.assertLess( + self.durations[path_to_data_dir('scanner/example.log')], 100) - def test_empty_wav_file_is_ignored(self): + def test_empty_wav_file(self): self.scan([path_to_data_dir('scanner/empty.wav')]) - self.assert_(self.errors) + self.assertEqual( + self.durations[path_to_data_dir('scanner/empty.wav')], 0) @unittest.SkipTest def test_song_without_time_is_handeled(self): diff --git a/tests/audio/test_utils.py b/tests/audio/test_utils.py new file mode 100644 index 00000000..b2028518 --- /dev/null +++ b/tests/audio/test_utils.py @@ -0,0 +1,246 @@ +from __future__ import absolute_import, unicode_literals + +import datetime +import unittest + +from mopidy.audio import utils +from mopidy.models import Album, Artist, Track + + +# TODO: keep ids without name? +# TODO: current test is trying to test everything at once with a complete tags +# set, instead we might want to try with a minimal one making testing easier. +class TagsToTrackTest(unittest.TestCase): + def setUp(self): # noqa + self.tags = { + 'album': ['album'], + 'track-number': [1], + 'artist': ['artist'], + 'composer': ['composer'], + 'performer': ['performer'], + 'album-artist': ['albumartist'], + 'title': ['track'], + 'track-count': [2], + 'album-disc-number': [2], + 'album-disc-count': [3], + 'date': [datetime.date(2006, 1, 1,)], + 'container-format': ['ID3 tag'], + 'genre': ['genre'], + 'comment': ['comment'], + 'musicbrainz-trackid': ['trackid'], + 'musicbrainz-albumid': ['albumid'], + 'musicbrainz-artistid': ['artistid'], + 'musicbrainz-albumartistid': ['albumartistid'], + 'bitrate': [1000], + } + + artist = Artist(name='artist', musicbrainz_id='artistid') + composer = Artist(name='composer') + performer = Artist(name='performer') + albumartist = Artist(name='albumartist', + musicbrainz_id='albumartistid') + + album = Album(name='album', num_tracks=2, num_discs=3, + musicbrainz_id='albumid', artists=[albumartist]) + + self.track = Track(name='track', date='2006-01-01', + genre='genre', track_no=1, disc_no=2, + comment='comment', musicbrainz_id='trackid', + album=album, bitrate=1000, artists=[artist], + composers=[composer], performers=[performer]) + + def check(self, expected): + actual = utils.convert_tags_to_track(self.tags) + self.assertEqual(expected, actual) + + def test_track(self): + self.check(self.track) + + def test_missing_track_no(self): + del self.tags['track-number'] + self.check(self.track.copy(track_no=None)) + + def test_multiple_track_no(self): + self.tags['track-number'].append(9) + self.check(self.track) + + def test_missing_track_disc_no(self): + del self.tags['album-disc-number'] + self.check(self.track.copy(disc_no=None)) + + def test_multiple_track_disc_no(self): + self.tags['album-disc-number'].append(9) + self.check(self.track) + + def test_missing_track_name(self): + del self.tags['title'] + self.check(self.track.copy(name=None)) + + def test_multiple_track_name(self): + self.tags['title'] = ['name1', 'name2'] + self.check(self.track.copy(name='name1; name2')) + + def test_missing_track_musicbrainz_id(self): + del self.tags['musicbrainz-trackid'] + self.check(self.track.copy(musicbrainz_id=None)) + + def test_multiple_track_musicbrainz_id(self): + self.tags['musicbrainz-trackid'].append('id') + self.check(self.track) + + def test_missing_track_bitrate(self): + del self.tags['bitrate'] + self.check(self.track.copy(bitrate=None)) + + def test_multiple_track_bitrate(self): + self.tags['bitrate'].append(1234) + self.check(self.track) + + def test_missing_track_genre(self): + del self.tags['genre'] + self.check(self.track.copy(genre=None)) + + def test_multiple_track_genre(self): + self.tags['genre'] = ['genre1', 'genre2'] + self.check(self.track.copy(genre='genre1; genre2')) + + def test_missing_track_date(self): + del self.tags['date'] + self.check(self.track.copy(date=None)) + + def test_multiple_track_date(self): + self.tags['date'].append(datetime.date(2030, 1, 1)) + self.check(self.track) + + def test_missing_track_comment(self): + del self.tags['comment'] + self.check(self.track.copy(comment=None)) + + def test_multiple_track_comment(self): + self.tags['comment'] = ['comment1', 'comment2'] + self.check(self.track.copy(comment='comment1; comment2')) + + def test_missing_track_artist_name(self): + del self.tags['artist'] + self.check(self.track.copy(artists=[])) + + def test_multiple_track_artist_name(self): + self.tags['artist'] = ['name1', 'name2'] + artists = [Artist(name='name1'), Artist(name='name2')] + self.check(self.track.copy(artists=artists)) + + def test_missing_track_artist_musicbrainz_id(self): + del self.tags['musicbrainz-artistid'] + artist = list(self.track.artists)[0].copy(musicbrainz_id=None) + self.check(self.track.copy(artists=[artist])) + + def test_multiple_track_artist_musicbrainz_id(self): + self.tags['musicbrainz-artistid'].append('id') + self.check(self.track) + + def test_missing_track_composer_name(self): + del self.tags['composer'] + self.check(self.track.copy(composers=[])) + + def test_multiple_track_composer_name(self): + self.tags['composer'] = ['composer1', 'composer2'] + composers = [Artist(name='composer1'), Artist(name='composer2')] + self.check(self.track.copy(composers=composers)) + + def test_missing_track_performer_name(self): + del self.tags['performer'] + self.check(self.track.copy(performers=[])) + + def test_multiple_track_performe_name(self): + self.tags['performer'] = ['performer1', 'performer2'] + performers = [Artist(name='performer1'), Artist(name='performer2')] + self.check(self.track.copy(performers=performers)) + + def test_missing_album_name(self): + del self.tags['album'] + self.check(self.track.copy(album=None)) + + def test_multiple_album_name(self): + self.tags['album'].append('album2') + self.check(self.track) + + def test_missing_album_musicbrainz_id(self): + del self.tags['musicbrainz-albumid'] + album = self.track.album.copy(musicbrainz_id=None, + images=[]) + self.check(self.track.copy(album=album)) + + def test_multiple_album_musicbrainz_id(self): + self.tags['musicbrainz-albumid'].append('id') + self.check(self.track) + + def test_missing_album_num_tracks(self): + del self.tags['track-count'] + album = self.track.album.copy(num_tracks=None) + self.check(self.track.copy(album=album)) + + def test_multiple_album_num_tracks(self): + self.tags['track-count'].append(9) + self.check(self.track) + + def test_missing_album_num_discs(self): + del self.tags['album-disc-count'] + album = self.track.album.copy(num_discs=None) + self.check(self.track.copy(album=album)) + + def test_multiple_album_num_discs(self): + self.tags['album-disc-count'].append(9) + self.check(self.track) + + def test_missing_album_artist_name(self): + del self.tags['album-artist'] + album = self.track.album.copy(artists=[]) + self.check(self.track.copy(album=album)) + + def test_multiple_album_artist_name(self): + self.tags['album-artist'] = ['name1', 'name2'] + artists = [Artist(name='name1'), Artist(name='name2')] + album = self.track.album.copy(artists=artists) + self.check(self.track.copy(album=album)) + + def test_missing_album_artist_musicbrainz_id(self): + del self.tags['musicbrainz-albumartistid'] + albumartist = list(self.track.album.artists)[0] + albumartist = albumartist.copy(musicbrainz_id=None) + album = self.track.album.copy(artists=[albumartist]) + self.check(self.track.copy(album=album)) + + def test_multiple_album_artist_musicbrainz_id(self): + self.tags['musicbrainz-albumartistid'].append('id') + self.check(self.track) + + def test_stream_organization_track_name(self): + del self.tags['title'] + self.tags['organization'] = ['organization'] + self.check(self.track.copy(name='organization')) + + def test_multiple_organization_track_name(self): + del self.tags['title'] + self.tags['organization'] = ['organization1', 'organization2'] + self.check(self.track.copy(name='organization1; organization2')) + + # TODO: combine all comment types? + def test_stream_location_track_comment(self): + del self.tags['comment'] + self.tags['location'] = ['location'] + self.check(self.track.copy(comment='location')) + + def test_multiple_location_track_comment(self): + del self.tags['comment'] + self.tags['location'] = ['location1', 'location2'] + self.check(self.track.copy(comment='location1; location2')) + + def test_stream_copyright_track_comment(self): + del self.tags['comment'] + self.tags['copyright'] = ['copyright'] + self.check(self.track.copy(comment='copyright')) + + def test_multiple_copyright_track_comment(self): + del self.tags['comment'] + self.tags['copyright'] = ['copyright1', 'copyright2'] + self.check(self.track.copy(comment='copyright1; copyright2')) diff --git a/tests/local/test_translator.py b/tests/local/test_translator.py index a473a0ff..b238c909 100644 --- a/tests/local/test_translator.py +++ b/tests/local/test_translator.py @@ -6,9 +6,9 @@ import os import tempfile import unittest -from mopidy.local.translator import parse_m3u -from mopidy.models import Track -from mopidy.utils.path import path_to_uri +from mopidy.local import translator +from mopidy.models import Album, Track +from mopidy.utils import path from tests import path_to_data_dir @@ -16,9 +16,9 @@ data_dir = path_to_data_dir('') song1_path = path_to_data_dir('song1.mp3') song2_path = path_to_data_dir('song2.mp3') encoded_path = path_to_data_dir('æøå.mp3') -song1_uri = path_to_uri(song1_path) -song2_uri = path_to_uri(song2_path) -encoded_uri = path_to_uri(encoded_path) +song1_uri = path.path_to_uri(song1_path) +song2_uri = path.path_to_uri(song2_path) +encoded_uri = path.path_to_uri(encoded_path) song1_track = Track(uri=song1_uri) song2_track = Track(uri=song2_uri) encoded_track = Track(uri=encoded_uri) @@ -30,23 +30,26 @@ encoded_ext_track = encoded_track.copy(name='æøå') # FIXME use mock instead of tempfile.NamedTemporaryFile class M3UToUriTest(unittest.TestCase): + def parse(self, name): + return translator.parse_m3u(name, data_dir) + def test_empty_file(self): - tracks = parse_m3u(path_to_data_dir('empty.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('empty.m3u')) self.assertEqual([], tracks) def test_basic_file(self): - tracks = parse_m3u(path_to_data_dir('one.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('one.m3u')) self.assertEqual([song1_track], tracks) def test_file_with_comment(self): - tracks = parse_m3u(path_to_data_dir('comment.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('comment.m3u')) self.assertEqual([song1_track], tracks) def test_file_is_relative_to_correct_dir(self): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write('song1.mp3') try: - tracks = parse_m3u(tmp.name, data_dir) + tracks = self.parse(tmp.name) self.assertEqual([song1_track], tracks) finally: if os.path.exists(tmp.name): @@ -56,7 +59,7 @@ class M3UToUriTest(unittest.TestCase): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write(song1_path) try: - tracks = parse_m3u(tmp.name, data_dir) + tracks = self.parse(tmp.name) self.assertEqual([song1_track], tracks) finally: if os.path.exists(tmp.name): @@ -68,7 +71,7 @@ class M3UToUriTest(unittest.TestCase): tmp.write('# comment \n') tmp.write(song2_path) try: - tracks = parse_m3u(tmp.name, data_dir) + tracks = self.parse(tmp.name) self.assertEqual([song1_track, song2_track], tracks) finally: if os.path.exists(tmp.name): @@ -78,40 +81,53 @@ class M3UToUriTest(unittest.TestCase): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write(song1_uri) try: - tracks = parse_m3u(tmp.name, data_dir) + tracks = self.parse(tmp.name) self.assertEqual([song1_track], tracks) finally: if os.path.exists(tmp.name): os.remove(tmp.name) def test_encoding_is_latin1(self): - tracks = parse_m3u(path_to_data_dir('encoding.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('encoding.m3u')) self.assertEqual([encoded_track], tracks) def test_open_missing_file(self): - tracks = parse_m3u(path_to_data_dir('non-existant.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('non-existant.m3u')) self.assertEqual([], tracks) def test_empty_ext_file(self): - tracks = parse_m3u(path_to_data_dir('empty-ext.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('empty-ext.m3u')) self.assertEqual([], tracks) def test_basic_ext_file(self): - tracks = parse_m3u(path_to_data_dir('one-ext.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('one-ext.m3u')) self.assertEqual([song1_ext_track], tracks) def test_multi_ext_file(self): - tracks = parse_m3u(path_to_data_dir('two-ext.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('two-ext.m3u')) self.assertEqual([song1_ext_track, song2_ext_track], tracks) def test_ext_file_with_comment(self): - tracks = parse_m3u(path_to_data_dir('comment-ext.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('comment-ext.m3u')) self.assertEqual([song1_ext_track], tracks) def test_ext_encoding_is_latin1(self): - tracks = parse_m3u(path_to_data_dir('encoding-ext.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('encoding-ext.m3u')) self.assertEqual([encoded_ext_track], tracks) class URItoM3UTest(unittest.TestCase): pass + + +class AddMusicbrainzCoverartTest(unittest.TestCase): + def test_add_cover_for_album(self): + album = Album(musicbrainz_id='someid') + track = Track(album=album) + + expected = album.copy( + images=['http://coverartarchive.org/release/someid/front']) + + self.assertEqual( + track.copy(album=expected), + translator.add_musicbrainz_coverart_to_track(track)) diff --git a/tests/stream/__init__.py b/tests/stream/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/stream/test_library.py b/tests/stream/test_library.py new file mode 100644 index 00000000..b660a2d4 --- /dev/null +++ b/tests/stream/test_library.py @@ -0,0 +1,43 @@ +from __future__ import absolute_import, unicode_literals + +import unittest + +import gobject +gobject.threads_init() + +import pygst +pygst.require('0.10') +import gst # noqa: pygst magic is needed to import correct gst + +import mock + +from mopidy.models import Track +from mopidy.stream import actor +from mopidy.utils.path import path_to_uri + +from tests import path_to_data_dir + + +class LibraryProviderTest(unittest.TestCase): + def setUp(self): # noqa: ignore method must be lowercase + self.backend = mock.Mock() + self.backend.uri_schemes = ['file'] + self.uri = path_to_uri(path_to_data_dir('song1.wav')) + + def test_lookup_ignores_unknown_scheme(self): + library = actor.StreamLibraryProvider(self.backend, 1000, []) + self.assertFalse(library.lookup('http://example.com')) + + def test_lookup_respects_blacklist(self): + library = actor.StreamLibraryProvider(self.backend, 100, [self.uri]) + self.assertEqual([Track(uri=self.uri)], library.lookup(self.uri)) + + def test_lookup_respects_blacklist_globbing(self): + blacklist = [path_to_uri(path_to_data_dir('')) + '*'] + library = actor.StreamLibraryProvider(self.backend, 100, blacklist) + self.assertEqual([Track(uri=self.uri)], library.lookup(self.uri)) + + def test_lookup_converts_uri_metadata_to_track(self): + library = actor.StreamLibraryProvider(self.backend, 100, []) + self.assertEqual([Track(length=4406, uri=self.uri)], + library.lookup(self.uri))