Release v0.18.2

This commit is contained in:
Stein Magnus Jodal 2014-02-16 21:27:07 +01:00
commit 60a4a572bd
24 changed files with 237 additions and 125 deletions

View File

@ -35,3 +35,6 @@
- Luke Giuliani <luke@giuliani.com.au>
- Colin Montgomerie <kiteflyingmonkey@gmail.com>
- Simon de Bakker <simon@simbits.nl>
- Arnaud Barisain-Monrose <abarisain@gmail.com>
- nathanharper <nathan.sam.harper@gmail.com>
- Pierpaolo Frasa <pfrasa@smail.uni-koeln.de>

View File

@ -5,6 +5,40 @@ Changelog
This changelog is used to track all major changes to Mopidy.
v0.18.2 (2014-02-16)
====================
Bug fix release.
- We now log warnings for wrongly configured extensions, and clearly label them
in :option:`mopidy config`, but does no longer stop Mopidy from starting
because of misconfigured extensions. (Fixes: :issue:`682`)
- Fix a crash in the server side WebSocket handler caused by connection
problems with clients. (Fixes: :issue:`428`, :issue:`571`)
- Fix the ``time_position`` field of the ``track_playback_ended`` event, which
has been always 0 since v0.18.0. This made scrobbles by Mopidy-Scrobbler not
be persisted by Last.fm, because Mopidy reported that you listened to 0
seconds of each track. (Fixes: :issue:`674`)
- Fix the log setup so that it is possible to increase the amount of logging
from a specific logger using the ``loglevels`` config section. (Fixes:
:issue:`684`)
- Serialization of :class:`~mopidy.models.Playlist` models with the
``last_modified`` field set to a :class:`datetime.datetime` instance did not
work. The type of :attr:`mopidy.models.Playlist.last_modified` has been
redefined from a :class:`datetime.datetime` instance to the number of
milliseconds since Unix epoch as an integer. This makes serialization of the
time stamp simpler.
- Minor refactor of the MPD server context so that Mopidy's MPD protocol
implementation can easier be reused. (Fixes: :issue:`646`)
- Network and signal handling has been updated to play nice on Windows systems.
v0.18.1 (2014-01-23)
====================

View File

@ -76,6 +76,14 @@ https://github.com/sauberfred/mopidy-notifier
Extension for displaying track info as User Notifications in Mac OS X.
Mopidy-Podcast
==============
https://github.com/tkem/mopidy-podcast
Extension for browsing RSS feeds of podcasts and stream the episodes.
Mopidy-radio-de
===============

View File

@ -21,4 +21,4 @@ if (isinstance(pykka.__version__, basestring)
warnings.filterwarnings('ignore', 'could not open display')
__version__ = '0.18.1'
__version__ = '0.18.2'

View File

@ -37,7 +37,9 @@ def main():
logger.info('Starting Mopidy %s', versioning.get_version())
signal.signal(signal.SIGTERM, process.exit_handler)
signal.signal(signal.SIGUSR1, pykka.debug.log_thread_tracebacks)
# Windows does not have signal.SIGUSR1
if hasattr(signal, 'SIGUSR1'):
signal.signal(signal.SIGUSR1, pykka.debug.log_thread_tracebacks)
try:
registry = ext.Registry()
@ -72,20 +74,28 @@ def main():
log.setup_logging(config, verbosity_level, args.save_debug_log)
enabled_extensions = []
extensions = {
'validate': [], 'config': [], 'disabled': [], 'enabled': []}
for extension in installed_extensions:
if not ext.validate_extension(extension):
config[extension.ext_name] = {'enabled': False}
config_errors[extension.ext_name] = {
'enabled': 'extension disabled by self check.'}
extensions['validate'].append(extension)
elif not config[extension.ext_name]['enabled']:
config[extension.ext_name] = {'enabled': False}
config_errors[extension.ext_name] = {
'enabled': 'extension disabled by user config.'}
extensions['disabled'].append(extension)
elif config_errors.get(extension.ext_name):
config[extension.ext_name]['enabled'] = False
config_errors[extension.ext_name]['enabled'] = (
'extension disabled due to config errors.')
extensions['config'].append(extension)
else:
enabled_extensions.append(extension)
extensions['enabled'].append(extension)
log_extension_info(installed_extensions, enabled_extensions)
log_extension_info(installed_extensions, extensions['enabled'])
# Config and deps commands are simply special cased for now.
if args.command == config_cmd:
@ -94,22 +104,22 @@ def main():
elif args.command == deps_cmd:
return args.command.run()
# Remove errors for extensions that are not enabled:
for extension in installed_extensions:
if extension not in enabled_extensions:
config_errors.pop(extension.ext_name, None)
check_config_errors(config_errors)
check_config_errors(config, config_errors, extensions)
if not extensions['enabled']:
logger.error('No extension enabled, exiting...')
sys.exit(1)
# Read-only config from here on, please.
proxied_config = config_lib.Proxy(config)
if args.extension and args.extension not in enabled_extensions:
if args.extension and args.extension not in extensions['enabled']:
logger.error(
'Unable to run command provided by disabled extension %s',
args.extension.ext_name)
return 1
for extension in enabled_extensions:
for extension in extensions['enabled']:
extension.setup(registry)
# Anything that wants to exit after this point must use
@ -171,13 +181,39 @@ def log_extension_info(all_extensions, enabled_extensions):
'Disabled extensions: %s', ', '.join(disabled_names) or 'none')
def check_config_errors(errors):
if not errors:
return
for section in errors:
for key, msg in errors[section].items():
logger.error('Config value %s/%s %s', section, key, msg)
sys.exit(1)
def check_config_errors(config, errors, extensions):
fatal_errors = []
extension_names = {}
all_extension_names = set()
for state in extensions:
extension_names[state] = set(e.ext_name for e in extensions[state])
all_extension_names.update(extension_names[state])
for section in sorted(errors):
if not errors[section]:
continue
if section not in all_extension_names:
logger.warning('Found fatal %s configuration errors:', section)
fatal_errors.append(section)
elif section in extension_names['config']:
del errors[section]['enabled']
logger.warning('Found %s configuration errors, the extension '
'has been automatically disabled:', section)
else:
continue
for field, msg in errors[section].items():
logger.warning(' %s/%s %s', section, field, msg)
if extensions['config']:
logger.warning('Please fix the extension configuration errors or '
'disable the extensions to silence these messages.')
if fatal_errors:
logger.error('Please fix fatal configuration errors, exiting...')
sys.exit(1)
if __name__ == '__main__':

View File

@ -19,20 +19,25 @@ else:
EMPTY_STRING = ''
FETCH_ERROR = (
'Fetching passwords from your keyring failed. Any passwords '
'stored in the keyring will not be available.')
def fetch():
if not dbus:
logger.debug('Fetching from keyring failed: dbus not installed.')
logger.debug('%s (dbus not installed)', FETCH_ERROR)
return []
try:
bus = dbus.SessionBus()
except dbus.exceptions.DBusException as e:
logger.debug('Fetching from keyring failed: %s', e)
logger.debug('%s (%s)', FETCH_ERROR, e)
return []
if not bus.name_has_owner('org.freedesktop.secrets'):
logger.debug(
'Fetching from keyring failed: secrets service not running.')
'%s (org.freedesktop.secrets service not running)', FETCH_ERROR)
return []
service = _service(bus)
@ -47,7 +52,7 @@ def fetch():
items, prompt = service.Unlock(locked)
if prompt != '/':
_prompt(bus, prompt).Dismiss()
logger.debug('Fetching from keyring failed: keyring is locked.')
logger.debug('%s (Keyring is locked)', FETCH_ERROR)
return []
result = []
@ -65,19 +70,20 @@ def set(section, key, value):
Indicates if storage failed or succeeded.
"""
if not dbus:
logger.debug('Saving %s/%s to keyring failed: dbus not installed.',
logger.debug('Saving %s/%s to keyring failed. (dbus not installed)',
section, key)
return False
try:
bus = dbus.SessionBus()
except dbus.exceptions.DBusException as e:
logger.debug('Saving %s/%s to keyring failed: %s', section, key, e)
logger.debug('Saving %s/%s to keyring failed. (%s)', section, key, e)
return False
if not bus.name_has_owner('org.freedesktop.secrets'):
logger.debug(
'Saving %s/%s to keyring failed: secrets service not running.',
'Saving %s/%s to keyring failed. '
'(org.freedesktop.secrets service not running)',
section, key)
return False
@ -101,14 +107,14 @@ def set(section, key, value):
item, prompt = collection.CreateItem(properties, secret, True)
except dbus.exceptions.DBusException as e:
# TODO: catch IsLocked errors etc.
logger.debug('Saving %s/%s to keyring failed: %s', section, key, e)
logger.debug('Saving %s/%s to keyring failed. (%s)', section, key, e)
return False
if prompt == '/':
return True
_prompt(bus, prompt).Dismiss()
logger.debug('Saving secret %s/%s failed: Keyring is locked',
logger.debug('Saving secret %s/%s failed. (Keyring is locked)',
section, key)
return False

View File

@ -306,9 +306,10 @@ class PlaybackController(object):
"""
if self.state != PlaybackState.STOPPED:
backend = self._get_backend()
time_position_before_stop = self.time_position
if not backend or backend.playback.stop().get():
self.state = PlaybackState.STOPPED
self._trigger_track_playback_ended()
self._trigger_track_playback_ended(time_position_before_stop)
if clear_current_track:
self.current_tl_track = None
@ -336,13 +337,14 @@ class PlaybackController(object):
'track_playback_started',
tl_track=self.current_tl_track)
def _trigger_track_playback_ended(self):
def _trigger_track_playback_ended(self, time_position_before_stop):
logger.debug('Triggering track playback ended event')
if self.current_tl_track is None:
return
listener.CoreListener.send(
'track_playback_ended',
tl_track=self.current_tl_track, time_position=self.time_position)
tl_track=self.current_tl_track,
time_position=time_position_before_stop)
def _trigger_playback_state_changed(self, old_state, new_state):
logger.debug('Triggering playback state change event')

View File

@ -100,10 +100,11 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener):
host=self.hostname, port=self.port)
if self.zeroconf_service.publish():
logger.info('Registered HTTP with Zeroconf as "%s"',
self.zeroconf_service.name)
logger.debug(
'Registered HTTP with Zeroconf as "%s"',
self.zeroconf_service.name)
else:
logger.info('Registering HTTP with Zeroconf failed.')
logger.debug('Registering HTTP with Zeroconf failed.')
def on_stop(self):
if self.zeroconf_service:

View File

@ -4,6 +4,3 @@ hostname = 127.0.0.1
port = 6680
static_dir =
zeroconf = Mopidy HTTP server on $hostname
[loglevels]
cherrypy = warning

View File

@ -1,14 +1,14 @@
from __future__ import unicode_literals
import logging
import socket
import cherrypy
from ws4py.websocket import WebSocket
import ws4py.websocket
from mopidy import core, models
from mopidy.utils import jsonrpc
logger = logging.getLogger(__name__)
@ -43,7 +43,28 @@ class WebSocketResource(object):
cherrypy.request.ws_handler.jsonrpc = self.jsonrpc
class WebSocketHandler(WebSocket):
class _WebSocket(ws4py.websocket.WebSocket):
"""Sub-class ws4py WebSocket with better error handling."""
def send(self, *args, **kwargs):
try:
super(_WebSocket, self).send(*args, **kwargs)
return True
except socket.error as e:
logger.warning('Send message failed: %s', e)
# This isn't really correct, but its the only way to break of out
# the loop in run and trick ws4py into cleaning up.
self.client_terminated = self.server_terminated = True
return False
def close(self, *args, **kwargs):
try:
super(_WebSocket, self).close(*args, **kwargs)
except socket.error as e:
logger.warning('Closing WebSocket failed: %s', e)
class WebSocketHandler(_WebSocket):
def opened(self):
remote = cherrypy.request.remote
logger.debug(
@ -66,8 +87,7 @@ class WebSocketHandler(WebSocket):
remote.ip, remote.port, request)
response = self.jsonrpc.handle_json(request)
if response:
self.send(response)
if response and self.send(response):
logger.debug(
'Sent WebSocket message to %s:%d: %r',
remote.ip, remote.port, response)

View File

@ -1,4 +1,4 @@
from __future__ import unicode_literals
from __future__ import print_function, unicode_literals
import logging
import os
@ -37,17 +37,17 @@ class ClearCommand(commands.Command):
def run(self, args, config):
library = _get_library(args, config)
prompt = 'Are you sure you want to clear the library? [y/N] '
prompt = '\nAre you sure you want to clear the library? [y/N] '
if raw_input(prompt).lower() != 'y':
logging.info('Clearing library aborted.')
print('Clearing library aborted.')
return 0
if library.clear():
logging.info('Library succesfully cleared.')
print('Library successfully cleared.')
return 0
logging.warning('Unable to clear library.')
print('Unable to clear library.')
return 1

View File

@ -418,8 +418,9 @@ class Playlist(ImmutableObject):
:type name: string
:param tracks: playlist's tracks
:type tracks: list of :class:`Track` elements
:param last_modified: playlist's modification time in UTC
:type last_modified: :class:`datetime.datetime`
:param last_modified:
playlist's modification time in milliseconds since Unix epoch
:type last_modified: int
"""
#: The playlist URI. Read-only.
@ -431,9 +432,10 @@ class Playlist(ImmutableObject):
#: The playlist's tracks. Read-only.
tracks = tuple()
#: The playlist modification time in UTC. Read-only.
#: The playlist modification time in milliseconds since Unix epoch.
#: Read-only.
#:
#: :class:`datetime.datetime`, or :class:`None` if unknown.
#: Integer, or :class:`None` if unknown.
last_modified = None
def __init__(self, *args, **kwargs):

View File

@ -48,10 +48,11 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
host=self.hostname, port=self.port)
if self.zeroconf_service.publish():
logger.info('Registered MPD with Zeroconf as "%s"',
self.zeroconf_service.name)
logger.debug(
'Registered MPD with Zeroconf as "%s"',
self.zeroconf_service.name)
else:
logger.info('Registering MPD with Zeroconf failed.')
logger.debug('Registering MPD with Zeroconf failed.')
def on_stop(self):
if self.zeroconf_service:

View File

@ -229,8 +229,8 @@ class MpdContext(object):
#: The current :class:`mopidy.mpd.MpdSession`.
session = None
#: The Mopidy configuration.
config = None
#: The MPD password
password = None
#: The Mopidy core API. An instance of :class:`mopidy.core.Core`.
core = None
@ -246,7 +246,8 @@ class MpdContext(object):
def __init__(self, dispatcher, session=None, config=None, core=None):
self.dispatcher = dispatcher
self.session = session
self.config = config
if config is not None:
self.password = config['mpd']['password']
self.core = core
self.events = set()
self.subscriptions = set()

View File

@ -39,7 +39,7 @@ def password(context, password):
This is used for authentication with the server. ``PASSWORD`` is
simply the plaintext password.
"""
if password == context.config['mpd']['password']:
if password == context.password:
context.dispatcher.authenticated = True
else:
raise MpdPasswordError('incorrect password')

View File

@ -62,30 +62,15 @@ SEARCH_QUERY = r"""
$
"""
# TODO Would be nice to get ("?)...\1 working for the quotes here
SEARCH_PAIR_WITHOUT_GROUPS = r"""
SEARCH_PAIR_WITH_GROUPS = r"""
("?) # Optional quote around the field type
\b # Only begin matching at word bundaries
"? # Optional quote around the field type
(?: # A non-capturing group for the field type
( # A capturing group for the field type
""" + SEARCH_FIELDS + """
)
"? # End of optional quote around the field type
\\1 # End of optional quote around the field type
\ # A single space
"[^"]+" # Matching a quoted search string
"""
SEARCH_PAIR_WITHOUT_GROUPS_RE = re.compile(
SEARCH_PAIR_WITHOUT_GROUPS, flags=(re.UNICODE | re.VERBOSE))
# TODO Would be nice to get ("?)...\1 working for the quotes here
SEARCH_PAIR_WITH_GROUPS = r"""
\b # Only begin matching at word bundaries
"? # Optional quote around the field type
(?P<field>( # A capturing group for the field type
""" + SEARCH_FIELDS + """
))
"? # End of optional quote around the field type
\ # A single space
"(?P<what>[^"]+)" # Capturing a quoted search string
"([^"]+)" # Capturing a quoted search string
"""
SEARCH_PAIR_WITH_GROUPS_RE = re.compile(
SEARCH_PAIR_WITH_GROUPS, flags=(re.UNICODE | re.VERBOSE))
@ -99,18 +84,18 @@ def _query_from_mpd_search_format(mpd_query):
:param mpd_query: the MPD search query
:type mpd_query: string
"""
pairs = SEARCH_PAIR_WITHOUT_GROUPS_RE.findall(mpd_query)
matches = SEARCH_PAIR_WITH_GROUPS_RE.findall(mpd_query)
query = {}
for pair in pairs:
m = SEARCH_PAIR_WITH_GROUPS_RE.match(pair)
field = m.groupdict()['field'].lower()
# discard first field, which just captures optional quote
for _, field, what in matches:
field = field.lower()
if field == 'title':
field = 'track_name'
elif field == 'track':
field = 'track_no'
elif field in ('file', 'filename'):
field = 'uri'
what = m.groupdict()['what']
if not what:
raise ValueError
if field in query:

View File

@ -1,6 +1,6 @@
from __future__ import unicode_literals
from __future__ import division, unicode_literals
import datetime as dt
import datetime
from mopidy.mpd.exceptions import MpdNoExistError, MpdNotImplemented
from mopidy.mpd.protocol import handle_request
@ -80,16 +80,26 @@ def listplaylists(context):
continue
name = context.lookup_playlist_name_from_uri(playlist.uri)
result.append(('playlist', name))
last_modified = (
playlist.last_modified or dt.datetime.utcnow()).isoformat()
# Remove microseconds
last_modified = last_modified.split('.')[0]
# Add time zone information
last_modified = last_modified + 'Z'
result.append(('Last-Modified', last_modified))
result.append(('Last-Modified', _get_last_modified(playlist)))
return result
def _get_last_modified(playlist):
"""Formats last modified timestamp of a playlist for MPD.
Time in UTC with second precision, formatted in the ISO 8601 format, with
the "Z" time zone marker for UTC. For example, "1970-01-01T00:00:00Z".
"""
if playlist.last_modified is None:
# If unknown, assume the playlist is modified
dt = datetime.datetime.utcnow()
else:
dt = datetime.datetime.utcfromtimestamp(
playlist.last_modified / 1000.0)
dt = dt.replace(microsecond=0)
return '%sZ' % dt.isoformat()
@handle_request(
r'load\ "(?P<name>[^"]+)"(\ "(?P<start>\d+):(?P<end>\d+)*")*$')
def load(context, name, start=None, end=None):

View File

@ -39,7 +39,6 @@ def setup_logging(config, verbosity_level, save_debug_log):
# added. If not, the other handlers will have no effect.
logging.config.fileConfig(config['logging']['config_file'])
setup_log_levels(config)
setup_console_logging(config, verbosity_level)
if save_debug_log:
setup_debug_logging_to_file(config)
@ -47,11 +46,6 @@ def setup_logging(config, verbosity_level, save_debug_log):
_delayed_handler.release()
def setup_log_levels(config):
for name, level in config['loglevels'].items():
logging.getLogger(name).setLevel(level)
LOG_LEVELS = {
-1: dict(root=logging.ERROR, mopidy=logging.WARNING),
0: dict(root=logging.ERROR, mopidy=logging.INFO),
@ -62,10 +56,15 @@ LOG_LEVELS = {
class VerbosityFilter(logging.Filter):
def __init__(self, verbosity_level):
def __init__(self, verbosity_level, loglevels):
self.verbosity_level = verbosity_level
self.loglevels = loglevels
def filter(self, record):
for name, required_log_level in self.loglevels.items():
if record.name == name or record.name.startswith(name + '.'):
return record.levelno >= required_log_level
if record.name.startswith('mopidy'):
required_log_level = LOG_LEVELS[self.verbosity_level]['mopidy']
else:
@ -79,9 +78,13 @@ def setup_console_logging(config, verbosity_level):
if verbosity_level > max(LOG_LEVELS.keys()):
verbosity_level = max(LOG_LEVELS.keys())
verbosity_filter = VerbosityFilter(verbosity_level)
loglevels = config.get('loglevels', {})
has_debug_loglevels = any([
level < logging.INFO for level in loglevels.values()])
if verbosity_level < 1:
verbosity_filter = VerbosityFilter(verbosity_level, loglevels)
if verbosity_level < 1 and not has_debug_loglevels:
log_format = config['logging']['console_format']
else:
log_format = config['logging']['debug_format']

View File

@ -5,6 +5,7 @@ import gobject
import logging
import re
import socket
import sys
import threading
import pykka
@ -43,7 +44,12 @@ def create_socket():
if has_ipv6:
sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
# Explicitly configure socket to work for both IPv4 and IPv6
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
if hasattr(socket, 'IPPROTO_IPV6'):
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
elif sys.platform == 'win32': # also match 64bit windows.
# Python 2.7 on windows does not have the IPPROTO_IPV6 constant
# Use values extracted from Windows Vista/7/8's header
sock.setsockopt(41, 27, 0)
else:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

View File

@ -63,7 +63,7 @@ class Zeroconf(object):
"""
if _is_loopback_address(self.host):
logger.info(
logger.debug(
'Zeroconf publish on loopback interface is not supported.')
return False

View File

@ -1,6 +1,5 @@
from __future__ import unicode_literals
import datetime
import unittest
from mopidy.mpd.protocol import music_db
@ -12,7 +11,7 @@ from tests.mpd import protocol
class QueryFromMpdSearchFormatTest(unittest.TestCase):
def test_dates_are_extracted(self):
result = music_db._query_from_mpd_search_format(
'Date "1974-01-02" Date "1975"')
'Date "1974-01-02" "Date" "1975"')
self.assertEqual(result['date'][0], '1974-01-02')
self.assertEqual(result['date'][1], '1975')
@ -237,7 +236,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase):
self.assertEqual(response1, response2)
def test_lsinfo_without_path_returns_same_as_for_root(self):
last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345)
last_modified = 1390942873222
self.backend.playlists.playlists = [
Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]
@ -246,7 +245,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase):
self.assertEqual(response1, response2)
def test_lsinfo_with_empty_path_returns_same_as_for_root(self):
last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345)
last_modified = 1390942873222
self.backend.playlists.playlists = [
Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]
@ -255,14 +254,14 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase):
self.assertEqual(response1, response2)
def test_lsinfo_for_root_includes_playlists(self):
last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345)
last_modified = 1390942873222
self.backend.playlists.playlists = [
Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]
self.sendRequest('lsinfo "/"')
self.assertInResponse('playlist: a')
# Date without microseconds and with time zone information
self.assertInResponse('Last-Modified: 2001-03-17T13:41:17Z')
# Date without milliseconds and with time zone information
self.assertInResponse('Last-Modified: 2014-01-28T21:01:13Z')
self.assertInResponse('OK')
def test_lsinfo_for_root_includes_dirs_for_each_lib_with_content(self):

View File

@ -1,7 +1,5 @@
from __future__ import unicode_literals
import datetime
from mopidy.models import Track, Playlist
from tests.mpd import protocol
@ -78,14 +76,14 @@ class PlaylistsHandlerTest(protocol.BaseTestCase):
self.assertInResponse('OK')
def test_listplaylists(self):
last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345)
last_modified = 1390942873222
self.backend.playlists.playlists = [
Playlist(name='a', uri='dummy:a', last_modified=last_modified)]
self.sendRequest('listplaylists')
self.assertInResponse('playlist: a')
# Date without microseconds and with time zone information
self.assertInResponse('Last-Modified: 2001-03-17T13:41:17Z')
# Date without milliseconds and with time zone information
self.assertInResponse('Last-Modified: 2014-01-28T21:01:13Z')
self.assertInResponse('OK')
def test_listplaylists_duplicate(self):
@ -99,7 +97,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase):
self.assertInResponse('OK')
def test_listplaylists_ignores_playlists_without_name(self):
last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345)
last_modified = 1390942873222
self.backend.playlists.playlists = [
Playlist(name='', uri='dummy:', last_modified=last_modified)]

View File

@ -1,6 +1,5 @@
from __future__ import unicode_literals
import datetime
import json
import unittest
@ -842,7 +841,7 @@ class PlaylistTest(unittest.TestCase):
self.assertEqual(playlist.length, 3)
def test_last_modified(self):
last_modified = datetime.datetime.utcnow()
last_modified = 1390942873000
playlist = Playlist(last_modified=last_modified)
self.assertEqual(playlist.last_modified, last_modified)
self.assertRaises(
@ -850,7 +849,7 @@ class PlaylistTest(unittest.TestCase):
def test_with_new_uri(self):
tracks = [Track()]
last_modified = datetime.datetime.utcnow()
last_modified = 1390942873000
playlist = Playlist(
uri='an uri', name='a name', tracks=tracks,
last_modified=last_modified)
@ -862,7 +861,7 @@ class PlaylistTest(unittest.TestCase):
def test_with_new_name(self):
tracks = [Track()]
last_modified = datetime.datetime.utcnow()
last_modified = 1390942873000
playlist = Playlist(
uri='an uri', name='a name', tracks=tracks,
last_modified=last_modified)
@ -874,7 +873,7 @@ class PlaylistTest(unittest.TestCase):
def test_with_new_tracks(self):
tracks = [Track()]
last_modified = datetime.datetime.utcnow()
last_modified = 1390942873000
playlist = Playlist(
uri='an uri', name='a name', tracks=tracks,
last_modified=last_modified)
@ -887,8 +886,8 @@ class PlaylistTest(unittest.TestCase):
def test_with_new_last_modified(self):
tracks = [Track()]
last_modified = datetime.datetime.utcnow()
new_last_modified = last_modified + datetime.timedelta(1)
last_modified = 1390942873000
new_last_modified = last_modified + 1000
playlist = Playlist(
uri='an uri', name='a name', tracks=tracks,
last_modified=last_modified)

View File

@ -43,5 +43,6 @@ class VersionTest(unittest.TestCase):
self.assertLess(SV('0.15.0'), SV('0.16.0'))
self.assertLess(SV('0.16.0'), SV('0.17.0'))
self.assertLess(SV('0.17.0'), SV('0.18.0'))
self.assertLess(SV('0.18.0'), SV(__version__))
self.assertLess(SV(__version__), SV('0.18.2'))
self.assertLess(SV('0.18.0'), SV('0.18.1'))
self.assertLess(SV('0.18.1'), SV(__version__))
self.assertLess(SV(__version__), SV('0.18.3'))