Mopidy-2.0.0/0000775000175000017500000000000012660436443013170 5ustar jodaljodal00000000000000Mopidy-2.0.0/PKG-INFO0000664000175000017500000001063512660436443014272 0ustar jodaljodal00000000000000Metadata-Version: 1.1 Name: Mopidy Version: 2.0.0 Summary: Music server with MPD and Spotify support Home-page: http://www.mopidy.com/ Author: Stein Magnus Jodal Author-email: stein.magnus@jodal.no License: Apache License, Version 2.0 Description: ****** Mopidy ****** Mopidy is an extensible music server written in Python. Mopidy plays music from local disk, Spotify, SoundCloud, Google Play Music, and more. You edit the playlist from any phone, tablet, or computer using a range of MPD and web clients. **Stream music from the cloud** Vanilla Mopidy only plays music from your local disk and radio streams. Through extensions, Mopidy can play music from cloud services like Spotify, SoundCloud, and Google Play Music. With Mopidy's extension support, backends for new music sources can be easily added. **Mopidy is just a server** Mopidy is a Python application that runs in a terminal or in the background on Linux computers or Macs that have network connectivity and audio output. Out of the box, Mopidy is an MPD and HTTP server. Additional frontends for controlling Mopidy can be installed from extensions. **Everybody use their favorite client** You and the people around you can all connect their favorite MPD or web client to the Mopidy server to search for music and manage the playlist together. With a browser or MPD client, which is available for all popular operating systems, you can control the music from any phone, tablet, or computer. **Mopidy on Raspberry Pi** The Raspberry Pi is a popular device to run Mopidy on, either using Raspbian or Arch Linux. It is quite slow, but it is very affordable. In fact, the Kickstarter funded Gramofon: Modern Cloud Jukebox project used Mopidy on a Raspberry Pi to prototype the Gramofon device. Mopidy is also a major building block in the Pi Musicbox integrated audio jukebox system for Raspberry Pi. **Mopidy is hackable** Mopidy's extension support and Python, JSON-RPC, and JavaScript APIs makes Mopidy perfect for building your own hacks. In one project, a Raspberry Pi was embedded in an old cassette player. The buttons and volume control are wired up with GPIO on the Raspberry Pi, and is used to control playback through a custom Mopidy extension. The cassettes have NFC tags used to select playlists from Spotify. To get started with Mopidy, check out `the installation docs `_. - `Documentation `_ - `Discussion forum `_ - `Source code `_ - `Issue tracker `_ - IRC: ``#mopidy`` at `irc.freenode.net `_ - Announcement list: `mopidy@googlegroups.com `_ - Twitter: `@mopidy `_ .. image:: https://img.shields.io/pypi/v/Mopidy.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy/ :alt: Latest PyPI version .. image:: https://img.shields.io/pypi/dm/Mopidy.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy/ :alt: Number of PyPI downloads .. image:: https://img.shields.io/travis/mopidy/mopidy/develop.svg?style=flat :target: https://travis-ci.org/mopidy/mopidy :alt: Travis CI build status .. image:: https://img.shields.io/coveralls/mopidy/mopidy/develop.svg?style=flat :target: https://coveralls.io/r/mopidy/mopidy?branch=develop :alt: Test coverage Platform: UNKNOWN Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: No Input/Output (Daemon) Classifier: Intended Audience :: End Users/Desktop Classifier: License :: OSI Approved :: Apache Software License Classifier: Operating System :: MacOS :: MacOS X Classifier: Operating System :: POSIX :: Linux Classifier: Programming Language :: Python :: 2.7 Classifier: Topic :: Multimedia :: Sound/Audio :: Players Mopidy-2.0.0/tasks.py0000664000175000017500000000217412505224626014667 0ustar jodaljodal00000000000000import sys from invoke import run, task @task def docs(watch=False, warn=False): if watch: return watcher(docs) run('make -C docs/ html', warn=warn) @task def test(path=None, coverage=False, watch=False, warn=False): if watch: return watcher(test, path=path, coverage=coverage) path = path or 'tests/' cmd = 'py.test' if coverage: cmd += ' --cov=mopidy --cov-report=term-missing' cmd += ' %s' % path run(cmd, pty=True, warn=warn) @task def lint(watch=False, warn=False): if watch: return watcher(lint) run('flake8', warn=warn) @task def update_authors(): # Keep authors in the order of appearance and use awk to filter out dupes run("git log --format='- %aN <%aE>' --reverse | awk '!x[$0]++' > AUTHORS") def watcher(task, *args, **kwargs): while True: run('clear') kwargs['warn'] = True task(*args, **kwargs) try: run( 'inotifywait -q -e create -e modify -e delete ' '--exclude ".*\.(pyc|sw.)" -r docs/ mopidy/ tests/') except KeyboardInterrupt: sys.exit() Mopidy-2.0.0/setup.py0000664000175000017500000000342112635122146014674 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import re from setuptools import find_packages, setup def get_version(filename): with open(filename) as fh: metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", fh.read())) return metadata['version'] setup( name='Mopidy', version=get_version('mopidy/__init__.py'), url='http://www.mopidy.com/', license='Apache License, Version 2.0', author='Stein Magnus Jodal', author_email='stein.magnus@jodal.no', description='Music server with MPD and Spotify support', long_description=open('README.rst').read(), packages=find_packages(exclude=['tests', 'tests.*']), zip_safe=False, include_package_data=True, install_requires=[ 'Pykka >= 1.1', 'requests >= 2.0', 'setuptools', 'tornado >= 2.3', ], extras_require={'http': []}, entry_points={ 'console_scripts': [ 'mopidy = mopidy.__main__:main', ], 'mopidy.ext': [ 'http = mopidy.http:Extension', 'local = mopidy.local:Extension', 'file = mopidy.file:Extension', 'm3u = mopidy.m3u:Extension', 'mpd = mopidy.mpd:Extension', 'softwaremixer = mopidy.softwaremixer:Extension', 'stream = mopidy.stream:Extension', ], }, classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: No Input/Output (Daemon)', 'Intended Audience :: End Users/Desktop', 'License :: OSI Approved :: Apache Software License', 'Operating System :: MacOS :: MacOS X', 'Operating System :: POSIX :: Linux', 'Programming Language :: Python :: 2.7', 'Topic :: Multimedia :: Sound/Audio :: Players', ], ) Mopidy-2.0.0/dev-requirements.txt0000664000175000017500000000061112575004517017224 0ustar jodaljodal00000000000000# Automate tasks invoke # Build documentation sphinx # Check code style, errors, etc flake8 flake8-import-order # Mock dependencies in tests mock responses # Test runners pytest pytest-capturelog pytest-cov pytest-xdist tox # Check that MANIFEST.in matches Git repo contents before making a release check-manifest # To make wheel packages wheel # Securely upload packages to PyPI twine Mopidy-2.0.0/AUTHORS0000664000175000017500000000604112660436420014234 0ustar jodaljodal00000000000000- Stein Magnus Jodal - Johannes Knutsen - Thomas Adamcik - Kristian Klette - Martins Grunskis - Henrik Olsson - Antoine Pierlot-Garcin - John Bäckstrand - Fred Hatfull - Erling Børresen - David Caruso - Christian Johansen - Matt Bray - Trygve Aaberge - Wouter van Wijk - Jeremy B. Merrill - Adam Rigg - Ernst Bammer - Nick Steel - Zan Dobersek - Thomas Refis - Janez Troha - Tobias Sauerwein - Alli Witheford - Alexandre Petitjean - Terje Larsen - Javier Domingo Cansino - Pavol Babincak - Javier Domingo - Lasse Bigum - David Eisner - Pål Ruud - Thomas Kemmer - Paul Connolley - Luke Giuliani - Colin Montgomerie - Simon de Bakker - Arnaud Barisain-Monrose - Nathan Harper - Pierpaolo Frasa - Thomas Scholtes - Sam Willcocks - Ignasi Fosch - Arjun Naik - Christopher Schirner - Dmitry Sandalov - Lukas Vogel - Thomas Amland - Deni Bertovic - Ali Ukani - Dirk Groenen - John Cass - Laura Barber - Jakab Kristóf - Ronald Zielaznicki - Wojciech Wnętrzak - Camilo Nova - Dražen Lučanin - Naglis Jonaitis - Kyle Heyne - Tom Roth - Mark Greenwood - Stein Karlsen - Dejan Prokić - Eric Jahn - Mikhail Golubev - Danilo Bargen - Bjørnar Snoksrud - Giorgos Logiotatidis - Ben Evans - vrs01 - Cadel Watson - Loïck Bonniot - Gustaf Hallberg - kozec - Jelle van der Waa - Alex Malone - Daniel Hahler - Bryan Bennett Mopidy-2.0.0/Mopidy.egg-info/0000775000175000017500000000000012660436443016123 5ustar jodaljodal00000000000000Mopidy-2.0.0/Mopidy.egg-info/PKG-INFO0000664000175000017500000001063512660436442017224 0ustar jodaljodal00000000000000Metadata-Version: 1.1 Name: Mopidy Version: 2.0.0 Summary: Music server with MPD and Spotify support Home-page: http://www.mopidy.com/ Author: Stein Magnus Jodal Author-email: stein.magnus@jodal.no License: Apache License, Version 2.0 Description: ****** Mopidy ****** Mopidy is an extensible music server written in Python. Mopidy plays music from local disk, Spotify, SoundCloud, Google Play Music, and more. You edit the playlist from any phone, tablet, or computer using a range of MPD and web clients. **Stream music from the cloud** Vanilla Mopidy only plays music from your local disk and radio streams. Through extensions, Mopidy can play music from cloud services like Spotify, SoundCloud, and Google Play Music. With Mopidy's extension support, backends for new music sources can be easily added. **Mopidy is just a server** Mopidy is a Python application that runs in a terminal or in the background on Linux computers or Macs that have network connectivity and audio output. Out of the box, Mopidy is an MPD and HTTP server. Additional frontends for controlling Mopidy can be installed from extensions. **Everybody use their favorite client** You and the people around you can all connect their favorite MPD or web client to the Mopidy server to search for music and manage the playlist together. With a browser or MPD client, which is available for all popular operating systems, you can control the music from any phone, tablet, or computer. **Mopidy on Raspberry Pi** The Raspberry Pi is a popular device to run Mopidy on, either using Raspbian or Arch Linux. It is quite slow, but it is very affordable. In fact, the Kickstarter funded Gramofon: Modern Cloud Jukebox project used Mopidy on a Raspberry Pi to prototype the Gramofon device. Mopidy is also a major building block in the Pi Musicbox integrated audio jukebox system for Raspberry Pi. **Mopidy is hackable** Mopidy's extension support and Python, JSON-RPC, and JavaScript APIs makes Mopidy perfect for building your own hacks. In one project, a Raspberry Pi was embedded in an old cassette player. The buttons and volume control are wired up with GPIO on the Raspberry Pi, and is used to control playback through a custom Mopidy extension. The cassettes have NFC tags used to select playlists from Spotify. To get started with Mopidy, check out `the installation docs `_. - `Documentation `_ - `Discussion forum `_ - `Source code `_ - `Issue tracker `_ - IRC: ``#mopidy`` at `irc.freenode.net `_ - Announcement list: `mopidy@googlegroups.com `_ - Twitter: `@mopidy `_ .. image:: https://img.shields.io/pypi/v/Mopidy.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy/ :alt: Latest PyPI version .. image:: https://img.shields.io/pypi/dm/Mopidy.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy/ :alt: Number of PyPI downloads .. image:: https://img.shields.io/travis/mopidy/mopidy/develop.svg?style=flat :target: https://travis-ci.org/mopidy/mopidy :alt: Travis CI build status .. image:: https://img.shields.io/coveralls/mopidy/mopidy/develop.svg?style=flat :target: https://coveralls.io/r/mopidy/mopidy?branch=develop :alt: Test coverage Platform: UNKNOWN Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: No Input/Output (Daemon) Classifier: Intended Audience :: End Users/Desktop Classifier: License :: OSI Approved :: Apache Software License Classifier: Operating System :: MacOS :: MacOS X Classifier: Operating System :: POSIX :: Linux Classifier: Programming Language :: Python :: 2.7 Classifier: Topic :: Multimedia :: Sound/Audio :: Players Mopidy-2.0.0/Mopidy.egg-info/entry_points.txt0000664000175000017500000000043612660436442021423 0ustar jodaljodal00000000000000[console_scripts] mopidy = mopidy.__main__:main [mopidy.ext] file = mopidy.file:Extension http = mopidy.http:Extension local = mopidy.local:Extension m3u = mopidy.m3u:Extension mpd = mopidy.mpd:Extension softwaremixer = mopidy.softwaremixer:Extension stream = mopidy.stream:Extension Mopidy-2.0.0/Mopidy.egg-info/not-zip-safe0000664000175000017500000000000112660436442020350 0ustar jodaljodal00000000000000 Mopidy-2.0.0/Mopidy.egg-info/dependency_links.txt0000664000175000017500000000000112660436442022170 0ustar jodaljodal00000000000000 Mopidy-2.0.0/Mopidy.egg-info/top_level.txt0000664000175000017500000000000712660436442020651 0ustar jodaljodal00000000000000mopidy Mopidy-2.0.0/Mopidy.egg-info/requires.txt0000664000175000017500000000007712660436442020526 0ustar jodaljodal00000000000000Pykka >= 1.1 requests >= 2.0 setuptools tornado >= 2.3 [http] Mopidy-2.0.0/Mopidy.egg-info/SOURCES.txt0000664000175000017500000002233612660436443020015 0ustar jodaljodal00000000000000.mailmap .travis.yml AUTHORS LICENSE MANIFEST.in README.rst dev-requirements.txt setup.cfg setup.py tasks.py tox.ini Mopidy.egg-info/PKG-INFO Mopidy.egg-info/SOURCES.txt Mopidy.egg-info/dependency_links.txt Mopidy.egg-info/entry_points.txt Mopidy.egg-info/not-zip-safe Mopidy.egg-info/requires.txt Mopidy.egg-info/top_level.txt docs/Makefile docs/audio.rst docs/authors.rst docs/changelog.rst docs/codestyle.rst docs/command.rst docs/conf.py docs/config.rst docs/contributing.rst docs/devenv.rst docs/extensiondev.rst docs/glossary.rst docs/index.rst docs/releasing.rst docs/requirements.txt docs/running.rst docs/service.rst docs/sponsors.rst docs/troubleshooting.rst docs/versioning.rst docs/_static/mopidy.png docs/api/architecture.rst docs/api/audio.rst docs/api/backend.rst docs/api/commands.rst docs/api/config.rst docs/api/core.rst docs/api/ext.rst docs/api/frontend.rst docs/api/http-server.rst docs/api/http.rst docs/api/httpclient.rst docs/api/index.rst docs/api/js.rst docs/api/mixer.rst docs/api/models.rst docs/api/zeroconf.rst docs/clients/http.rst docs/clients/mpd-client-gmpc.png docs/clients/mpd-client-mpad.jpg docs/clients/mpd-client-mpdroid.jpg docs/clients/mpd-client-mpod.jpg docs/clients/mpd-client-ncmpcpp.png docs/clients/mpd-client-sonata.png docs/clients/mpd.rst docs/clients/mpris.rst docs/clients/rompr.png docs/clients/ubuntu-sound-menu.png docs/clients/upnp.rst docs/ext/api_explorer.png docs/ext/backends.rst docs/ext/file.rst docs/ext/frontends.rst docs/ext/http.rst docs/ext/local.rst docs/ext/local_images.jpg docs/ext/m3u.rst docs/ext/material_webclient.png docs/ext/mixers.rst docs/ext/mobile.png docs/ext/moped.png docs/ext/mopidy_party.png docs/ext/mopify.jpg docs/ext/mopster.png docs/ext/mpd.rst docs/ext/musicbox_webclient.png docs/ext/simple_webclient.png docs/ext/softwaremixer.rst docs/ext/spotmop.jpg docs/ext/stream.rst docs/ext/web.rst docs/installation/arch.rst docs/installation/debian.rst docs/installation/index.rst docs/installation/osx.rst docs/installation/raspberrypi.rst docs/installation/raspberrypi2.jpg docs/installation/source.rst docs/modules/index.rst docs/modules/local.rst docs/modules/mpd.rst extra/desktop/mopidy.desktop extra/mopidyctl/mopidyctl extra/mopidyctl/mopidyctl.8 extra/systemd/mopidy.service mopidy/__init__.py mopidy/__main__.py mopidy/backend.py mopidy/commands.py mopidy/compat.py mopidy/exceptions.py mopidy/ext.py mopidy/httpclient.py mopidy/listener.py mopidy/mixer.py mopidy/zeroconf.py mopidy/audio/__init__.py mopidy/audio/actor.py mopidy/audio/constants.py mopidy/audio/listener.py mopidy/audio/scan.py mopidy/audio/tags.py mopidy/audio/utils.py mopidy/config/__init__.py mopidy/config/default.conf mopidy/config/keyring.py mopidy/config/schemas.py mopidy/config/types.py mopidy/config/validators.py mopidy/core/__init__.py mopidy/core/actor.py mopidy/core/history.py mopidy/core/library.py mopidy/core/listener.py mopidy/core/mixer.py mopidy/core/playback.py mopidy/core/playlists.py mopidy/core/tracklist.py mopidy/file/__init__.py mopidy/file/backend.py mopidy/file/ext.conf mopidy/file/library.py mopidy/http/__init__.py mopidy/http/actor.py mopidy/http/ext.conf mopidy/http/handlers.py mopidy/http/data/clients.html mopidy/http/data/favicon.ico mopidy/http/data/mopidy.css mopidy/http/data/mopidy.js mopidy/http/data/mopidy.min.js mopidy/internal/__init__.py mopidy/internal/deprecation.py mopidy/internal/deps.py mopidy/internal/encoding.py mopidy/internal/formatting.py mopidy/internal/gi.py mopidy/internal/http.py mopidy/internal/jsonrpc.py mopidy/internal/log.py mopidy/internal/network.py mopidy/internal/path.py mopidy/internal/playlists.py mopidy/internal/process.py mopidy/internal/timer.py mopidy/internal/validation.py mopidy/internal/versioning.py mopidy/internal/xdg.py mopidy/local/__init__.py mopidy/local/actor.py mopidy/local/commands.py mopidy/local/ext.conf mopidy/local/json.py mopidy/local/library.py mopidy/local/playback.py mopidy/local/search.py mopidy/local/storage.py mopidy/local/translator.py mopidy/m3u/__init__.py mopidy/m3u/backend.py mopidy/m3u/ext.conf mopidy/m3u/playlists.py mopidy/m3u/translator.py mopidy/models/__init__.py mopidy/models/fields.py mopidy/models/immutable.py mopidy/models/serialize.py mopidy/mpd/__init__.py mopidy/mpd/actor.py mopidy/mpd/dispatcher.py mopidy/mpd/exceptions.py mopidy/mpd/ext.conf mopidy/mpd/session.py mopidy/mpd/tokenize.py mopidy/mpd/translator.py mopidy/mpd/uri_mapper.py mopidy/mpd/protocol/__init__.py mopidy/mpd/protocol/audio_output.py mopidy/mpd/protocol/channels.py mopidy/mpd/protocol/command_list.py mopidy/mpd/protocol/connection.py mopidy/mpd/protocol/current_playlist.py mopidy/mpd/protocol/mount.py mopidy/mpd/protocol/music_db.py mopidy/mpd/protocol/playback.py mopidy/mpd/protocol/reflection.py mopidy/mpd/protocol/status.py mopidy/mpd/protocol/stickers.py mopidy/mpd/protocol/stored_playlists.py mopidy/mpd/protocol/tagtype_list.py mopidy/softwaremixer/__init__.py mopidy/softwaremixer/ext.conf mopidy/softwaremixer/mixer.py mopidy/stream/__init__.py mopidy/stream/actor.py mopidy/stream/ext.conf tests/__init__.py tests/dummy_audio.py tests/dummy_backend.py tests/dummy_mixer.py tests/test_commands.py tests/test_exceptions.py tests/test_ext.py tests/test_help.py tests/test_httpclient.py tests/test_mixer.py tests/test_version.py tests/audio/__init__.py tests/audio/test_actor.py tests/audio/test_listener.py tests/audio/test_scan.py tests/audio/test_tags.py tests/audio/test_utils.py tests/backend/__init__.py tests/backend/test_backend.py tests/backend/test_listener.py tests/config/__init__.py tests/config/test_config.py tests/config/test_defaults.py tests/config/test_schemas.py tests/config/test_types.py tests/config/test_validator.py tests/core/__init__.py tests/core/test_actor.py tests/core/test_events.py tests/core/test_history.py tests/core/test_library.py tests/core/test_listener.py tests/core/test_mixer.py tests/core/test_playback.py tests/core/test_playlists.py tests/core/test_tracklist.py tests/data/blank.flac tests/data/blank.mp3 tests/data/blank.ogg tests/data/blank.wav tests/data/comment-ext.m3u tests/data/comment.m3u tests/data/empty-ext.m3u tests/data/empty.m3u tests/data/encoding-ext.m3u tests/data/encoding.m3u tests/data/file1.conf tests/data/file2.conf tests/data/file3.conf tests/data/file4.conf tests/data/one-ext.m3u tests/data/one.m3u tests/data/song1.flac tests/data/song1.mp3 tests/data/song1.ogg tests/data/song1.wav tests/data/song2.flac tests/data/song2.mp3 tests/data/song2.ogg tests/data/song2.wav tests/data/song3.flac tests/data/song3.mp3 tests/data/song3.ogg tests/data/song3.wav tests/data/song4.wav tests/data/two-ext.m3u tests/data/conf1.d/file1.conf tests/data/conf1.d/file2.conf tests/data/conf2.d/file1.conf tests/data/conf2.d/file2.conf.disabled tests/data/local/library.json.gz tests/data/scanner/empty.wav tests/data/scanner/example.log tests/data/scanner/plain.txt tests/data/scanner/playlist.m3u tests/data/scanner/sample.mp3 tests/data/scanner/advanced/song1.mp3 tests/data/scanner/advanced/song2.mp3 tests/data/scanner/advanced/song3.mp3 tests/data/scanner/advanced/subdir1/song4.mp3 tests/data/scanner/advanced/subdir1/song5.mp3 tests/data/scanner/advanced/subdir1/subsubdir/song8.mp3 tests/data/scanner/advanced/subdir1/subsubdir/song9.mp3 tests/data/scanner/advanced/subdir2/song6.mp3 tests/data/scanner/advanced/subdir2/song7.mp3 tests/data/scanner/empty/.gitignore tests/data/scanner/image/test.png tests/data/scanner/simple/song1.mp3 tests/data/scanner/simple/song1.ogg tests/file/__init__.py tests/file/conftest.py tests/file/test_browse.py tests/file/test_lookup.py tests/http/__init__.py tests/http/test_events.py tests/http/test_handlers.py tests/http/test_server.py tests/internal/__init__.py tests/internal/test_deps.py tests/internal/test_encoding.py tests/internal/test_http.py tests/internal/test_jsonrpc.py tests/internal/test_path.py tests/internal/test_playlists.py tests/internal/test_validation.py tests/internal/test_xdg.py tests/internal/network/__init__.py tests/internal/network/test_connection.py tests/internal/network/test_lineprotocol.py tests/internal/network/test_server.py tests/internal/network/test_utils.py tests/local/__init__.py tests/local/test_json.py tests/local/test_library.py tests/local/test_playback.py tests/local/test_search.py tests/local/test_tracklist.py tests/local/test_translator.py tests/m3u/__init__.py tests/m3u/test_playlists.py tests/m3u/test_translator.py tests/models/test_fields.py tests/models/test_legacy.py tests/models/test_models.py tests/mpd/__init__.py tests/mpd/test_actor.py tests/mpd/test_commands.py tests/mpd/test_dispatcher.py tests/mpd/test_exceptions.py tests/mpd/test_status.py tests/mpd/test_tokenizer.py tests/mpd/test_translator.py tests/mpd/protocol/__init__.py tests/mpd/protocol/test_audio_output.py tests/mpd/protocol/test_authentication.py tests/mpd/protocol/test_channels.py tests/mpd/protocol/test_command_list.py tests/mpd/protocol/test_connection.py tests/mpd/protocol/test_current_playlist.py tests/mpd/protocol/test_idle.py tests/mpd/protocol/test_mount.py tests/mpd/protocol/test_music_db.py tests/mpd/protocol/test_playback.py tests/mpd/protocol/test_reflection.py tests/mpd/protocol/test_regression.py tests/mpd/protocol/test_status.py tests/mpd/protocol/test_stickers.py tests/mpd/protocol/test_stored_playlists.py tests/stream/__init__.py tests/stream/test_library.py tests/stream/test_playback.pyMopidy-2.0.0/extra/0000775000175000017500000000000012660436443014313 5ustar jodaljodal00000000000000Mopidy-2.0.0/extra/desktop/0000775000175000017500000000000012660436443015764 5ustar jodaljodal00000000000000Mopidy-2.0.0/extra/desktop/mopidy.desktop0000664000175000017500000000036512505224626020660 0ustar jodaljodal00000000000000[Desktop Entry] Type=Application Version=1.0 Name=Mopidy Comment=Music server with support for MPD and HTTP clients Icon=audio-x-generic TryExec=mopidy Exec=mopidy Terminal=true Categories=AudioVideo;Audio;Player;ConsoleOnly; StartupNotify=true Mopidy-2.0.0/extra/mopidyctl/0000775000175000017500000000000012660436443016317 5ustar jodaljodal00000000000000Mopidy-2.0.0/extra/mopidyctl/mopidyctl0000775000175000017500000000105412505224626020245 0ustar jodaljodal00000000000000#!/bin/sh SELF=$(basename $0) DAEMON="/usr/bin/mopidy" DAEMON_USER="mopidy" CONFIG_FILES="/usr/share/mopidy/conf.d:/etc/mopidy/mopidy.conf" CMD="$DAEMON --config $CONFIG_FILES $@" if [ $# -eq 0 ]; then echo "Usage: $SELF [options]" 1>&2 echo "Examples:" 1>&2 echo " $SELF --help" 1>&2 echo " $SELF config" 1>&2 echo " $SELF local scan" 1>&2 exit 1 fi if [ $(id -u) -ne 0 ]; then echo "$SELF must be run as root" 1>&2 exit 2 fi echo "Running \"$CMD\" as user $DAEMON_USER" 1>&2 su -s /bin/sh -c "$CMD" -- $DAEMON_USER Mopidy-2.0.0/extra/mopidyctl/mopidyctl.80000664000175000017500000000107512505224626020413 0ustar jodaljodal00000000000000.\" Manpage for mopidyctl .TH "MOPIDYCTL" "8" "October 11, 2014" "1.0" "mopidyctl" .SH NAME mopidyctl \- manage the Mopidy music server system service .SH SYNOPSIS .B mopidyctl [any mopidy(1) option] .SH DESCRIPTION The \fBmopidyctl\fP command runs \fBmopidy\fP subcommands in the same environment as the Mopidy system service is running in. That is, as the same user and with the same config as the Mopidy system service is using. .SH OPTIONS mopidyctl(8) takes the same options as mopidy(1). .SH SEE ALSO mopidy(1) .SH COPYRIGHT 2014, Stein Magnus Jodal and contributors Mopidy-2.0.0/extra/systemd/0000775000175000017500000000000012660436443016003 5ustar jodaljodal00000000000000Mopidy-2.0.0/extra/systemd/mopidy.service0000664000175000017500000000052612505224626020665 0ustar jodaljodal00000000000000[Unit] Description=Mopidy music server After=avahi-daemon.service After=dbus.service After=network.target After=nss-lookup.target After=pulseaudio.service After=remote-fs.target After=sound.target [Service] User=mopidy ExecStart=/usr/bin/mopidy --config /usr/share/mopidy/conf.d:/etc/mopidy/mopidy.conf [Install] WantedBy=multi-user.target Mopidy-2.0.0/.travis.yml0000664000175000017500000000150512660436420015275 0ustar jodaljodal00000000000000sudo: required dist: trusty language: python python: - "2.7_with_system_site_packages" env: - TOX_ENV=py27 - TOX_ENV=py27-tornado23 - TOX_ENV=py27-tornado31 - TOX_ENV=docs - TOX_ENV=flake8 before_install: - "sudo sed -i '/127.0.1.1/d' /etc/hosts" # Workaround tornadoweb/tornado#1573 - "sudo apt-get update -qq" - "sudo apt-get install -y gir1.2-gst-plugins-base-1.0 gir1.2-gstreamer-1.0 graphviz-dev gstreamer1.0-plugins-good gstreamer1.0-plugins-bad python-gst-1.0" install: - "pip install tox" script: - "tox -e $TOX_ENV" after_success: - "if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi" branches: except: - debian notifications: irc: channels: - "irc.freenode.org#mopidy" on_success: change on_failure: change use_notice: true skip_join: true Mopidy-2.0.0/mopidy/0000775000175000017500000000000012660436443014471 5ustar jodaljodal00000000000000Mopidy-2.0.0/mopidy/backend.py0000664000175000017500000003053212660436420016430 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging from mopidy import listener, models logger = logging.getLogger(__name__) class Backend(object): """Backend API If the backend has problems during initialization it should raise :exc:`mopidy.exceptions.BackendError` with a descriptive error message. This will make Mopidy print the error message and exit so that the user can fix the issue. :param config: the entire Mopidy configuration :type config: dict :param audio: actor proxy for the audio subsystem :type audio: :class:`pykka.ActorProxy` for :class:`mopidy.audio.Audio` """ #: Actor proxy to an instance of :class:`mopidy.audio.Audio`. #: #: Should be passed to the backend constructor as the kwarg ``audio``, #: which will then set this field. audio = None #: The library provider. An instance of #: :class:`~mopidy.backend.LibraryProvider`, or :class:`None` if #: the backend doesn't provide a library. library = None #: The playback provider. An instance of #: :class:`~mopidy.backend.PlaybackProvider`, or :class:`None` if #: the backend doesn't provide playback. playback = None #: The playlists provider. An instance of #: :class:`~mopidy.backend.PlaylistsProvider`, or class:`None` if #: the backend doesn't provide playlists. playlists = None #: List of URI schemes this backend can handle. uri_schemes = [] # Because the providers is marked as pykka_traversible, we can't get() them # from another actor, and need helper methods to check if the providers are # set or None. def has_library(self): return self.library is not None def has_library_browse(self): return self.has_library() and self.library.root_directory is not None def has_playback(self): return self.playback is not None def has_playlists(self): return self.playlists is not None def ping(self): """Called to check if the actor is still alive.""" return True class LibraryProvider(object): """ :param backend: backend the controller is a part of :type backend: :class:`mopidy.backend.Backend` """ pykka_traversable = True root_directory = None """ :class:`mopidy.models.Ref.directory` instance with a URI and name set representing the root of this library's browse tree. URIs must use one of the schemes supported by the backend, and name should be set to a human friendly value. *MUST be set by any class that implements* :meth:`LibraryProvider.browse`. """ def __init__(self, backend): self.backend = backend def browse(self, uri): """ See :meth:`mopidy.core.LibraryController.browse`. If you implement this method, make sure to also set :attr:`root_directory`. *MAY be implemented by subclass.* """ return [] def get_distinct(self, field, query=None): """ See :meth:`mopidy.core.LibraryController.get_distinct`. *MAY be implemented by subclass.* Default implementation will simply return an empty set. Note that backends should always return an empty set for unexpected field types. """ return set() def get_images(self, uris): """ See :meth:`mopidy.core.LibraryController.get_images`. *MAY be implemented by subclass.* Default implementation will simply call lookup and try and use the album art for any tracks returned. Most extensions should replace this with something smarter or simply return an empty dictionary. """ result = {} for uri in uris: image_uris = set() for track in self.lookup(uri): if track.album and track.album.images: image_uris.update(track.album.images) result[uri] = [models.Image(uri=u) for u in image_uris] return result def lookup(self, uri): """ See :meth:`mopidy.core.LibraryController.lookup`. *MUST be implemented by subclass.* """ raise NotImplementedError def refresh(self, uri=None): """ See :meth:`mopidy.core.LibraryController.refresh`. *MAY be implemented by subclass.* """ pass def search(self, query=None, uris=None, exact=False): """ See :meth:`mopidy.core.LibraryController.search`. *MAY be implemented by subclass.* .. versionadded:: 1.0 The ``exact`` param which replaces the old ``find_exact``. """ pass class PlaybackProvider(object): """ :param audio: the audio actor :type audio: actor proxy to an instance of :class:`mopidy.audio.Audio` :param backend: the backend :type backend: :class:`mopidy.backend.Backend` """ pykka_traversable = True def __init__(self, audio, backend): self.audio = audio self.backend = backend def pause(self): """ Pause playback. *MAY be reimplemented by subclass.* :rtype: :class:`True` if successful, else :class:`False` """ return self.audio.pause_playback().get() def play(self): """ Start playback. *MAY be reimplemented by subclass.* :rtype: :class:`True` if successful, else :class:`False` """ return self.audio.start_playback().get() def prepare_change(self): """ Indicate that an URI change is about to happen. *MAY be reimplemented by subclass.* It is extremely unlikely it makes sense for any backends to override this. For most practical purposes it should be considered an internal call between backends and core that backend authors should not touch. """ self.audio.prepare_change().get() def translate_uri(self, uri): """ Convert custom URI scheme to real playable URI. *MAY be reimplemented by subclass.* This is very likely the *only* thing you need to override as a backend author. Typically this is where you convert any Mopidy specific URI to a real URI and then return it. If you can't convert the URI just return :class:`None`. :param uri: the URI to translate :type uri: string :rtype: string or :class:`None` if the URI could not be translated """ return uri def change_track(self, track): """ Swith to provided track. *MAY be reimplemented by subclass.* It is unlikely it makes sense for any backends to override this. For most practical purposes it should be considered an internal call between backends and core that backend authors should not touch. The default implementation will call :meth:`translate_uri` which is what you want to implement. :param track: the track to play :type track: :class:`mopidy.models.Track` :rtype: :class:`True` if successful, else :class:`False` """ uri = self.translate_uri(track.uri) if uri != track.uri: logger.debug( 'Backend translated URI from %s to %s', track.uri, uri) if not uri: return False self.audio.set_uri(uri).get() return True def resume(self): """ Resume playback at the same time position playback was paused. *MAY be reimplemented by subclass.* :rtype: :class:`True` if successful, else :class:`False` """ return self.audio.start_playback().get() def seek(self, time_position): """ Seek to a given time position. *MAY be reimplemented by subclass.* :param time_position: time position in milliseconds :type time_position: int :rtype: :class:`True` if successful, else :class:`False` """ return self.audio.set_position(time_position).get() def stop(self): """ Stop playback. *MAY be reimplemented by subclass.* Should not be used for tracking if tracks have been played or when we are done playing them. :rtype: :class:`True` if successful, else :class:`False` """ return self.audio.stop_playback().get() def get_time_position(self): """ Get the current time position in milliseconds. *MAY be reimplemented by subclass.* :rtype: int """ return self.audio.get_position().get() class PlaylistsProvider(object): """ A playlist provider exposes a collection of playlists, methods to create/change/delete playlists in this collection, and lookup of any playlist the backend knows about. :param backend: backend the controller is a part of :type backend: :class:`mopidy.backend.Backend` instance """ pykka_traversable = True def __init__(self, backend): self.backend = backend def as_list(self): """ Get a list of the currently available playlists. Returns a list of :class:`~mopidy.models.Ref` objects referring to the playlists. In other words, no information about the playlists' content is given. :rtype: list of :class:`mopidy.models.Ref` .. versionadded:: 1.0 """ raise NotImplementedError def get_items(self, uri): """ Get the items in a playlist specified by ``uri``. Returns a list of :class:`~mopidy.models.Ref` objects referring to the playlist's items. If a playlist with the given ``uri`` doesn't exist, it returns :class:`None`. :rtype: list of :class:`mopidy.models.Ref`, or :class:`None` .. versionadded:: 1.0 """ raise NotImplementedError def create(self, name): """ Create a new empty playlist with the given name. Returns a new playlist with the given name and an URI, or :class:`None` on failure. *MUST be implemented by subclass.* :param name: name of the new playlist :type name: string :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ raise NotImplementedError def delete(self, uri): """ Delete playlist identified by the URI. *MUST be implemented by subclass.* :param uri: URI of the playlist to delete :type uri: string """ raise NotImplementedError def lookup(self, uri): """ Lookup playlist with given URI in both the set of playlists and in any other playlist source. Returns the playlists or :class:`None` if not found. *MUST be implemented by subclass.* :param uri: playlist URI :type uri: string :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ raise NotImplementedError def refresh(self): """ Refresh the playlists in :attr:`playlists`. *MUST be implemented by subclass.* """ raise NotImplementedError def save(self, playlist): """ Save the given playlist. The playlist must have an ``uri`` attribute set. To create a new playlist with an URI, use :meth:`create`. Returns the saved playlist or :class:`None` on failure. *MUST be implemented by subclass.* :param playlist: the playlist to save :type playlist: :class:`mopidy.models.Playlist` :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ raise NotImplementedError class BackendListener(listener.Listener): """ Marker interface for recipients of events sent by the backend actors. Any Pykka actor that mixes in this class will receive calls to the methods defined here when the corresponding events happen in a backend actor. This interface is used both for looking up what actors to notify of the events, and for providing default implementations for those listeners that are not interested in all events. Normally, only the Core actor should mix in this class. """ @staticmethod def send(event, **kwargs): """Helper to allow calling of backend listener events""" listener.send(BackendListener, event, **kwargs) def playlists_loaded(self): """ Called when playlists are loaded or refreshed. *MAY* be implemented by actor. """ pass Mopidy-2.0.0/mopidy/audio/0000775000175000017500000000000012660436443015572 5ustar jodaljodal00000000000000Mopidy-2.0.0/mopidy/audio/utils.py0000664000175000017500000000633512660436420017306 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mopidy import httpclient from mopidy.internal.gi import Gst def calculate_duration(num_samples, sample_rate): """Determine duration of samples using GStreamer helper for precise math.""" return Gst.util_uint64_scale(num_samples, Gst.SECOND, sample_rate) def create_buffer(data, timestamp=None, duration=None): """Create a new GStreamer buffer based on provided data. Mainly intended to keep gst imports out of non-audio modules. .. versionchanged:: 2.0 ``capabilites`` argument was removed. """ if not data: raise ValueError('Cannot create buffer without data') buffer_ = Gst.Buffer.new_wrapped(data) if timestamp is not None: buffer_.pts = timestamp if duration is not None: buffer_.duration = duration return buffer_ def millisecond_to_clocktime(value): """Convert a millisecond time to internal GStreamer time.""" return value * Gst.MSECOND def clocktime_to_millisecond(value): """Convert an internal GStreamer time to millisecond time.""" return value // Gst.MSECOND def supported_uri_schemes(uri_schemes): """Determine which URIs we can actually support from provided whitelist. :param uri_schemes: list/set of URIs to check support for. :type uri_schemes: list or set or URI schemes as strings. :rtype: set of URI schemes we can support via this GStreamer install. """ supported_schemes = set() registry = Gst.Registry.get() for factory in registry.get_feature_list(Gst.ElementFactory): for uri in factory.get_uri_protocols(): if uri in uri_schemes: supported_schemes.add(uri) return supported_schemes def setup_proxy(element, config): """Configure a GStreamer element with proxy settings. :param element: element to setup proxy in. :type element: :class:`Gst.GstElement` :param config: proxy settings to use. :type config: :class:`dict` """ if not hasattr(element.props, 'proxy') or not config.get('hostname'): return element.set_property('proxy', httpclient.format_proxy(config, auth=False)) element.set_property('proxy-id', config.get('username')) element.set_property('proxy-pw', config.get('password')) class Signals(object): """Helper for tracking gobject signal registrations""" def __init__(self): self._ids = {} def connect(self, element, event, func, *args): """Connect a function + args to signal event on an element. Each event may only be handled by one callback in this implementation. """ assert (element, event) not in self._ids self._ids[(element, event)] = element.connect(event, func, *args) def disconnect(self, element, event): """Disconnect whatever handler we have for an element+event pair. Does nothing it the handler has already been removed. """ signal_id = self._ids.pop((element, event), None) if signal_id is not None: element.disconnect(signal_id) def clear(self): """Clear all registered signal handlers.""" for element, event in self._ids.keys(): element.disconnect(self._ids.pop((element, event))) Mopidy-2.0.0/mopidy/audio/__init__.py0000664000175000017500000000043412626555165017711 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals # flake8: noqa from .actor import Audio from .listener import AudioListener from .constants import PlaybackState from .utils import ( calculate_duration, create_buffer, millisecond_to_clocktime, supported_uri_schemes) Mopidy-2.0.0/mopidy/audio/tags.py0000664000175000017500000001216412660436420017101 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import collections import datetime import logging import numbers from mopidy import compat from mopidy.internal import log from mopidy.internal.gi import GLib, Gst from mopidy.models import Album, Artist, Track logger = logging.getLogger(__name__) 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 trace logged. Tag keys are all strings defined as part GStreamer under GstTagList_. .. _GstTagList: https://developer.gnome.org/gstreamer/stable/\ gstreamer-GstTagList.html :param taglist: A GStreamer taglist to be converted. :type taglist: :class:`Gst.TagList` :rtype: dictionary of tag keys with a list of values. """ result = collections.defaultdict(list) for n in range(taglist.n_tags()): tag = taglist.nth_tag_name(n) for i in range(taglist.get_tag_size(tag)): value = taglist.get_value_index(tag, i) if isinstance(value, GLib.Date): date = datetime.date( value.get_year(), value.get_month(), value.get_day()) result[tag].append(date.isoformat().decode('utf-8')) if isinstance(value, Gst.DateTime): result[tag].append(value.to_iso8601_string().decode('utf-8')) elif isinstance(value, bytes): result[tag].append(value.decode('utf-8', 'replace')) elif isinstance(value, (compat.text_type, bool, numbers.Number)): result[tag].append(value) else: logger.log( log.TRACE_LOG_LEVEL, 'Ignoring unknown tag data: %r = %r', tag, value) # TODO: dict(result) to not leak the defaultdict, or just use setdefault? return result # 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 tags: dictionary of tag keys with a list of values :type tags: :class:`dict` :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', 'musicbrainz-sortname') 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] album_kwargs['date'] = tags.get(Gst.TAG_DATE, [None])[0] if not album_kwargs['date']: datetime = tags.get(Gst.TAG_DATE_TIME, [None])[0] if datetime is not None: album_kwargs['date'] = datetime.split('T')[0] # 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 _artists( tags, artist_name, artist_id=None, artist_sortname=None): # Name missing, don't set artist if not tags.get(artist_name): return None # One artist name and either id or sortname, include all available fields if len(tags[artist_name]) == 1 and \ (artist_id in tags or artist_sortname in tags): attrs = {'name': tags[artist_name][0]} if artist_id in tags: attrs['musicbrainz_id'] = tags[artist_id][0] if artist_sortname in tags: attrs['sortname'] = tags[artist_sortname][0] return [Artist(**attrs)] # Multiple artist, provide artists with name only to avoid ambiguity. return [Artist(name=name) for name in tags[artist_name]] Mopidy-2.0.0/mopidy/audio/constants.py0000664000175000017500000000053612575004517020162 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals class PlaybackState(object): """ Enum of playback states. """ #: Constant representing the paused state. PAUSED = 'paused' #: Constant representing the playing state. PLAYING = 'playing' #: Constant representing the stopped state. STOPPED = 'stopped' Mopidy-2.0.0/mopidy/audio/scan.py0000664000175000017500000002044212660436420017065 0ustar jodaljodal00000000000000from __future__ import ( absolute_import, division, print_function, unicode_literals) import collections import time from mopidy import exceptions from mopidy.audio import tags as tags_lib, utils from mopidy.internal import encoding from mopidy.internal.gi import Gst, GstPbutils # GST_ELEMENT_FACTORY_LIST: _DECODER = 1 << 0 _AUDIO = 1 << 50 _DEMUXER = 1 << 5 _DEPAYLOADER = 1 << 8 _PARSER = 1 << 6 # GST_TYPE_AUTOPLUG_SELECT_RESULT: _SELECT_TRY = 0 _SELECT_EXPOSE = 1 _Result = collections.namedtuple( 'Result', ('uri', 'tags', 'duration', 'seekable', 'mime', 'playable')) # TODO: replace with a scan(uri, timeout=1000, proxy_config=None)? class Scanner(object): """ Helper to get tags and other relevant info from URIs. :param timeout: timeout for scanning a URI in ms :param proxy_config: dictionary containing proxy config strings. :type event: int """ def __init__(self, timeout=1000, proxy_config=None): self._timeout_ms = int(timeout) self._proxy_config = proxy_config or {} def scan(self, uri, timeout=None): """ Scan the given uri collecting relevant metadata. :param uri: URI of the resource to scan. :type uri: string :param timeout: timeout for scanning a URI in ms. Defaults to the ``timeout`` value used when creating the scanner. :type timeout: int :return: A named tuple containing ``(uri, tags, duration, seekable, mime)``. ``tags`` is a dictionary of lists for all the tags we found. ``duration`` is the length of the URI in milliseconds, or :class:`None` if the URI has no duration. ``seekable`` is boolean. indicating if a seek would succeed. """ timeout = int(timeout or self._timeout_ms) tags, duration, seekable, mime = None, None, None, None pipeline, signals = _setup_pipeline(uri, self._proxy_config) try: _start_pipeline(pipeline) tags, mime, have_audio = _process(pipeline, timeout) duration = _query_duration(pipeline) seekable = _query_seekable(pipeline) finally: signals.clear() pipeline.set_state(Gst.State.NULL) del pipeline return _Result(uri, tags, duration, seekable, mime, have_audio) # Turns out it's _much_ faster to just create a new pipeline for every as # decodebins and other elements don't seem to take well to being reused. def _setup_pipeline(uri, proxy_config=None): src = Gst.Element.make_from_uri(Gst.URIType.SRC, uri) if not src: raise exceptions.ScannerError('GStreamer can not open: %s' % uri) typefind = Gst.ElementFactory.make('typefind') decodebin = Gst.ElementFactory.make('decodebin') pipeline = Gst.ElementFactory.make('pipeline') for e in (src, typefind, decodebin): pipeline.add(e) src.link(typefind) typefind.link(decodebin) if proxy_config: utils.setup_proxy(src, proxy_config) signals = utils.Signals() signals.connect(typefind, 'have-type', _have_type, decodebin) signals.connect(decodebin, 'pad-added', _pad_added, pipeline) signals.connect(decodebin, 'autoplug-select', _autoplug_select) return pipeline, signals def _have_type(element, probability, caps, decodebin): decodebin.set_property('sink-caps', caps) struct = Gst.Structure.new_empty('have-type') struct.set_value('caps', caps.get_structure(0)) element.get_bus().post(Gst.Message.new_application(element, struct)) def _pad_added(element, pad, pipeline): sink = Gst.ElementFactory.make('fakesink') sink.set_property('sync', False) pipeline.add(sink) sink.sync_state_with_parent() pad.link(sink.get_static_pad('sink')) if pad.query_caps().is_subset(Gst.Caps.from_string('audio/x-raw')): # Probably won't happen due to autoplug-select fix, but lets play it # safe until we've tested more. struct = Gst.Structure.new_empty('have-audio') element.get_bus().post(Gst.Message.new_application(element, struct)) def _autoplug_select(element, pad, caps, factory): if factory.list_is_type(_DECODER | _AUDIO): struct = Gst.Structure.new_empty('have-audio') element.get_bus().post(Gst.Message.new_application(element, struct)) if not factory.list_is_type(_DEMUXER | _DEPAYLOADER | _PARSER): return _SELECT_EXPOSE return _SELECT_TRY def _start_pipeline(pipeline): result = pipeline.set_state(Gst.State.PAUSED) if result == Gst.StateChangeReturn.NO_PREROLL: pipeline.set_state(Gst.State.PLAYING) def _query_duration(pipeline, timeout=100): # 1. Try and get a duration, return if success. # 2. Some formats need to play some buffers before duration is found. # 3. Wait for a duration change event. # 4. Try and get a duration again. success, duration = pipeline.query_duration(Gst.Format.TIME) if success and duration >= 0: return duration // Gst.MSECOND result = pipeline.set_state(Gst.State.PLAYING) if result == Gst.StateChangeReturn.FAILURE: return None gst_timeout = timeout * Gst.MSECOND bus = pipeline.get_bus() bus.timed_pop_filtered(gst_timeout, Gst.MessageType.DURATION_CHANGED) success, duration = pipeline.query_duration(Gst.Format.TIME) if success and duration >= 0: return duration // Gst.MSECOND return None def _query_seekable(pipeline): query = Gst.Query.new_seeking(Gst.Format.TIME) pipeline.query(query) return query.parse_seeking()[1] def _process(pipeline, timeout_ms): bus = pipeline.get_bus() tags = {} mime = None have_audio = False missing_message = None types = ( Gst.MessageType.ELEMENT | Gst.MessageType.APPLICATION | Gst.MessageType.ERROR | Gst.MessageType.EOS | Gst.MessageType.ASYNC_DONE | Gst.MessageType.TAG ) timeout = timeout_ms previous = int(time.time() * 1000) while timeout > 0: message = bus.timed_pop_filtered(timeout * Gst.MSECOND, types) if message is None: break elif message.type == Gst.MessageType.ELEMENT: if GstPbutils.is_missing_plugin_message(message): missing_message = message elif message.type == Gst.MessageType.APPLICATION: if message.get_structure().get_name() == 'have-type': mime = message.get_structure().get_value('caps').get_name() if mime and ( mime.startswith('text/') or mime == 'application/xml'): return tags, mime, have_audio elif message.get_structure().get_name() == 'have-audio': have_audio = True elif message.type == Gst.MessageType.ERROR: error = encoding.locale_decode(message.parse_error()[0]) if missing_message and not mime: caps = missing_message.get_structure().get_value('detail') mime = caps.get_structure(0).get_name() return tags, mime, have_audio raise exceptions.ScannerError(error) elif message.type == Gst.MessageType.EOS: return tags, mime, have_audio elif message.type == Gst.MessageType.ASYNC_DONE: if message.src == pipeline: return tags, mime, have_audio elif message.type == Gst.MessageType.TAG: taglist = message.parse_tag() # Note that this will only keep the last tag. tags.update(tags_lib.convert_taglist(taglist)) now = int(time.time() * 1000) timeout -= now - previous previous = now raise exceptions.ScannerError('Timeout after %dms' % timeout_ms) if __name__ == '__main__': import os import sys from mopidy.internal import path scanner = Scanner(5000) for uri in sys.argv[1:]: if not Gst.uri_is_valid(uri): uri = path.path_to_uri(os.path.abspath(uri)) try: result = scanner.scan(uri) for key in ('uri', 'mime', 'duration', 'playable', 'seekable'): print('%-20s %s' % (key, getattr(result, key))) print('tags') for tag, value in result.tags.items(): print('%-20s %s' % (tag, value)) except exceptions.ScannerError as error: print('%s: %s' % (uri, error)) Mopidy-2.0.0/mopidy/audio/listener.py0000664000175000017500000000630412660436420017767 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mopidy import listener class AudioListener(listener.Listener): """ Marker interface for recipients of events sent by the audio actor. Any Pykka actor that mixes in this class will receive calls to the methods defined here when the corresponding events happen in the core actor. This interface is used both for looking up what actors to notify of the events, and for providing default implementations for those listeners that are not interested in all events. """ @staticmethod def send(event, **kwargs): """Helper to allow calling of audio listener events""" listener.send(AudioListener, event, **kwargs) def reached_end_of_stream(self): """ Called whenever the end of the audio stream is reached. *MAY* be implemented by actor. """ pass def stream_changed(self, uri): """ Called whenever the audio stream changes. *MAY* be implemented by actor. :param string uri: URI the stream has started playing. """ pass def position_changed(self, position): """ Called whenever the position of the stream changes. *MAY* be implemented by actor. :param int position: Position in milliseconds. """ pass def state_changed(self, old_state, new_state, target_state): """ Called after the playback state have changed. Will be called for both immediate and async state changes in GStreamer. Target state is used to when we should be in the target state, but temporarily need to switch to an other state. A typical example of this is buffering. When this happens an event with `old=PLAYING, new=PAUSED, target=PLAYING` will be emitted. Once we have caught up a `old=PAUSED, new=PLAYING, target=None` event will be be generated. Regular state changes will not have target state set as they are final states which should be stable. *MAY* be implemented by actor. :param old_state: the state before the change :type old_state: string from :class:`mopidy.core.PlaybackState` field :param new_state: the state after the change :type new_state: A :class:`mopidy.core.PlaybackState` field :type new_state: string from :class:`mopidy.core.PlaybackState` field :param target_state: the intended state :type target_state: string from :class:`mopidy.core.PlaybackState` 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 Mopidy-2.0.0/mopidy/audio/actor.py0000664000175000017500000007303512660436420017257 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging import os import threading import pykka from mopidy import exceptions from mopidy.audio import tags as tags_lib, utils from mopidy.audio.constants import PlaybackState from mopidy.audio.listener import AudioListener from mopidy.internal import deprecation, process from mopidy.internal.gi import GObject, Gst, GstPbutils logger = logging.getLogger(__name__) # This logger is only meant for debug logging of low level GStreamer info such # as callbacks, event, messages and direct interaction with GStreamer such as # set_state() on a pipeline. gst_logger = logging.getLogger('mopidy.audio.gst') _GST_STATE_MAPPING = { Gst.State.PLAYING: PlaybackState.PLAYING, Gst.State.PAUSED: PlaybackState.PAUSED, Gst.State.NULL: PlaybackState.STOPPED, } # TODO: expose this as a property on audio? class _Appsrc(object): """Helper class for dealing with appsrc based playback.""" def __init__(self): self._signals = utils.Signals() self.reset() def reset(self): """Reset the helper. Should be called whenever the source changes and we are not setting up a new appsrc. """ self.prepare(None, None, None, None) def prepare(self, caps, need_data, enough_data, seek_data): """Store info we will need when the appsrc element gets installed.""" self._signals.clear() self._source = None self._caps = caps self._need_data_callback = need_data self._seek_data_callback = seek_data self._enough_data_callback = enough_data def configure(self, source): """Configure the supplied source for use. Should be called whenever we get a new appsrc. """ source.set_property('caps', self._caps) source.set_property('format', b'time') source.set_property('stream-type', b'seekable') source.set_property('max-bytes', 1 << 20) # 1MB source.set_property('min-percent', 50) if self._need_data_callback: self._signals.connect(source, 'need-data', self._on_signal, self._need_data_callback) if self._seek_data_callback: self._signals.connect(source, 'seek-data', self._on_signal, self._seek_data_callback) if self._enough_data_callback: self._signals.connect(source, 'enough-data', self._on_signal, None, self._enough_data_callback) self._source = source def push(self, buffer_): if self._source is None: return False if buffer_ is None: gst_logger.debug('Sending appsrc end-of-stream event.') result = self._source.emit('end-of-stream') return result == Gst.FlowReturn.OK else: result = self._source.emit('push-buffer', buffer_) return result == Gst.FlowReturn.OK def _on_signal(self, element, clocktime, func): # This shim is used to ensure we always return true, and also handles # that not all the callbacks have a time argument. if clocktime is None: func() else: func(utils.clocktime_to_millisecond(clocktime)) return True # TODO: expose this as a property on audio when #790 gets further along. class _Outputs(Gst.Bin): def __init__(self): Gst.Bin.__init__(self) # TODO gst1: Set 'outputs' as the Bin name for easier debugging self._tee = Gst.ElementFactory.make('tee') self.add(self._tee) ghost_pad = Gst.GhostPad.new('sink', self._tee.get_static_pad('sink')) self.add_pad(ghost_pad) # Add an always connected fakesink which respects the clock so the tee # doesn't fail even if we don't have any outputs. fakesink = Gst.ElementFactory.make('fakesink') fakesink.set_property('sync', True) self._add(fakesink) def add_output(self, description): # XXX This only works for pipelines not in use until #790 gets done. try: output = Gst.parse_bin_from_description( description, ghost_unlinked_pads=True) except GObject.GError as ex: logger.error( 'Failed to create audio output "%s": %s', description, ex) raise exceptions.AudioException(bytes(ex)) self._add(output) logger.info('Audio output set to "%s"', description) def _add(self, element): queue = Gst.ElementFactory.make('queue') self.add(element) self.add(queue) queue.link(element) self._tee.link(queue) class SoftwareMixer(object): pykka_traversable = True def __init__(self, mixer): self._mixer = mixer self._element = None self._last_volume = None self._last_mute = None self._signals = utils.Signals() def setup(self, element, mixer_ref): self._element = element self._mixer.setup(mixer_ref) def teardown(self): self._signals.clear() self._mixer.teardown() def get_volume(self): return int(round(self._element.get_property('volume') * 100)) def set_volume(self, volume): self._element.set_property('volume', volume / 100.0) self._mixer.trigger_volume_changed(self.get_volume()) def get_mute(self): return self._element.get_property('mute') def set_mute(self, mute): self._element.set_property('mute', bool(mute)) self._mixer.trigger_mute_changed(self.get_mute()) class _Handler(object): def __init__(self, audio): self._audio = audio self._element = None self._pad = None self._message_handler_id = None self._event_handler_id = None def setup_message_handling(self, element): self._element = element bus = element.get_bus() bus.add_signal_watch() self._message_handler_id = bus.connect('message', self.on_message) def setup_event_handling(self, pad): self._pad = pad self._event_handler_id = pad.add_probe( Gst.PadProbeType.EVENT_BOTH, self.on_pad_event) def teardown_message_handling(self): bus = self._element.get_bus() bus.remove_signal_watch() bus.disconnect(self._message_handler_id) self._message_handler_id = None def teardown_event_handling(self): self._pad.remove_probe(self._event_handler_id) self._event_handler_id = None def on_message(self, bus, msg): if msg.type == Gst.MessageType.STATE_CHANGED: if msg.src != self._element: return old_state, new_state, pending_state = msg.parse_state_changed() self.on_playbin_state_changed(old_state, new_state, pending_state) elif msg.type == Gst.MessageType.BUFFERING: self.on_buffering(msg.parse_buffering(), msg.get_structure()) elif msg.type == Gst.MessageType.EOS: self.on_end_of_stream() elif msg.type == Gst.MessageType.ERROR: error, debug = msg.parse_error() self.on_error(error, debug) elif msg.type == Gst.MessageType.WARNING: error, debug = msg.parse_warning() self.on_warning(error, debug) elif msg.type == Gst.MessageType.ASYNC_DONE: self.on_async_done() elif msg.type == Gst.MessageType.TAG: taglist = msg.parse_tag() self.on_tag(taglist) elif msg.type == Gst.MessageType.ELEMENT: if GstPbutils.is_missing_plugin_message(msg): self.on_missing_plugin(msg) elif msg.type == Gst.MessageType.STREAM_START: self.on_stream_start() def on_pad_event(self, pad, pad_probe_info): event = pad_probe_info.get_event() if event.type == Gst.EventType.SEGMENT: self.on_segment(event.parse_segment()) return Gst.PadProbeReturn.OK def on_playbin_state_changed(self, old_state, new_state, pending_state): gst_logger.debug( 'Got STATE_CHANGED bus message: old=%s new=%s pending=%s', old_state.value_name, new_state.value_name, pending_state.value_name) if new_state == Gst.State.READY and pending_state == Gst.State.NULL: # XXX: We're not called on the last state change when going down to # NULL, so we rewrite the second to last call to get the expected # behavior. new_state = Gst.State.NULL pending_state = Gst.State.VOID_PENDING if pending_state != Gst.State.VOID_PENDING: return # Ignore intermediate state changes if new_state == Gst.State.READY: return # Ignore READY state as it's GStreamer specific new_state = _GST_STATE_MAPPING[new_state] old_state, self._audio.state = self._audio.state, new_state target_state = _GST_STATE_MAPPING.get(self._audio._target_state) if target_state is None: # XXX: Workaround for #1430, to be fixed properly by #1222. logger.debug('Race condition happened. See #1222 and #1430.') return if target_state == new_state: target_state = None logger.debug('Audio event: state_changed(old_state=%s, new_state=%s, ' 'target_state=%s)', old_state, new_state, target_state) AudioListener.send('state_changed', old_state=old_state, new_state=new_state, target_state=target_state) if new_state == PlaybackState.STOPPED: logger.debug('Audio event: stream_changed(uri=None)') AudioListener.send('stream_changed', uri=None) if 'GST_DEBUG_DUMP_DOT_DIR' in os.environ: Gst.debug_bin_to_dot_file( self._audio._playbin, Gst.DebugGraphDetails.ALL, 'mopidy') def on_buffering(self, percent, structure=None): if structure is not None and structure.has_field('buffering-mode'): buffering_mode = structure.get_enum( 'buffering-mode', Gst.BufferingMode) if buffering_mode == Gst.BufferingMode.LIVE: return # Live sources stall in paused. level = logging.getLevelName('TRACE') if percent < 10 and not self._audio._buffering: self._audio._playbin.set_state(Gst.State.PAUSED) self._audio._buffering = True level = logging.DEBUG if percent == 100: self._audio._buffering = False if self._audio._target_state == Gst.State.PLAYING: self._audio._playbin.set_state(Gst.State.PLAYING) level = logging.DEBUG gst_logger.log( level, 'Got BUFFERING bus message: percent=%d%%', percent) def on_end_of_stream(self): gst_logger.debug('Got EOS (end of stream) bus message.') logger.debug('Audio event: reached_end_of_stream()') self._audio._tags = {} AudioListener.send('reached_end_of_stream') def on_error(self, error, debug): error_msg = str(error).decode('utf-8') debug_msg = debug.decode('utf-8') gst_logger.debug( 'Got ERROR bus message: error=%r debug=%r', error_msg, debug_msg) gst_logger.error('GStreamer error: %s', error_msg) # TODO: is this needed? self._audio.stop_playback() def on_warning(self, error, debug): error_msg = str(error).decode('utf-8') debug_msg = debug.decode('utf-8') gst_logger.warning('GStreamer warning: %s', error_msg) gst_logger.debug( 'Got WARNING bus message: error=%r debug=%r', error_msg, debug_msg) def on_async_done(self): gst_logger.debug('Got ASYNC_DONE bus message.') def on_tag(self, taglist): tags = tags_lib.convert_taglist(taglist) gst_logger.debug('Got TAG bus message: tags=%r', dict(tags)) # Postpone emitting tags until stream start. if self._audio._pending_tags is not None: self._audio._pending_tags.update(tags) return # TODO: Add proper tests for only emitting changed tags. unique = object() changed = [] for key, value in tags.items(): # Update any tags that changed, and store changed keys. if self._audio._tags.get(key, unique) != value: self._audio._tags[key] = value changed.append(key) if changed: logger.debug('Audio event: tags_changed(tags=%r)', changed) AudioListener.send('tags_changed', tags=changed) def on_missing_plugin(self, msg): desc = GstPbutils.missing_plugin_message_get_description(msg) debug = GstPbutils.missing_plugin_message_get_installer_detail(msg) gst_logger.debug( 'Got missing-plugin bus message: description=%r', desc) logger.warning('Could not find a %s to handle media.', desc) if GstPbutils.install_plugins_supported(): logger.info('You might be able to fix this by running: ' 'gst-installer "%s"', debug) # TODO: store the missing plugins installer info in a file so we can # can provide a 'mopidy install-missing-plugins' if the system has the # required helper installed? def on_stream_start(self): gst_logger.debug('Got STREAM_START bus message') uri = self._audio._pending_uri logger.debug('Audio event: stream_changed(uri=%r)', uri) AudioListener.send('stream_changed', uri=uri) # Emit any postponed tags that we got after about-to-finish. tags, self._audio._pending_tags = self._audio._pending_tags, None self._audio._tags = tags if tags: logger.debug('Audio event: tags_changed(tags=%r)', tags.keys()) AudioListener.send('tags_changed', tags=tags.keys()) def on_segment(self, segment): gst_logger.debug( 'Got SEGMENT pad event: ' 'rate=%(rate)s format=%(format)s start=%(start)s stop=%(stop)s ' 'position=%(position)s', { 'rate': segment.rate, 'format': Gst.Format.get_name(segment.format), 'start': segment.start, 'stop': segment.stop, 'position': segment.position }) position_ms = segment.position // Gst.MSECOND logger.debug('Audio event: position_changed(position=%r)', position_ms) AudioListener.send('position_changed', position=position_ms) # TODO: create a player class which replaces the actors internals class Audio(pykka.ThreadingActor): """ Audio output through `GStreamer `_. """ #: The GStreamer state mapped to :class:`mopidy.audio.PlaybackState` state = PlaybackState.STOPPED #: The software mixing interface :class:`mopidy.audio.actor.SoftwareMixer` mixer = None def __init__(self, config, mixer): super(Audio, self).__init__() self._config = config self._target_state = Gst.State.NULL self._buffering = False self._tags = {} self._pending_uri = None self._pending_tags = None self._playbin = None self._outputs = None self._queue = None self._about_to_finish_callback = None self._handler = _Handler(self) self._appsrc = _Appsrc() self._signals = utils.Signals() if mixer and self._config['audio']['mixer'] == 'software': self.mixer = SoftwareMixer(mixer) def on_start(self): self._thread = threading.current_thread() try: self._setup_preferences() self._setup_playbin() self._setup_outputs() self._setup_audio_sink() except GObject.GError as ex: logger.exception(ex) process.exit_process() def on_stop(self): self._teardown_mixer() self._teardown_playbin() def _setup_preferences(self): # TODO: move out of audio actor? # Fix for https://github.com/mopidy/mopidy/issues/604 registry = Gst.Registry.get() jacksink = registry.find_feature('jackaudiosink', Gst.ElementFactory) if jacksink: jacksink.set_rank(Gst.Rank.SECONDARY) def _setup_playbin(self): playbin = Gst.ElementFactory.make('playbin') playbin.set_property('flags', 2) # GST_PLAY_FLAG_AUDIO # TODO: turn into config values... playbin.set_property('buffer-size', 5 << 20) # 5MB playbin.set_property('buffer-duration', 5 * Gst.SECOND) self._signals.connect(playbin, 'source-setup', self._on_source_setup) self._signals.connect(playbin, 'about-to-finish', self._on_about_to_finish) self._playbin = playbin self._handler.setup_message_handling(playbin) def _teardown_playbin(self): self._handler.teardown_message_handling() self._handler.teardown_event_handling() self._signals.disconnect(self._playbin, 'about-to-finish') self._signals.disconnect(self._playbin, 'source-setup') self._playbin.set_state(Gst.State.NULL) def _setup_outputs(self): # We don't want to use outputs for regular testing, so just install # an unsynced fakesink when someone asks for a 'testoutput'. if self._config['audio']['output'] == 'testoutput': self._outputs = Gst.ElementFactory.make('fakesink') else: self._outputs = _Outputs() try: self._outputs.add_output(self._config['audio']['output']) except exceptions.AudioException: process.exit_process() # TODO: move this up the chain self._handler.setup_event_handling( self._outputs.get_static_pad('sink')) def _setup_audio_sink(self): audio_sink = Gst.ElementFactory.make('bin', 'audio-sink') # Queue element to buy us time between the about-to-finish event and # the actual switch, i.e. about to switch can block for longer thanks # to this queue. # TODO: See if settings should be set to minimize latency. Previous # setting breaks appsrc, and settings before that broke on a few # systems. So leave the default to play it safe. queue = Gst.ElementFactory.make('queue') if self._config['audio']['buffer_time'] > 0: queue.set_property( 'max-size-time', self._config['audio']['buffer_time'] * Gst.MSECOND) audio_sink.add(queue) audio_sink.add(self._outputs) if self.mixer: volume = Gst.ElementFactory.make('volume') audio_sink.add(volume) queue.link(volume) volume.link(self._outputs) self.mixer.setup(volume, self.actor_ref.proxy().mixer) else: queue.link(self._outputs) ghost_pad = Gst.GhostPad.new('sink', queue.get_static_pad('sink')) audio_sink.add_pad(ghost_pad) self._playbin.set_property('audio-sink', audio_sink) self._queue = queue def _teardown_mixer(self): if self.mixer: self.mixer.teardown() def _on_about_to_finish(self, element): if self._thread == threading.current_thread(): logger.error( 'about-to-finish in actor, aborting to avoid deadlock.') return gst_logger.debug('Got about-to-finish event.') if self._about_to_finish_callback: logger.debug('Running about-to-finish callback.') self._about_to_finish_callback() def _on_source_setup(self, element, source): gst_logger.debug( 'Got source-setup signal: element=%s', source.__class__.__name__) if source.get_factory().get_name() == 'appsrc': self._appsrc.configure(source) else: self._appsrc.reset() utils.setup_proxy(source, self._config['proxy']) def set_uri(self, uri): """ Set URI of audio to be played. You *MUST* call :meth:`prepare_change` before calling this method. :param uri: the URI to play :type uri: string """ # XXX: Hack to workaround issue on Mac OS X where volume level # does not persist between track changes. mopidy/mopidy#886 if self.mixer is not None: current_volume = self.mixer.get_volume() else: current_volume = None self._pending_uri = uri self._pending_tags = {} self._playbin.set_property('uri', uri) if self.mixer is not None and current_volume is not None: self.mixer.set_volume(current_volume) def set_appsrc( self, caps, need_data=None, enough_data=None, seek_data=None): """ Switch to using appsrc for getting audio to be played. You *MUST* call :meth:`prepare_change` before calling this method. :param caps: GStreamer caps string describing the audio format to expect :type caps: string :param need_data: callback for when appsrc needs data :type need_data: callable which takes data length hint in ms :param enough_data: callback for when appsrc has enough data :type enough_data: callable :param seek_data: callback for when data from a new position is needed to continue playback :type seek_data: callable which takes time position in ms """ self._appsrc.prepare( Gst.Caps.from_string(caps), need_data, enough_data, seek_data) uri = 'appsrc://' self._pending_uri = uri self._playbin.set_property('uri', uri) def emit_data(self, buffer_): """ Call this to deliver raw audio data to be played. If the buffer is :class:`None`, the end-of-stream token is put on the playbin. We will get a GStreamer message when the stream playback reaches the token, and can then do any end-of-stream related tasks. Note that the URI must be set to ``appsrc://`` for this to work. Returns :class:`True` if data was delivered. :param buffer_: buffer to pass to appsrc :type buffer_: :class:`Gst.Buffer` or :class:`None` :rtype: boolean """ return self._appsrc.push(buffer_) def emit_end_of_stream(self): """ Put an end-of-stream token on the playbin. This is typically used in combination with :meth:`emit_data`. We will get a GStreamer message when the stream playback reaches the token, and can then do any end-of-stream related tasks. .. deprecated:: 1.0 Use :meth:`emit_data` with a :class:`None` buffer instead. """ deprecation.warn('audio.emit_end_of_stream') self._appsrc.push(None) def set_about_to_finish_callback(self, callback): """ Configure audio to use an about-to-finish callback. This should be used to achieve gapless playback. For this to work the callback *MUST* call :meth:`set_uri` with the new URI to play and block until this call has been made. :meth:`prepare_change` is not needed before :meth:`set_uri` in this one special case. :param callable callback: Callback to run when we need the next URI. """ self._about_to_finish_callback = callback def get_position(self): """ Get position in milliseconds. :rtype: int """ success, position = self._playbin.query_position(Gst.Format.TIME) if not success: # TODO: take state into account for this and possibly also return # None as the unknown value instead of zero? logger.debug('Position query failed') return 0 return utils.clocktime_to_millisecond(position) def set_position(self, position): """ Set position in milliseconds. :param position: the position in milliseconds :type position: int :rtype: :class:`True` if successful, else :class:`False` """ # TODO: double check seek flags in use. gst_position = utils.millisecond_to_clocktime(position) gst_logger.debug('Sending flushing seek: position=%r', gst_position) # Send seek event to the queue not the playbin. The default behavior # for bins is to forward this event to all sinks. Which results in # duplicate seek events making it to appsrc. Since elements are not # allowed to act on the seek event, only modify it, this should be safe # to do. result = self._queue.seek_simple( Gst.Format.TIME, Gst.SeekFlags.FLUSH, gst_position) return result def start_playback(self): """ Notify GStreamer that it should start playback. :rtype: :class:`True` if successfull, else :class:`False` """ return self._set_state(Gst.State.PLAYING) def pause_playback(self): """ Notify GStreamer that it should pause playback. :rtype: :class:`True` if successfull, else :class:`False` """ return self._set_state(Gst.State.PAUSED) def prepare_change(self): """ Notify GStreamer that we are about to change state of playback. This function *MUST* be called before changing URIs or doing changes like updating data that is being pushed. The reason for this is that GStreamer will reset all its state when it changes to :attr:`Gst.State.READY`. """ return self._set_state(Gst.State.READY) def stop_playback(self): """ Notify GStreamer that is should stop playback. :rtype: :class:`True` if successfull, else :class:`False` """ self._buffering = False return self._set_state(Gst.State.NULL) def wait_for_state_change(self): """Block until any pending state changes are complete. Should only be used by tests. """ self._playbin.get_state(timeout=Gst.CLOCK_TIME_NONE) def enable_sync_handler(self): """Enable manual processing of messages from bus. Should only be used by tests. """ def sync_handler(bus, message): self._handler.on_message(bus, message) return Gst.BusSyncReply.DROP bus = self._playbin.get_bus() bus.set_sync_handler(sync_handler) def _set_state(self, state): """ Internal method for setting the raw GStreamer state. .. digraph:: gst_state_transitions graph [rankdir="LR"]; node [fontsize=10]; "NULL" -> "READY" "PAUSED" -> "PLAYING" "PAUSED" -> "READY" "PLAYING" -> "PAUSED" "READY" -> "NULL" "READY" -> "PAUSED" :param state: State to set playbin to. One of: `Gst.State.NULL`, `Gst.State.READY`, `Gst.State.PAUSED` and `Gst.State.PLAYING`. :type state: :class:`Gst.State` :rtype: :class:`True` if successfull, else :class:`False` """ self._target_state = state result = self._playbin.set_state(state) gst_logger.debug( 'Changing state to %s: result=%s', state.value_name, result.value_name) if result == Gst.StateChangeReturn.FAILURE: logger.warning( 'Setting GStreamer state to %s failed', state.value_name) return False # TODO: at this point we could already emit stopped event instead # 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. Only needs to be called by sources such as ``appsrc`` which do not already inject tags in playbin, e.g. when using :meth:`emit_data` to deliver raw audio data to GStreamer. :param track: the current track :type track: :class:`mopidy.models.Track` """ taglist = Gst.TagList.new_empty() artists = [a for a in (track.artists or []) if a.name] def set_value(tag, value): gobject_value = GObject.Value() gobject_value.init(GObject.TYPE_STRING) gobject_value.set_string(value) taglist.add_value(Gst.TagMergeMode.REPLACE, tag, gobject_value) # Default to blank data to trick shoutcast into clearing any previous # values it might have. # TODO: Verify if this works at all, likely it doesn't. set_value(Gst.TAG_ARTIST, ' ') set_value(Gst.TAG_TITLE, ' ') set_value(Gst.TAG_ALBUM, ' ') if artists: set_value(Gst.TAG_ARTIST, ', '.join([a.name for a in artists])) if track.name: set_value(Gst.TAG_TITLE, track.name) if track.album and track.album.name: set_value(Gst.TAG_ALBUM, track.album.name) gst_logger.debug( 'Sending TAG event for track %r: %r', track.uri, taglist.to_string()) event = Gst.Event.new_tag(taglist) # TODO: check if we get this back on our own bus? self._playbin.send_event(event) 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 Mopidy-2.0.0/mopidy/config/0000775000175000017500000000000012660436443015736 5ustar jodaljodal00000000000000Mopidy-2.0.0/mopidy/config/types.py0000664000175000017500000002103112614502604017441 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging import re import socket from mopidy import compat from mopidy.config import validators from mopidy.internal import log, path def decode(value): if isinstance(value, compat.text_type): return value # TODO: only unescape \n \t and \\? return value.decode('string-escape').decode('utf-8') def encode(value): if not isinstance(value, compat.text_type): return value for char in ('\\', '\n', '\t'): # TODO: more escapes? value = value.replace(char, char.encode('unicode-escape')) return value.encode('utf-8') class ExpandedPath(bytes): def __new__(cls, original, expanded): return super(ExpandedPath, cls).__new__(cls, expanded) def __init__(self, original, expanded): self.original = original class DeprecatedValue(object): pass class ConfigValue(object): """Represents a config key's value and how to handle it. Normally you will only be interacting with sub-classes for config values that encode either deserialization behavior and/or validation. Each config value should be used for the following actions: 1. Deserializing from a raw string and validating, raising ValueError on failure. 2. Serializing a value back to a string that can be stored in a config. 3. Formatting a value to a printable form (useful for masking secrets). :class:`None` values should not be deserialized, serialized or formatted, the code interacting with the config should simply skip None config values. """ def deserialize(self, value): """Cast raw string to appropriate type.""" return value def serialize(self, value, display=False): """Convert value back to string for saving.""" if value is None: return b'' return bytes(value) class Deprecated(ConfigValue): """Deprecated value Used for ignoring old config values that are no longer in use, but should not cause the config parser to crash. """ def deserialize(self, value): return DeprecatedValue() def serialize(self, value, display=False): return DeprecatedValue() class String(ConfigValue): """String value. Is decoded as utf-8 and \\n \\t escapes should work and be preserved. """ def __init__(self, optional=False, choices=None): self._required = not optional self._choices = choices def deserialize(self, value): value = decode(value).strip() validators.validate_required(value, self._required) if not value: return None validators.validate_choice(value, self._choices) return value def serialize(self, value, display=False): if value is None: return b'' return encode(value) class Secret(String): """Secret string value. Is decoded as utf-8 and \\n \\t escapes should work and be preserved. Should be used for passwords, auth tokens etc. Will mask value when being displayed. """ def __init__(self, optional=False, choices=None): self._required = not optional self._choices = None # Choices doesn't make sense for secrets def serialize(self, value, display=False): if value is not None and display: return b'********' return super(Secret, self).serialize(value, display) class Integer(ConfigValue): """Integer value.""" def __init__( self, minimum=None, maximum=None, choices=None, optional=False): self._required = not optional self._minimum = minimum self._maximum = maximum self._choices = choices def deserialize(self, value): validators.validate_required(value, self._required) if not value: return None value = int(value) validators.validate_choice(value, self._choices) validators.validate_minimum(value, self._minimum) validators.validate_maximum(value, self._maximum) return value class Boolean(ConfigValue): """Boolean value. Accepts ``1``, ``yes``, ``true``, and ``on`` with any casing as :class:`True`. Accepts ``0``, ``no``, ``false``, and ``off`` with any casing as :class:`False`. """ true_values = ('1', 'yes', 'true', 'on') false_values = ('0', 'no', 'false', 'off') def __init__(self, optional=False): self._required = not optional def deserialize(self, value): validators.validate_required(value, self._required) if not value: return None if value.lower() in self.true_values: return True elif value.lower() in self.false_values: return False raise ValueError('invalid value for boolean: %r' % value) def serialize(self, value, display=False): if value: return b'true' else: return b'false' class List(ConfigValue): """List value. Supports elements split by commas or newlines. Newlines take presedence and empty list items will be filtered out. """ def __init__(self, optional=False): self._required = not optional def deserialize(self, value): if b'\n' in value: values = re.split(r'\s*\n\s*', value) else: values = re.split(r'\s*,\s*', value) values = (decode(v).strip() for v in values) values = filter(None, values) validators.validate_required(values, self._required) return tuple(values) def serialize(self, value, display=False): if not value: return b'' return b'\n ' + b'\n '.join(encode(v) for v in value if v) class LogColor(ConfigValue): def deserialize(self, value): validators.validate_choice(value.lower(), log.COLORS) return value.lower() def serialize(self, value, display=False): if value.lower() in log.COLORS: return value.lower() return b'' class LogLevel(ConfigValue): """Log level value. Expects one of ``critical``, ``error``, ``warning``, ``info``, ``debug``, or ``all``, with any casing. """ levels = { b'critical': logging.CRITICAL, b'error': logging.ERROR, b'warning': logging.WARNING, b'info': logging.INFO, b'debug': logging.DEBUG, b'all': logging.NOTSET, } def deserialize(self, value): validators.validate_choice(value.lower(), self.levels.keys()) return self.levels.get(value.lower()) def serialize(self, value, display=False): lookup = dict((v, k) for k, v in self.levels.items()) if value in lookup: return lookup[value] return b'' class Hostname(ConfigValue): """Network hostname value.""" def __init__(self, optional=False): self._required = not optional def deserialize(self, value, display=False): validators.validate_required(value, self._required) if not value.strip(): return None try: socket.getaddrinfo(value, None) except socket.error: raise ValueError('must be a resolveable hostname or valid IP') return value class Port(Integer): """Network port value. Expects integer in the range 0-65535, zero tells the kernel to simply allocate a port for us. """ # TODO: consider probing if port is free or not? def __init__(self, choices=None, optional=False): super(Port, self).__init__( minimum=0, maximum=2 ** 16 - 1, choices=choices, optional=optional) class Path(ConfigValue): """File system path The following expansions of the path will be done: - ``~`` to the current user's home directory - ``$XDG_CACHE_DIR`` according to the XDG spec - ``$XDG_CONFIG_DIR`` according to the XDG spec - ``$XDG_DATA_DIR`` according to the XDG spec - ``$XDG_MUSIC_DIR`` according to the XDG spec """ def __init__(self, optional=False): self._required = not optional def deserialize(self, value): value = value.strip() expanded = path.expand_path(value) validators.validate_required(value, self._required) validators.validate_required(expanded, self._required) if not value or expanded is None: return None return ExpandedPath(value, expanded) def serialize(self, value, display=False): if isinstance(value, compat.text_type): raise ValueError('paths should always be bytes') if isinstance(value, ExpandedPath): return value.original return value Mopidy-2.0.0/mopidy/config/schemas.py0000664000175000017500000000771112575004517017737 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import collections from mopidy.config import types def _did_you_mean(name, choices): """Suggest most likely setting based on levenshtein.""" if not choices: return None name = name.lower() candidates = [(_levenshtein(name, c), c) for c in choices] candidates.sort() if candidates[0][0] <= 3: return candidates[0][1] return None def _levenshtein(a, b): """Calculates the Levenshtein distance between a and b.""" n, m = len(a), len(b) if n > m: return _levenshtein(b, a) current = range(n + 1) for i in range(1, m + 1): previous, current = current, [i] + [0] * n for j in range(1, n + 1): add, delete = previous[j] + 1, current[j - 1] + 1 change = previous[j - 1] if a[j - 1] != b[i - 1]: change += 1 current[j] = min(add, delete, change) return current[n] class ConfigSchema(collections.OrderedDict): """Logical group of config values that correspond to a config section. Schemas are set up by assigning config keys with config values to instances. Once setup :meth:`deserialize` can be called with a dict of values to process. For convienience we also support :meth:`format` method that can used for converting the values to a dict that can be printed and :meth:`serialize` for converting the values to a form suitable for persistence. """ def __init__(self, name): super(ConfigSchema, self).__init__() self.name = name def deserialize(self, values): """Validates the given ``values`` using the config schema. Returns a tuple with cleaned values and errors. """ errors = {} result = {} for key, value in values.items(): try: result[key] = self[key].deserialize(value) except KeyError: # not in our schema errors[key] = 'unknown config key.' suggestion = _did_you_mean(key, self.keys()) if suggestion: errors[key] += ' Did you mean %s?' % suggestion except ValueError as e: # deserialization failed result[key] = None errors[key] = str(e) for key in self.keys(): if isinstance(self[key], types.Deprecated): result.pop(key, None) elif key not in result and key not in errors: result[key] = None errors[key] = 'config key not found.' return result, errors def serialize(self, values, display=False): """Converts the given ``values`` to a format suitable for persistence. If ``display`` is :class:`True` secret config values, like passwords, will be masked out. Returns a dict of config keys and values.""" result = collections.OrderedDict() for key in self.keys(): if key in values: result[key] = self[key].serialize(values[key], display) return result class MapConfigSchema(object): """Schema for handling multiple unknown keys with the same type. Does not sub-class :class:`ConfigSchema`, but implements the same serialize/deserialize interface. """ def __init__(self, name, value_type): self.name = name self._value_type = value_type def deserialize(self, values): errors = {} result = {} for key, value in values.items(): try: result[key] = self._value_type.deserialize(value) except ValueError as e: # deserialization failed result[key] = None errors[key] = str(e) return result, errors def serialize(self, values, display=False): result = collections.OrderedDict() for key in sorted(values.keys()): result[key] = self._value_type.serialize(values[key], display) return result Mopidy-2.0.0/mopidy/config/default.conf0000664000175000017500000000073512660436420020231 0ustar jodaljodal00000000000000[core] cache_dir = $XDG_CACHE_DIR/mopidy config_dir = $XDG_CONFIG_DIR/mopidy data_dir = $XDG_DATA_DIR/mopidy max_tracklist_length = 10000 [logging] color = true console_format = %(levelname)-8s %(message)s debug_format = %(levelname)-8s %(asctime)s [%(process)d:%(threadName)s] %(name)s\n %(message)s debug_file = mopidy.log config_file = [audio] mixer = software mixer_volume = output = autoaudiosink buffer_time = [proxy] scheme = hostname = port = username = password = Mopidy-2.0.0/mopidy/config/validators.py0000664000175000017500000000251312505224626020455 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals # TODO: add validate regexp? def validate_required(value, required): """Validate that ``value`` is set if ``required`` Normally called in :meth:`~mopidy.config.types.ConfigValue.deserialize` on the raw string, _not_ the converted value. """ if required and not value: raise ValueError('must be set.') def validate_choice(value, choices): """Validate that ``value`` is one of the ``choices`` Normally called in :meth:`~mopidy.config.types.ConfigValue.deserialize`. """ if choices is not None and value not in choices: names = ', '.join(repr(c) for c in choices) raise ValueError('must be one of %s, not %s.' % (names, value)) def validate_minimum(value, minimum): """Validate that ``value`` is at least ``minimum`` Normally called in :meth:`~mopidy.config.types.ConfigValue.deserialize`. """ if minimum is not None and value < minimum: raise ValueError('%r must be larger than %r.' % (value, minimum)) def validate_maximum(value, maximum): """Validate that ``value`` is at most ``maximum`` Normally called in :meth:`~mopidy.config.types.ConfigValue.deserialize`. """ if maximum is not None and value > maximum: raise ValueError('%r must be smaller than %r.' % (value, maximum)) Mopidy-2.0.0/mopidy/config/__init__.py0000664000175000017500000002367312660436420020055 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import io import itertools import logging import os.path import re from mopidy import compat from mopidy.compat import configparser from mopidy.config import keyring from mopidy.config.schemas import * # noqa from mopidy.config.types import * # noqa from mopidy.internal import path, versioning logger = logging.getLogger(__name__) _core_schema = ConfigSchema('core') _core_schema['cache_dir'] = Path() _core_schema['config_dir'] = Path() _core_schema['data_dir'] = Path() # MPD supports at most 10k tracks, some clients segfault when this is exceeded. _core_schema['max_tracklist_length'] = Integer(minimum=1, maximum=10000) _logging_schema = ConfigSchema('logging') _logging_schema['color'] = Boolean() _logging_schema['console_format'] = String() _logging_schema['debug_format'] = String() _logging_schema['debug_file'] = Path() _logging_schema['config_file'] = Path(optional=True) _loglevels_schema = MapConfigSchema('loglevels', LogLevel()) _logcolors_schema = MapConfigSchema('logcolors', LogColor()) _audio_schema = ConfigSchema('audio') _audio_schema['mixer'] = String() _audio_schema['mixer_track'] = Deprecated() _audio_schema['mixer_volume'] = Integer(optional=True, minimum=0, maximum=100) _audio_schema['output'] = String() _audio_schema['visualizer'] = Deprecated() _audio_schema['buffer_time'] = Integer(optional=True, minimum=1) _proxy_schema = ConfigSchema('proxy') _proxy_schema['scheme'] = String(optional=True, choices=['http', 'https', 'socks4', 'socks5']) _proxy_schema['hostname'] = Hostname(optional=True) _proxy_schema['port'] = Port(optional=True) _proxy_schema['username'] = String(optional=True) _proxy_schema['password'] = Secret(optional=True) # NOTE: if multiple outputs ever comes something like LogLevelConfigSchema # _outputs_schema = config.AudioOutputConfigSchema() _schemas = [ _core_schema, _logging_schema, _loglevels_schema, _logcolors_schema, _audio_schema, _proxy_schema] _INITIAL_HELP = """ # For further information about options in this file see: # http://docs.mopidy.com/ # # The initial commented out values reflect the defaults as of: # %(versions)s # # Available options and defaults might have changed since then, # run `mopidy config` to see the current effective config and # `mopidy --version` to check the current version. """ def read(config_file): """Helper to load config defaults in same way across core and extensions""" with io.open(config_file, 'rb') as filehandle: return filehandle.read() def load(files, ext_schemas, ext_defaults, overrides): config_dir = os.path.dirname(__file__) defaults = [read(os.path.join(config_dir, 'default.conf'))] defaults.extend(ext_defaults) raw_config = _load(files, defaults, keyring.fetch() + (overrides or [])) schemas = _schemas[:] schemas.extend(ext_schemas) return _validate(raw_config, schemas) def format(config, ext_schemas, comments=None, display=True): schemas = _schemas[:] schemas.extend(ext_schemas) return _format(config, comments or {}, schemas, display, False) def format_initial(extensions_data): config_dir = os.path.dirname(__file__) defaults = [read(os.path.join(config_dir, 'default.conf'))] defaults.extend(d.extension.get_default_config() for d in extensions_data) raw_config = _load([], defaults, []) schemas = _schemas[:] schemas.extend(d.extension.get_config_schema() for d in extensions_data) config, errors = _validate(raw_config, schemas) versions = ['Mopidy %s' % versioning.get_version()] extensions_data = sorted( extensions_data, key=lambda d: d.extension.dist_name) for data in extensions_data: versions.append('%s %s' % ( data.extension.dist_name, data.extension.version)) header = _INITIAL_HELP.strip() % {'versions': '\n# '.join(versions)} formatted_config = _format( config=config, comments={}, schemas=schemas, display=False, disable=True).decode('utf-8') return header + '\n\n' + formatted_config def _load(files, defaults, overrides): parser = configparser.RawConfigParser() # TODO: simply return path to config file for defaults so we can load it # all in the same way? logger.info('Loading config from builtin defaults') for default in defaults: if isinstance(default, compat.text_type): default = default.encode('utf-8') parser.readfp(io.BytesIO(default)) # Load config from a series of config files files = [path.expand_path(f) for f in files] for name in files: if os.path.isdir(name): for filename in os.listdir(name): filename = os.path.join(name, filename) if os.path.isfile(filename) and filename.endswith('.conf'): _load_file(parser, filename) else: _load_file(parser, name) # If there have been parse errors there is a python bug that causes the # values to be lists, this little trick coerces these into strings. parser.readfp(io.BytesIO()) raw_config = {} for section in parser.sections(): raw_config[section] = dict(parser.items(section)) logger.info('Loading config from command line options') for section, key, value in overrides: raw_config.setdefault(section, {})[key] = value return raw_config def _load_file(parser, filename): if not os.path.exists(filename): logger.debug( 'Loading config from %s failed; it does not exist', filename) return if not os.access(filename, os.R_OK): logger.warning( 'Loading config from %s failed; read permission missing', filename) return try: logger.info('Loading config from %s', filename) with io.open(filename, 'rb') as filehandle: parser.readfp(filehandle) except configparser.MissingSectionHeaderError as e: logger.warning('%s does not have a config section, not loaded.', filename) except configparser.ParsingError as e: linenos = ', '.join(str(lineno) for lineno, line in e.errors) logger.warning( '%s has errors, line %s has been ignored.', filename, linenos) except IOError: # TODO: if this is the initial load of logging config we might not # have a logger at this point, we might want to handle this better. logger.debug('Config file %s not found; skipping', filename) def _validate(raw_config, schemas): # Get validated config config = {} errors = {} sections = set(raw_config) for schema in schemas: sections.discard(schema.name) values = raw_config.get(schema.name, {}) result, error = schema.deserialize(values) if error: errors[schema.name] = error if result: config[schema.name] = result for section in sections: logger.debug('Ignoring unknown config section: %s', section) return config, errors def _format(config, comments, schemas, display, disable): output = [] for schema in schemas: serialized = schema.serialize( config.get(schema.name, {}), display=display) if not serialized: continue output.append(b'[%s]' % bytes(schema.name)) for key, value in serialized.items(): if isinstance(value, types.DeprecatedValue): continue comment = bytes(comments.get(schema.name, {}).get(key, '')) output.append(b'%s =' % bytes(key)) if value is not None: output[-1] += b' ' + value if comment: output[-1] += b' ; ' + comment.capitalize() if disable: output[-1] = re.sub(r'^', b'#', output[-1], flags=re.M) output.append(b'') return b'\n'.join(output).strip() def _preprocess(config_string): """Convert a raw config into a form that preserves comments etc.""" results = ['[__COMMENTS__]'] counter = itertools.count(0) section_re = re.compile(r'^(\[[^\]]+\])\s*(.+)$') blank_line_re = re.compile(r'^\s*$') comment_re = re.compile(r'^(#|;)') inline_comment_re = re.compile(r' ;') def newlines(match): return '__BLANK%d__ =' % next(counter) def comments(match): if match.group(1) == '#': return '__HASH%d__ =' % next(counter) elif match.group(1) == ';': return '__SEMICOLON%d__ =' % next(counter) def inlinecomments(match): return '\n__INLINE%d__ =' % next(counter) def sections(match): return '%s\n__SECTION%d__ = %s' % ( match.group(1), next(counter), match.group(2)) for line in config_string.splitlines(): line = blank_line_re.sub(newlines, line) line = section_re.sub(sections, line) line = comment_re.sub(comments, line) line = inline_comment_re.sub(inlinecomments, line) results.append(line) return '\n'.join(results) def _postprocess(config_string): """Converts a preprocessed config back to original form.""" flags = re.IGNORECASE | re.MULTILINE result = re.sub(r'^\[__COMMENTS__\](\n|$)', '', config_string, flags=flags) result = re.sub(r'\n__INLINE\d+__ =(.*)$', ' ;\g<1>', result, flags=flags) result = re.sub(r'^__HASH\d+__ =(.*)$', '#\g<1>', result, flags=flags) result = re.sub(r'^__SEMICOLON\d+__ =(.*)$', ';\g<1>', result, flags=flags) result = re.sub(r'\n__SECTION\d+__ =(.*)$', '\g<1>', result, flags=flags) result = re.sub(r'^__BLANK\d+__ =$', '', result, flags=flags) return result class Proxy(collections.Mapping): def __init__(self, data): self._data = data def __getitem__(self, key): item = self._data.__getitem__(key) if isinstance(item, dict): return Proxy(item) return item def __iter__(self): return self._data.__iter__() def __len__(self): return self._data.__len__() def __repr__(self): return b'Proxy(%r)' % self._data Mopidy-2.0.0/mopidy/config/keyring.py0000664000175000017500000001270112614502604017751 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging logger = logging.getLogger(__name__) try: import dbus except ImportError: dbus = None from mopidy import compat # XXX: Hack to workaround introspection bug caused by gnome-keyring, should be # fixed by version 3.5 per: # https://git.gnome.org/browse/gnome-keyring/commit/?id=5dccbe88eb94eea9934e2b7 if dbus: EMPTY_STRING = dbus.String('', variant_level=1) 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('%s (dbus not installed)', FETCH_ERROR) return [] try: bus = dbus.SessionBus() except dbus.exceptions.DBusException as e: logger.debug('%s (%s)', FETCH_ERROR, e) return [] if not bus.name_has_owner('org.freedesktop.secrets'): logger.debug( '%s (org.freedesktop.secrets service not running)', FETCH_ERROR) return [] service = _service(bus) session = service.OpenSession('plain', EMPTY_STRING)[1] items, locked = service.SearchItems({'service': 'mopidy'}) if not locked and not items: return [] if locked: # There is a chance we can unlock without prompting the users... items, prompt = service.Unlock(locked) if prompt != '/': _prompt(bus, prompt).Dismiss() logger.debug('%s (Keyring is locked)', FETCH_ERROR) return [] result = [] secrets = service.GetSecrets(items, session, byte_arrays=True) for item_path, values in secrets.items(): session_path, parameters, value, content_type = values attrs = _item_attributes(bus, item_path) result.append((attrs['section'], attrs['key'], bytes(value))) return result def set(section, key, value): """Store a secret config value for a given section/key. Indicates if storage failed or succeeded. """ if not dbus: 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) return False if not bus.name_has_owner('org.freedesktop.secrets'): logger.debug( 'Saving %s/%s to keyring failed. ' '(org.freedesktop.secrets service not running)', section, key) return False service = _service(bus) collection = _collection(bus) if not collection: return False if isinstance(value, compat.text_type): value = value.encode('utf-8') session = service.OpenSession('plain', EMPTY_STRING)[1] secret = dbus.Struct((session, '', dbus.ByteArray(value), 'plain/text; charset=utf8')) label = 'mopidy: %s/%s' % (section, key) attributes = {'service': 'mopidy', 'section': section, 'key': key} properties = {'org.freedesktop.Secret.Item.Label': label, 'org.freedesktop.Secret.Item.Attributes': attributes} try: 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) return False if prompt == '/': return True _prompt(bus, prompt).Dismiss() logger.debug('Saving secret %s/%s failed. (Keyring is locked)', section, key) return False def _service(bus): return _interface(bus, '/org/freedesktop/secrets', 'org.freedesktop.Secret.Service') # NOTE: depending on versions and setup 'default' might not exists, so try and # use it but fall back to the 'login' collection, and finally the 'session' one # if all else fails. We should probably create a keyring/collection setting # that allows users to set this so they have control over where their secrets # get stored. def _collection(bus): for name in 'aliases/default', 'collection/login', 'collection/session': path = '/org/freedesktop/secrets/' + name if _collection_exists(bus, path): break else: return None return _interface(bus, path, 'org.freedesktop.Secret.Collection') # NOTE: Hack to probe if a given collection actually exists. Needed to work # around an introspection bug in setting passwords for non-existant aliases. def _collection_exists(bus, path): try: item = _interface(bus, path, 'org.freedesktop.DBus.Properties') item.Get('org.freedesktop.Secret.Collection', 'Label') return True except dbus.exceptions.DBusException: return False # NOTE: We could call prompt.Prompt('') to unlock the keyring when it is not # '/', but we would then also have to arrange to setup signals to wait until # this has been completed. So for now we just dismiss the prompt and expect # keyrings to be unlocked. def _prompt(bus, path): return _interface(bus, path, 'Prompt') def _item_attributes(bus, path): item = _interface(bus, path, 'org.freedesktop.DBus.Properties') result = item.Get('org.freedesktop.Secret.Item', 'Attributes') return dict((bytes(k), bytes(v)) for k, v in result.items()) def _interface(bus, path, interface): obj = bus.get_object('org.freedesktop.secrets', path) return dbus.Interface(obj, interface) Mopidy-2.0.0/mopidy/models/0000775000175000017500000000000012660436443015754 5ustar jodaljodal00000000000000Mopidy-2.0.0/mopidy/models/immutable.py0000664000175000017500000001625212660436420020306 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import copy import itertools import weakref from mopidy.internal import deprecation from mopidy.models.fields import Field class ImmutableObject(object): """ Superclass for immutable objects whose fields can only be modified via the constructor. This version of this class has been retained to avoid breaking any clients relying on it's behavior. Internally in Mopidy we now use :class:`ValidatedImmutableObject` for type safety and it's much smaller memory footprint. :param kwargs: kwargs to set as fields on the object :type kwargs: any """ # Any sub-classes that don't set slots won't be effected by the base using # slots as they will still get an instance dict. __slots__ = ['__weakref__'] def __init__(self, *args, **kwargs): for key, value in kwargs.items(): if not self._is_valid_field(key): raise TypeError( '__init__() got an unexpected keyword argument "%s"' % key) self._set_field(key, value) def __setattr__(self, name, value): if name.startswith('_'): object.__setattr__(self, name, value) else: raise AttributeError('Object is immutable.') def __delattr__(self, name): if name.startswith('_'): object.__delattr__(self, name) else: raise AttributeError('Object is immutable.') def _is_valid_field(self, name): return hasattr(self, name) and not callable(getattr(self, name)) def _set_field(self, name, value): if value == getattr(self.__class__, name): self.__dict__.pop(name, None) else: self.__dict__[name] = value def _items(self): return self.__dict__.iteritems() def __repr__(self): kwarg_pairs = [] for key, value in sorted(self._items()): if isinstance(value, (frozenset, tuple)): if not value: continue value = list(value) kwarg_pairs.append('%s=%s' % (key, repr(value))) return '%(classname)s(%(kwargs)s)' % { 'classname': self.__class__.__name__, 'kwargs': ', '.join(kwarg_pairs), } def __hash__(self): hash_sum = 0 for key, value in self._items(): hash_sum += hash(key) + hash(value) return hash_sum def __eq__(self, other): if not isinstance(other, self.__class__): return False return all(a == b for a, b in itertools.izip_longest( self._items(), other._items(), fillvalue=object())) def __ne__(self, other): return not self.__eq__(other) def copy(self, **values): """ .. deprecated:: 1.1 Use :meth:`replace` instead. """ deprecation.warn('model.immutable.copy') return self.replace(**values) def replace(self, **kwargs): """ Replace the fields in the model and return a new instance Examples:: # Returns a track with a new name Track(name='foo').replace(name='bar') # Return an album with a new number of tracks Album(num_tracks=2).replace(num_tracks=5) :param kwargs: kwargs to set as fields on the object :type kwargs: any :rtype: instance of the model with replaced fields """ other = copy.copy(self) for key, value in kwargs.items(): if not self._is_valid_field(key): raise TypeError( 'replace() got an unexpected keyword argument "%s"' % key) other._set_field(key, value) return other def serialize(self): data = {} data['__model__'] = self.__class__.__name__ for key, value in self._items(): if isinstance(value, (set, frozenset, list, tuple)): value = [ v.serialize() if isinstance(v, ImmutableObject) else v for v in value] elif isinstance(value, ImmutableObject): value = value.serialize() if not (isinstance(value, list) and len(value) == 0): data[key] = value return data class _ValidatedImmutableObjectMeta(type): """Helper that initializes fields, slots and memoizes instance creation.""" def __new__(cls, name, bases, attrs): fields = {} for base in bases: # Copy parent fields over to our state fields.update(getattr(base, '_fields', {})) for key, value in attrs.items(): # Add our own fields if isinstance(value, Field): fields[key] = '_' + key value._name = key attrs['_fields'] = fields attrs['_instances'] = weakref.WeakValueDictionary() attrs['__slots__'] = list(attrs.get('__slots__', [])) + fields.values() return super(_ValidatedImmutableObjectMeta, cls).__new__( cls, name, bases, attrs) def __call__(cls, *args, **kwargs): # noqa: N805 instance = super(_ValidatedImmutableObjectMeta, cls).__call__( *args, **kwargs) return cls._instances.setdefault(weakref.ref(instance), instance) class ValidatedImmutableObject(ImmutableObject): """ Superclass for immutable objects whose fields can only be modified via the constructor. Fields should be :class:`Field` instances to ensure type safety in our models. Note that since these models can not be changed, we heavily memoize them to save memory. So constructing a class with the same arguments twice will give you the same instance twice. """ __metaclass__ = _ValidatedImmutableObjectMeta __slots__ = ['_hash'] def __hash__(self): if not hasattr(self, '_hash'): hash_sum = super(ValidatedImmutableObject, self).__hash__() object.__setattr__(self, '_hash', hash_sum) return self._hash def _is_valid_field(self, name): return name in self._fields def _set_field(self, name, value): object.__setattr__(self, name, value) def _items(self): for field, key in self._fields.items(): if hasattr(self, key): yield field, getattr(self, key) def replace(self, **kwargs): """ Replace the fields in the model and return a new instance Examples:: # Returns a track with a new name Track(name='foo').replace(name='bar') # Return an album with a new number of tracks Album(num_tracks=2).replace(num_tracks=5) Note that internally we memoize heavily to keep memory usage down given our overly repetitive data structures. So you might get an existing instance if it contains the same values. :param kwargs: kwargs to set as fields on the object :type kwargs: any :rtype: instance of the model with replaced fields """ if not kwargs: return self other = super(ValidatedImmutableObject, self).replace(**kwargs) if hasattr(self, '_hash'): object.__delattr__(other, '_hash') return self._instances.setdefault(weakref.ref(other), other) Mopidy-2.0.0/mopidy/models/__init__.py0000664000175000017500000002517512660436420020072 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mopidy import compat from mopidy.models import fields from mopidy.models.immutable import ImmutableObject, ValidatedImmutableObject from mopidy.models.serialize import ModelJSONEncoder, model_json_decoder __all__ = [ 'ImmutableObject', 'Ref', 'Image', 'Artist', 'Album', 'track', 'TlTrack', 'Playlist', 'SearchResult', 'model_json_decoder', 'ModelJSONEncoder', 'ValidatedImmutableObject'] class Ref(ValidatedImmutableObject): """ Model to represent URI references with a human friendly name and type attached. This is intended for use a lightweight object "free" of metadata that can be passed around instead of using full blown models. :param uri: object URI :type uri: string :param name: object name :type name: string :param type: object type :type type: string """ #: The object URI. Read-only. uri = fields.URI() #: The object name. Read-only. name = fields.String() #: The object type, e.g. "artist", "album", "track", "playlist", #: "directory". Read-only. type = fields.Identifier() # TODO: consider locking this down. # type = fields.Field(choices=(ALBUM, ARTIST, DIRECTORY, PLAYLIST, TRACK)) #: Constant used for comparison with the :attr:`type` field. ALBUM = 'album' #: Constant used for comparison with the :attr:`type` field. ARTIST = 'artist' #: Constant used for comparison with the :attr:`type` field. DIRECTORY = 'directory' #: Constant used for comparison with the :attr:`type` field. PLAYLIST = 'playlist' #: Constant used for comparison with the :attr:`type` field. TRACK = 'track' @classmethod def album(cls, **kwargs): """Create a :class:`Ref` with ``type`` :attr:`ALBUM`.""" kwargs['type'] = Ref.ALBUM return cls(**kwargs) @classmethod def artist(cls, **kwargs): """Create a :class:`Ref` with ``type`` :attr:`ARTIST`.""" kwargs['type'] = Ref.ARTIST return cls(**kwargs) @classmethod def directory(cls, **kwargs): """Create a :class:`Ref` with ``type`` :attr:`DIRECTORY`.""" kwargs['type'] = Ref.DIRECTORY return cls(**kwargs) @classmethod def playlist(cls, **kwargs): """Create a :class:`Ref` with ``type`` :attr:`PLAYLIST`.""" kwargs['type'] = Ref.PLAYLIST return cls(**kwargs) @classmethod def track(cls, **kwargs): """Create a :class:`Ref` with ``type`` :attr:`TRACK`.""" kwargs['type'] = Ref.TRACK return cls(**kwargs) class Image(ValidatedImmutableObject): """ :param string uri: URI of the image :param int width: Optional width of image or :class:`None` :param int height: Optional height of image or :class:`None` """ #: The image URI. Read-only. uri = fields.URI() #: Optional width of the image or :class:`None`. Read-only. width = fields.Integer(min=0) #: Optional height of the image or :class:`None`. Read-only. height = fields.Integer(min=0) class Artist(ValidatedImmutableObject): """ :param uri: artist URI :type uri: string :param name: artist name :type name: string :param sortname: artist name for sorting :type sortname: string :param musicbrainz_id: MusicBrainz ID :type musicbrainz_id: string """ #: The artist URI. Read-only. uri = fields.URI() #: The artist name. Read-only. name = fields.String() #: Artist name for better sorting, e.g. with articles stripped sortname = fields.String() #: The MusicBrainz ID of the artist. Read-only. musicbrainz_id = fields.Identifier() class Album(ValidatedImmutableObject): """ :param uri: album URI :type uri: string :param name: album name :type name: string :param artists: album artists :type artists: list of :class:`Artist` :param num_tracks: number of tracks in album :type num_tracks: integer or :class:`None` if unknown :param num_discs: number of discs in album :type num_discs: integer or :class:`None` if unknown :param date: album release date (YYYY or YYYY-MM-DD) :type date: string :param musicbrainz_id: MusicBrainz ID :type musicbrainz_id: string :param images: album image URIs :type images: list of strings .. deprecated:: 1.2 The ``images`` field is deprecated. Use :meth:`mopidy.core.LibraryController.get_images` instead. """ #: The album URI. Read-only. uri = fields.URI() #: The album name. Read-only. name = fields.String() #: A set of album artists. Read-only. artists = fields.Collection(type=Artist, container=frozenset) #: The number of tracks in the album. Read-only. num_tracks = fields.Integer(min=0) #: The number of discs in the album. Read-only. num_discs = fields.Integer(min=0) #: The album release date. Read-only. date = fields.Date() #: The MusicBrainz ID of the album. Read-only. musicbrainz_id = fields.Identifier() #: The album image URIs. Read-only. #: #: .. deprecated:: 1.2 #: Use :meth:`mopidy.core.LibraryController.get_images` instead. images = fields.Collection(type=compat.string_types, container=frozenset) class Track(ValidatedImmutableObject): """ :param uri: track URI :type uri: string :param name: track name :type name: string :param artists: track artists :type artists: list of :class:`Artist` :param album: track album :type album: :class:`Album` :param composers: track composers :type composers: string :param performers: track performers :type performers: string :param genre: track genre :type genre: string :param track_no: track number in album :type track_no: integer or :class:`None` if unknown :param disc_no: disc number in album :type disc_no: integer or :class:`None` if unknown :param date: track release date (YYYY or YYYY-MM-DD) :type date: string :param length: track length in milliseconds :type length: integer or :class:`None` if there is no duration :param bitrate: bitrate in kbit/s :type bitrate: integer :param comment: track comment :type comment: string :param musicbrainz_id: MusicBrainz ID :type musicbrainz_id: string :param last_modified: Represents last modification time :type last_modified: integer or :class:`None` if unknown """ #: The track URI. Read-only. uri = fields.URI() #: The track name. Read-only. name = fields.String() #: A set of track artists. Read-only. artists = fields.Collection(type=Artist, container=frozenset) #: The track :class:`Album`. Read-only. album = fields.Field(type=Album) #: A set of track composers. Read-only. composers = fields.Collection(type=Artist, container=frozenset) #: A set of track performers`. Read-only. performers = fields.Collection(type=Artist, container=frozenset) #: The track genre. Read-only. genre = fields.String() #: The track number in the album. Read-only. track_no = fields.Integer(min=0) #: The disc number in the album. Read-only. disc_no = fields.Integer(min=0) #: The track release date. Read-only. date = fields.Date() #: The track length in milliseconds. Read-only. length = fields.Integer(min=0) #: The track's bitrate in kbit/s. Read-only. bitrate = fields.Integer(min=0) #: The track comment. Read-only. comment = fields.String() #: The MusicBrainz ID of the track. Read-only. musicbrainz_id = fields.Identifier() #: Integer representing when the track was last modified. Exact meaning #: depends on source of track. For local files this is the modification #: time in milliseconds since Unix epoch. For other backends it could be an #: equivalent timestamp or simply a version counter. last_modified = fields.Integer(min=0) class TlTrack(ValidatedImmutableObject): """ A tracklist track. Wraps a regular track and it's tracklist ID. The use of :class:`TlTrack` allows the same track to appear multiple times in the tracklist. This class also accepts it's parameters as positional arguments. Both arguments must be provided, and they must appear in the order they are listed here. This class also supports iteration, so your extract its values like this:: (tlid, track) = tl_track :param tlid: tracklist ID :type tlid: int :param track: the track :type track: :class:`Track` """ #: The tracklist ID. Read-only. tlid = fields.Integer(min=0) #: The track. Read-only. track = fields.Field(type=Track) def __init__(self, *args, **kwargs): if len(args) == 2 and len(kwargs) == 0: kwargs['tlid'] = args[0] kwargs['track'] = args[1] args = [] super(TlTrack, self).__init__(*args, **kwargs) def __iter__(self): return iter([self.tlid, self.track]) class Playlist(ValidatedImmutableObject): """ :param uri: playlist URI :type uri: string :param name: playlist name :type name: string :param tracks: playlist's tracks :type tracks: list of :class:`Track` elements :param last_modified: playlist's modification time in milliseconds since Unix epoch :type last_modified: int """ #: The playlist URI. Read-only. uri = fields.URI() #: The playlist name. Read-only. name = fields.String() #: The playlist's tracks. Read-only. tracks = fields.Collection(type=Track, container=tuple) #: The playlist modification time in milliseconds since Unix epoch. #: Read-only. #: #: Integer, or :class:`None` if unknown. last_modified = fields.Integer(min=0) # TODO: def insert(self, pos, track): ... ? @property def length(self): """The number of tracks in the playlist. Read-only.""" return len(self.tracks) class SearchResult(ValidatedImmutableObject): """ :param uri: search result URI :type uri: string :param tracks: matching tracks :type tracks: list of :class:`Track` elements :param artists: matching artists :type artists: list of :class:`Artist` elements :param albums: matching albums :type albums: list of :class:`Album` elements """ #: The search result URI. Read-only. uri = fields.URI() #: The tracks matching the search query. Read-only. tracks = fields.Collection(type=Track, container=tuple) #: The artists matching the search query. Read-only. artists = fields.Collection(type=Artist, container=tuple) #: The albums matching the search query. Read-only. albums = fields.Collection(type=Album, container=tuple) Mopidy-2.0.0/mopidy/models/fields.py0000664000175000017500000001205612660436420017573 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mopidy import compat class Field(object): """ Base field for use in :class:`~mopidy.models.immutable.ValidatedImmutableObject`. These fields are responsible for type checking and other data sanitation in our models. For simplicity fields use the Python descriptor protocol to store the values in the instance dictionary. Also note that fields are mutable if the object they are attached to allow it. Default values will be validated with the exception of :class:`None`. :param default: default value for field :param type: if set the field value must be of this type :param choices: if set the field value must be one of these """ def __init__(self, default=None, type=None, choices=None): self._name = None # Set by ValidatedImmutableObjectMeta self._choices = choices self._default = default self._type = type if self._default is not None: self.validate(self._default) def validate(self, value): """Validate and possibly modify the field value before assignment""" if self._type and not isinstance(value, self._type): raise TypeError('Expected %s to be a %s, not %r' % (self._name, self._type, value)) if self._choices and value not in self._choices: raise TypeError('Expected %s to be a one of %s, not %r' % (self._name, self._choices, value)) return value def __get__(self, instance, owner): if not instance: return self return getattr(instance, '_' + self._name, self._default) def __set__(self, instance, value): if value is not None: value = self.validate(value) if value is None or value == self._default: self.__delete__(instance) else: setattr(instance, '_' + self._name, value) def __delete__(self, instance): if hasattr(instance, '_' + self._name): delattr(instance, '_' + self._name) class String(Field): """ Specialized :class:`Field` which is wired up for bytes and unicode. :param default: default value for field """ def __init__(self, default=None): # TODO: normalize to unicode? # TODO: only allow unicode? # TODO: disallow empty strings? super(String, self).__init__(type=compat.string_types, default=default) class Date(String): """ :class:`Field` for storing ISO 8601 dates as a string. Supported formats are ``YYYY-MM-DD``, ``YYYY-MM`` and ``YYYY``, currently not validated. :param default: default value for field """ pass # TODO: make this check for YYYY-MM-DD, YYYY-MM, YYYY using strptime. class Identifier(String): """ :class:`Field` for storing ASCII values such as GUIDs or other identifiers. Values will be interned. :param default: default value for field """ def validate(self, value): return compat.intern(str(super(Identifier, self).validate(value))) class URI(Identifier): """ :class:`Field` for storing URIs Values will be interned, currently not validated. :param default: default value for field """ pass # TODO: validate URIs? class Integer(Field): """ :class:`Field` for storing integer numbers. :param default: default value for field :param min: field value must be larger or equal to this value when set :param max: field value must be smaller or equal to this value when set """ def __init__(self, default=None, min=None, max=None): self._min = min self._max = max super(Integer, self).__init__( type=compat.integer_types, default=default) def validate(self, value): value = super(Integer, self).validate(value) if self._min is not None and value < self._min: raise ValueError('Expected %s to be at least %d, not %d' % (self._name, self._min, value)) if self._max is not None and value > self._max: raise ValueError('Expected %s to be at most %d, not %d' % (self._name, self._max, value)) return value class Collection(Field): """ :class:`Field` for storing collections of a given type. :param type: all items stored in the collection must be of this type :param container: the type to store the items in """ def __init__(self, type, container=tuple): super(Collection, self).__init__(type=type, default=container()) def validate(self, value): if isinstance(value, compat.string_types): raise TypeError('Expected %s to be a collection of %s, not %r' % (self._name, self._type.__name__, value)) for v in value: if not isinstance(v, self._type): raise TypeError('Expected %s to be a collection of %s, not %r' % (self._name, self._type.__name__, value)) return self._default.__class__(value) or None Mopidy-2.0.0/mopidy/models/serialize.py0000664000175000017500000000223612575004517020316 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import json from mopidy.models import immutable _MODELS = ['Ref', 'Artist', 'Album', 'Track', 'TlTrack', 'Playlist'] class ModelJSONEncoder(json.JSONEncoder): """ Automatically serialize Mopidy models to JSON. Usage:: >>> import json >>> json.dumps({'a_track': Track(name='name')}, cls=ModelJSONEncoder) '{"a_track": {"__model__": "Track", "name": "name"}}' """ def default(self, obj): if isinstance(obj, immutable.ImmutableObject): return obj.serialize() return json.JSONEncoder.default(self, obj) def model_json_decoder(dct): """ Automatically deserialize Mopidy models from JSON. Usage:: >>> import json >>> json.loads( ... '{"a_track": {"__model__": "Track", "name": "name"}}', ... object_hook=model_json_decoder) {u'a_track': Track(artists=[], name=u'name')} """ if '__model__' in dct: from mopidy import models model_name = dct.pop('__model__') if model_name in _MODELS: return getattr(models, model_name)(**dct) return dct Mopidy-2.0.0/mopidy/stream/0000775000175000017500000000000012660436443015764 5ustar jodaljodal00000000000000Mopidy-2.0.0/mopidy/stream/__init__.py0000664000175000017500000000155112505224626020073 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import os import mopidy from mopidy import config, ext class Extension(ext.Extension): dist_name = 'Mopidy-Stream' ext_name = 'stream' version = mopidy.__version__ def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') return config.read(conf_file) def get_config_schema(self): schema = super(Extension, self).get_config_schema() schema['protocols'] = config.List() schema['metadata_blacklist'] = config.List(optional=True) schema['timeout'] = config.Integer( minimum=1000, maximum=1000 * 60 * 60) return schema def validate_environment(self): pass def setup(self, registry): from .actor import StreamBackend registry.add('backend', StreamBackend) Mopidy-2.0.0/mopidy/stream/ext.conf0000664000175000017500000000017712575004517017436 0ustar jodaljodal00000000000000[stream] enabled = true protocols = http https mms rtmp rtmps rtsp timeout = 5000 metadata_blacklist = Mopidy-2.0.0/mopidy/stream/actor.py0000664000175000017500000001271612660436420017450 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import fnmatch import logging import re import time import pykka from mopidy import audio as audio_lib, backend, exceptions, stream from mopidy.audio import scan, tags from mopidy.compat import urllib from mopidy.internal import http, playlists from mopidy.models import Track logger = logging.getLogger(__name__) class StreamBackend(pykka.ThreadingActor, backend.Backend): def __init__(self, config, audio): super(StreamBackend, self).__init__() self._scanner = scan.Scanner( timeout=config['stream']['timeout'], proxy_config=config['proxy']) self._session = http.get_requests_session( proxy_config=config['proxy'], user_agent='%s/%s' % ( stream.Extension.dist_name, stream.Extension.version)) blacklist = config['stream']['metadata_blacklist'] self._blacklist_re = re.compile( r'^(%s)$' % '|'.join(fnmatch.translate(u) for u in blacklist)) self._timeout = config['stream']['timeout'] self.library = StreamLibraryProvider(backend=self) self.playback = StreamPlaybackProvider(audio=audio, backend=self) self.playlists = None self.uri_schemes = audio_lib.supported_uri_schemes( config['stream']['protocols']) if 'file' in self.uri_schemes and config['file']['enabled']: logger.warning( 'The stream/protocols config value includes the "file" ' 'protocol. "file" playback is now handled by Mopidy-File. ' 'Please remove it from the stream/protocols config.') self.uri_schemes -= {'file'} class StreamLibraryProvider(backend.LibraryProvider): def lookup(self, uri): if urllib.parse.urlsplit(uri).scheme not in self.backend.uri_schemes: return [] if self.backend._blacklist_re.match(uri): logger.debug('URI matched metadata lookup blacklist: %s', uri) return [Track(uri=uri)] _, scan_result = _unwrap_stream( uri, timeout=self.backend._timeout, scanner=self.backend._scanner, requests_session=self.backend._session) if scan_result: track = tags.convert_tags_to_track(scan_result.tags).replace( uri=uri, length=scan_result.duration) else: logger.warning('Problem looking up %s: %s', uri) track = Track(uri=uri) return [track] class StreamPlaybackProvider(backend.PlaybackProvider): def translate_uri(self, uri): if urllib.parse.urlsplit(uri).scheme not in self.backend.uri_schemes: return None if self.backend._blacklist_re.match(uri): logger.debug('URI matched metadata lookup blacklist: %s', uri) return uri unwrapped_uri, _ = _unwrap_stream( uri, timeout=self.backend._timeout, scanner=self.backend._scanner, requests_session=self.backend._session) return unwrapped_uri # TODO: cleanup the return value of this. def _unwrap_stream(uri, timeout, scanner, requests_session): """ Get a stream URI from a playlist URI, ``uri``. Unwraps nested playlists until something that's not a playlist is found or the ``timeout`` is reached. """ original_uri = uri seen_uris = set() deadline = time.time() + timeout while time.time() < deadline: if uri in seen_uris: logger.info( 'Unwrapping stream from URI (%s) failed: ' 'playlist referenced itself', uri) return None, None else: seen_uris.add(uri) logger.debug('Unwrapping stream from URI: %s', uri) try: scan_timeout = deadline - time.time() if scan_timeout < 0: logger.info( 'Unwrapping stream from URI (%s) failed: ' 'timed out in %sms', uri, timeout) return None, None scan_result = scanner.scan(uri, timeout=scan_timeout) except exceptions.ScannerError as exc: logger.debug('GStreamer failed scanning URI (%s): %s', uri, exc) scan_result = None if scan_result is not None: if scan_result.playable or ( not scan_result.mime.startswith('text/') and not scan_result.mime.startswith('application/') ): logger.debug( 'Unwrapped potential %s stream: %s', scan_result.mime, uri) return uri, scan_result download_timeout = deadline - time.time() if download_timeout < 0: logger.info( 'Unwrapping stream from URI (%s) failed: timed out in %sms', uri, timeout) return None, None content = http.download( requests_session, uri, timeout=download_timeout) if content is None: logger.info( 'Unwrapping stream from URI (%s) failed: ' 'error downloading URI %s', original_uri, uri) return None, None uris = playlists.parse(content) if not uris: logger.debug( 'Failed parsing URI (%s) as playlist; found potential stream.', uri) return uri, None # TODO Test streams and return first that seems to be playable logger.debug( 'Parsed playlist (%s) and found new URI: %s', uri, uris[0]) uri = uris[0] Mopidy-2.0.0/mopidy/__init__.py0000664000175000017500000000054512660436420016601 0ustar jodaljodal00000000000000from __future__ import absolute_import, print_function, unicode_literals import platform import sys import warnings if not (2, 7) <= sys.version_info < (3,): sys.exit( 'ERROR: Mopidy requires Python 2.7, but found %s.' % platform.python_version()) warnings.filterwarnings('ignore', 'could not open display') __version__ = '2.0.0' Mopidy-2.0.0/mopidy/local/0000775000175000017500000000000012660436443015563 5ustar jodaljodal00000000000000Mopidy-2.0.0/mopidy/local/playback.py0000664000175000017500000000050212575004517017716 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mopidy import backend from mopidy.local import translator class LocalPlaybackProvider(backend.PlaybackProvider): def translate_uri(self, uri): return translator.local_uri_to_file_uri( uri, self.backend.config['local']['media_dir']) Mopidy-2.0.0/mopidy/local/translator.py0000664000175000017500000000311412660436420020320 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging import os import urllib from mopidy import compat from mopidy.internal import path logger = logging.getLogger(__name__) def local_uri_to_file_uri(uri, media_dir): """Convert local track or directory URI to file URI.""" return path_to_file_uri(local_uri_to_path(uri, media_dir)) def local_uri_to_path(uri, media_dir): """Convert local track or directory URI to absolute path.""" if ( not uri.startswith('local:directory:') and not uri.startswith('local:track:')): raise ValueError('Invalid URI.') file_path = path.uri_to_path(uri).split(b':', 1)[1] return os.path.join(media_dir, file_path) def local_track_uri_to_path(uri, media_dir): # Deprecated version to keep old versions of Mopidy-Local-Sqlite working. return local_uri_to_path(uri, media_dir) def path_to_file_uri(abspath): """Convert absolute path to file URI.""" # Re-export internal method for use by Mopidy-Local-* extensions. return path.path_to_uri(abspath) def path_to_local_track_uri(relpath): """Convert path relative to :confval:`local/media_dir` to local track URI.""" if isinstance(relpath, compat.text_type): relpath = relpath.encode('utf-8') return 'local:track:%s' % urllib.quote(relpath) def path_to_local_directory_uri(relpath): """Convert path relative to :confval:`local/media_dir` directory URI.""" if isinstance(relpath, compat.text_type): relpath = relpath.encode('utf-8') return 'local:directory:%s' % urllib.quote(relpath) Mopidy-2.0.0/mopidy/local/storage.py0000664000175000017500000000051212575504731017600 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging import os logger = logging.getLogger(__name__) def check_dirs_and_files(config): if not os.path.isdir(config['local']['media_dir']): logger.warning( 'Local media dir %s does not exist.' % config['local']['media_dir']) Mopidy-2.0.0/mopidy/local/__init__.py0000664000175000017500000001665312660436420017702 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging import os import mopidy from mopidy import config, ext, models logger = logging.getLogger(__name__) class Extension(ext.Extension): dist_name = 'Mopidy-Local' ext_name = 'local' version = mopidy.__version__ def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') return config.read(conf_file) def get_config_schema(self): schema = super(Extension, self).get_config_schema() schema['library'] = config.String() schema['media_dir'] = config.Path() schema['data_dir'] = config.Deprecated() schema['playlists_dir'] = config.Deprecated() schema['tag_cache_file'] = config.Deprecated() schema['scan_timeout'] = config.Integer( minimum=1000, maximum=1000 * 60 * 60) schema['scan_flush_threshold'] = config.Integer(minimum=0) schema['scan_follow_symlinks'] = config.Boolean() schema['excluded_file_extensions'] = config.List(optional=True) return schema def setup(self, registry): from .actor import LocalBackend from .json import JsonLibrary LocalBackend.libraries = registry['local:library'] registry.add('backend', LocalBackend) registry.add('local:library', JsonLibrary) def get_command(self): from .commands import LocalCommand return LocalCommand() class Library(object): """ Local library interface. Extensions that wish to provide an alternate local library storage backend need to sub-class this class and install and configure it with an extension. Both scanning and library calls will use the active local library. :param config: Config dictionary """ ROOT_DIRECTORY_URI = 'local:directory' """ URI of the local backend's root directory. This constant should be used by libraries implementing the :meth:`Library.browse` method. """ #: Name of the local library implementation, must be overriden. name = None #: Feature marker to indicate that you want :meth:`add()` calls to be #: called with optional arguments tags and duration. add_supports_tags_and_duration = False def __init__(self, config): self._config = config def browse(self, uri): """ Browse directories and tracks at the given URI. The URI for the root directory is a constant available at :attr:`Library.ROOT_DIRECTORY_URI`. :param string path: URI to browse. :rtype: List of :class:`~mopidy.models.Ref` tracks and directories. """ raise NotImplementedError def get_distinct(self, field, query=None): """ List distinct values for a given field from the library. :param string field: One of ``artist``, ``albumartist``, ``album``, ``composer``, ``performer``, ``date``or ``genre``. :param dict query: Query to use for limiting results, see :meth:`search` for details about the query format. :rtype: set of values corresponding to the requested field type. """ return set() def get_images(self, uris): """ Lookup the images for the given URIs. The default implementation will simply call :meth:`lookup` and try and use the album art for any tracks returned. Most local libraries should replace this with something smarter or simply return an empty dictionary. :param list uris: list of URIs to find images for :rtype: {uri: tuple of :class:`mopidy.models.Image`} """ result = {} for uri in uris: image_uris = set() tracks = self.lookup(uri) # local libraries may return single track if isinstance(tracks, models.Track): tracks = [tracks] for track in tracks: if track.album and track.album.images: image_uris.update(track.album.images) result[uri] = [models.Image(uri=u) for u in image_uris] return result def load(self): """ (Re)load any tracks stored in memory, if any, otherwise just return number of available tracks currently available. Will be called at startup for both library and update use cases, so if you plan to store tracks in memory this is when the should be (re)loaded. :rtype: :class:`int` representing number of tracks in library. """ return 0 def lookup(self, uri): """ Lookup the given URI. :param string uri: track URI :rtype: list of :class:`~mopidy.models.Track` (or single :class:`~mopidy.models.Track` for backward compatibility) """ raise NotImplementedError # TODO: remove uris, replacing it with support in query language. # TODO: remove exact, replacing it with support in query language. def search(self, query=None, limit=100, offset=0, exact=False, uris=None): """ Search the library for tracks where ``field`` contains ``values``. :param dict query: one or more queries to search for :param int limit: maximum number of results to return :param int offset: offset into result set to use. :param bool exact: whether to look for exact matches :param uris: zero or more URI roots to limit the search to :type uris: list of strings or :class:`None` :rtype: :class:`~mopidy.models.SearchResult` """ raise NotImplementedError # TODO: add file browsing support. # Remaining methods are use for the update process. def begin(self): """ Prepare library for accepting updates. Exactly what this means is highly implementation depended. This must however return an iterator that generates all tracks in the library for efficient scanning. :rtype: :class:`~mopidy.models.Track` iterator """ raise NotImplementedError def add(self, track, tags=None, duration=None): """ Add the given track to library. Optional args will only be added if :attr:`add_supports_tags_and_duration` has been set. :param track: Track to add to the library :type track: :class:`~mopidy.models.Track` :param tags: All the tags the scanner found for the media. See :mod:`mopidy.audio.utils` for details about the tags. :type tags: dictionary of tag keys with a list of values. :param duration: Duration of media in milliseconds or :class:`None` if unknown :type duration: :class:`int` or :class:`None` """ raise NotImplementedError def remove(self, uri): """ Remove the given track from the library. :param str uri: URI to remove from the library/ """ raise NotImplementedError def flush(self): """ Called for every n-th track indicating that work should be committed. Sub-classes are free to ignore these hints. :rtype: Boolean indicating if state was flushed. """ return False def close(self): """ Close any resources used for updating, commit outstanding work etc. """ pass def clear(self): """ Clear out whatever data storage is used by this backend. :rtype: Boolean indicating if state was cleared. """ return False Mopidy-2.0.0/mopidy/local/commands.py0000664000175000017500000001546012660436420017737 0ustar jodaljodal00000000000000from __future__ import ( absolute_import, division, print_function, unicode_literals) import logging import os import time from mopidy import commands, compat, exceptions from mopidy.audio import scan, tags from mopidy.internal import path from mopidy.local import translator 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']) library_name = config['local']['library'] if library_name not in libraries: logger.error('Local library %s not found', library_name) return None logger.debug('Using %s as the local library', library_name) return libraries[library_name](config) class LocalCommand(commands.Command): def __init__(self): super(LocalCommand, self).__init__() self.add_child('scan', ScanCommand()) self.add_child('clear', ClearCommand()) class ClearCommand(commands.Command): help = 'Clear local media files from the local library.' def run(self, args, config): library = _get_library(args, config) if library is None: return 1 prompt = '\nAre you sure you want to clear the library? [y/N] ' if compat.input(prompt).lower() != 'y': print('Clearing library aborted.') return 0 if library.clear(): print('Library successfully cleared.') return 0 print('Unable to clear library.') return 1 class ScanCommand(commands.Command): help = 'Scan local media files and populate the local library.' def __init__(self): super(ScanCommand, self).__init__() self.add_argument('--limit', action='store', type=int, dest='limit', default=None, help='Maximum number of tracks to scan') self.add_argument('--force', action='store_true', dest='force', default=False, help='Force rescan of all media files') def run(self, args, config): media_dir = config['local']['media_dir'] scan_timeout = config['local']['scan_timeout'] flush_threshold = config['local']['scan_flush_threshold'] excluded_file_extensions = config['local']['excluded_file_extensions'] excluded_file_extensions = tuple( bytes(file_ext.lower()) for file_ext in excluded_file_extensions) library = _get_library(args, config) if library is None: return 1 file_mtimes, file_errors = path.find_mtimes( media_dir, follow=config['local']['scan_follow_symlinks']) logger.info('Found %d files in media_dir.', len(file_mtimes)) if file_errors: logger.warning('Encountered %d errors while scanning media_dir.', len(file_errors)) for name in file_errors: logger.debug('Scan error %r for %r', file_errors[name], name) 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.get(abspath) if mtime is None: logger.debug('Missing file %s', track.uri) uris_to_remove.add(track.uri) elif mtime > track.last_modified or args.force: 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: library.remove(uri) for abspath in file_mtimes: relpath = os.path.relpath(abspath, media_dir) uri = translator.path_to_local_track_uri(relpath) 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) elif uri not in uris_in_library: uris_to_update.add(uri) logger.info( 'Found %d tracks which need to be updated.', len(uris_to_update)) logger.info('Scanning...') uris_to_update = sorted(uris_to_update, key=lambda v: v.lower()) uris_to_update = uris_to_update[:args.limit] scanner = scan.Scanner(scan_timeout) progress = _Progress(flush_threshold, len(uris_to_update)) for uri in uris_to_update: try: relpath = translator.local_track_uri_to_path(uri, media_dir) file_uri = path.path_to_uri(os.path.join(media_dir, relpath)) result = scanner.scan(file_uri) if not result.playable: logger.warning('Failed %s: No audio found in file.', uri) elif result.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 = tags.convert_tags_to_track(result.tags).replace( uri=uri, length=result.duration, last_modified=mtime) if library.add_supports_tags_and_duration: library.add( track, tags=result.tags, duration=result.duration) else: library.add(track) logger.debug('Added %s', track.uri) except exceptions.ScannerError as error: logger.warning('Failed %s: %s', uri, error) if progress.increment(): progress.log() if library.flush(): logger.debug('Progress flushed.') progress.log() library.close() logger.info('Done scanning.') return 0 class _Progress(object): def __init__(self, batch_size, total): self.count = 0 self.batch_size = batch_size self.total = total self.start = time.time() def increment(self): self.count += 1 return self.batch_size and self.count % self.batch_size == 0 def log(self): duration = time.time() - self.start if self.count >= self.total or not self.count: logger.info('Scanned %d of %d files in %ds.', self.count, self.total, duration) else: remainder = duration / self.count * (self.total - self.count) logger.info('Scanned %d of %d files in %ds, ~%ds left.', self.count, self.total, duration, remainder) Mopidy-2.0.0/mopidy/local/search.py0000664000175000017500000002062612575004517017406 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mopidy.models import SearchResult def find_exact(tracks, query=None, limit=100, offset=0, uris=None): """ Filter a list of tracks where ``field`` is ``values``. :param list tracks: a list of :class:`~mopidy.models.Track` :param dict query: one or more field/value pairs to search for :param int limit: maximum number of results to return :param int offset: offset into result set to use. :param uris: zero or more URI roots to limit the search to :type uris: list of strings or :class:`None` :rtype: :class:`~mopidy.models.SearchResult` """ # TODO Only return results within URI roots given by ``uris`` if query is None: query = {} _validate_query(query) for (field, values) in query.items(): # FIXME this is bound to be slow for large libraries for value in values: if field == 'track_no': q = _convert_to_int(value) else: q = value.strip() def uri_filter(t): return q == t.uri def track_name_filter(t): return q == t.name def album_filter(t): return q == getattr(getattr(t, 'album', None), 'name', None) def artist_filter(t): return filter(lambda a: q == a.name, t.artists) def albumartist_filter(t): return any([ q == a.name for a in getattr(t.album, 'artists', [])]) def composer_filter(t): return any([q == a.name for a in getattr(t, 'composers', [])]) def performer_filter(t): return any([q == a.name for a in getattr(t, 'performers', [])]) def track_no_filter(t): return q == t.track_no def genre_filter(t): return (t.genre and q == t.genre) def date_filter(t): return q == t.date def comment_filter(t): return q == t.comment def any_filter(t): return (uri_filter(t) or track_name_filter(t) or album_filter(t) or artist_filter(t) or albumartist_filter(t) or composer_filter(t) or performer_filter(t) or track_no_filter(t) or genre_filter(t) or date_filter(t) or comment_filter(t)) if field == 'uri': tracks = filter(uri_filter, tracks) elif field == 'track_name': tracks = filter(track_name_filter, tracks) elif field == 'album': tracks = filter(album_filter, tracks) elif field == 'artist': tracks = filter(artist_filter, tracks) elif field == 'albumartist': tracks = filter(albumartist_filter, tracks) elif field == 'composer': tracks = filter(composer_filter, tracks) elif field == 'performer': tracks = filter(performer_filter, tracks) elif field == 'track_no': tracks = filter(track_no_filter, tracks) elif field == 'genre': tracks = filter(genre_filter, tracks) elif field == 'date': tracks = filter(date_filter, tracks) elif field == 'comment': tracks = filter(comment_filter, tracks) elif field == 'any': tracks = filter(any_filter, tracks) else: raise LookupError('Invalid lookup field: %s' % field) if limit is None: tracks = tracks[offset:] else: tracks = tracks[offset:offset + limit] # TODO: add local:search: return SearchResult(uri='local:search', tracks=tracks) def search(tracks, query=None, limit=100, offset=0, uris=None): """ Filter a list of tracks where ``field`` is like ``values``. :param list tracks: a list of :class:`~mopidy.models.Track` :param dict query: one or more field/value pairs to search for :param int limit: maximum number of results to return :param int offset: offset into result set to use. :param uris: zero or more URI roots to limit the search to :type uris: list of strings or :class:`None` :rtype: :class:`~mopidy.models.SearchResult` """ # TODO Only return results within URI roots given by ``uris`` if query is None: query = {} _validate_query(query) for (field, values) in query.items(): # FIXME this is bound to be slow for large libraries for value in values: if field == 'track_no': q = _convert_to_int(value) else: q = value.strip().lower() def uri_filter(t): return bool(t.uri and q in t.uri.lower()) def track_name_filter(t): return bool(t.name and q in t.name.lower()) def album_filter(t): return bool(t.album and t.album.name and q in t.album.name.lower()) def artist_filter(t): return bool(filter( lambda a: bool(a.name and q in a.name.lower()), t.artists)) def albumartist_filter(t): return any([a.name and q in a.name.lower() for a in getattr(t.album, 'artists', [])]) def composer_filter(t): return any([a.name and q in a.name.lower() for a in getattr(t, 'composers', [])]) def performer_filter(t): return any([a.name and q in a.name.lower() for a in getattr(t, 'performers', [])]) def track_no_filter(t): return q == t.track_no def genre_filter(t): return bool(t.genre and q in t.genre.lower()) def date_filter(t): return bool(t.date and t.date.startswith(q)) def comment_filter(t): return bool(t.comment and q in t.comment.lower()) def any_filter(t): return (uri_filter(t) or track_name_filter(t) or album_filter(t) or artist_filter(t) or albumartist_filter(t) or composer_filter(t) or performer_filter(t) or track_no_filter(t) or genre_filter(t) or date_filter(t) or comment_filter(t)) if field == 'uri': tracks = filter(uri_filter, tracks) elif field == 'track_name': tracks = filter(track_name_filter, tracks) elif field == 'album': tracks = filter(album_filter, tracks) elif field == 'artist': tracks = filter(artist_filter, tracks) elif field == 'albumartist': tracks = filter(albumartist_filter, tracks) elif field == 'composer': tracks = filter(composer_filter, tracks) elif field == 'performer': tracks = filter(performer_filter, tracks) elif field == 'track_no': tracks = filter(track_no_filter, tracks) elif field == 'genre': tracks = filter(genre_filter, tracks) elif field == 'date': tracks = filter(date_filter, tracks) elif field == 'comment': tracks = filter(comment_filter, tracks) elif field == 'any': tracks = filter(any_filter, tracks) else: raise LookupError('Invalid lookup field: %s' % field) if limit is None: tracks = tracks[offset:] else: tracks = tracks[offset:offset + limit] # TODO: add local:search: return SearchResult(uri='local:search', tracks=tracks) def _validate_query(query): for (_, values) in query.items(): if not values: raise LookupError('Missing query') for value in values: if not value: raise LookupError('Missing query') def _convert_to_int(string): try: return int(string) except ValueError: return object() Mopidy-2.0.0/mopidy/local/json.py0000664000175000017500000001551712614502604017107 0ustar jodaljodal00000000000000from __future__ import absolute_import, absolute_import, unicode_literals import collections import gzip import json import logging import os import re import sys import tempfile import mopidy from mopidy import compat, local, models from mopidy.internal import encoding, timer from mopidy.local import search, storage, translator logger = logging.getLogger(__name__) # TODO: move to load and dump in models? def load_library(json_file): if not os.path.isfile(json_file): logger.info( 'No local library metadata cache found at %s. Please run ' '`mopidy local scan` to index your local music library. ' 'If you do not have a local music collection, you can disable the ' 'local backend to hide this message.', json_file) return {} try: with gzip.open(json_file, 'rb') as fp: return json.load(fp, object_hook=models.model_json_decoder) except (IOError, ValueError) as error: logger.warning( 'Loading JSON local library failed: %s', encoding.locale_decode(error)) return {} def write_library(json_file, data): data['version'] = mopidy.__version__ directory, basename = os.path.split(json_file) # TODO: cleanup directory/basename.* files. tmp = tempfile.NamedTemporaryFile( prefix=basename + '.', dir=directory, delete=False) try: with gzip.GzipFile(fileobj=tmp, mode='wb') as fp: json.dump(data, fp, cls=models.ModelJSONEncoder, indent=2, separators=(',', ': ')) os.rename(tmp.name, json_file) finally: if os.path.exists(tmp.name): os.remove(tmp.name) class _BrowseCache(object): encoding = sys.getfilesystemencoding() splitpath_re = re.compile(r'([^/]+)') def __init__(self, uris): self._cache = { local.Library.ROOT_DIRECTORY_URI: collections.OrderedDict()} for track_uri in uris: path = translator.local_track_uri_to_path(track_uri, b'/') parts = self.splitpath_re.findall( path.decode(self.encoding, 'replace')) track_ref = models.Ref.track(uri=track_uri, name=parts.pop()) # Look for our parents backwards as this is faster than having to # do a complete search for each add. parent_uri = None child = None for i in reversed(range(len(parts))): directory = '/'.join(parts[:i + 1]) uri = translator.path_to_local_directory_uri(directory) # First dir we process is our parent if not parent_uri: parent_uri = uri # We found ourselves and we exist, done. if uri in self._cache: if child: self._cache[uri][child.uri] = child break # Initialize ourselves, store child if present, and add # ourselves as child for next loop. self._cache[uri] = collections.OrderedDict() if child: self._cache[uri][child.uri] = child child = models.Ref.directory(uri=uri, name=parts[i]) else: # Loop completed, so final child needs to be added to root. if child: self._cache[ local.Library.ROOT_DIRECTORY_URI][child.uri] = child # If no parent was set we belong in the root. if not parent_uri: parent_uri = local.Library.ROOT_DIRECTORY_URI self._cache[parent_uri][track_uri] = track_ref def lookup(self, uri): return self._cache.get(uri, {}).values() class JsonLibrary(local.Library): name = 'json' def __init__(self, config): self._tracks = {} self._browse_cache = None self._media_dir = config['local']['media_dir'] self._json_file = os.path.join( local.Extension.get_data_dir(config), b'library.json.gz') storage.check_dirs_and_files(config) def browse(self, uri): if not self._browse_cache: return [] return self._browse_cache.lookup(uri) def load(self): logger.debug('Loading library: %s', self._json_file) with timer.time_logger('Loading tracks'): library = load_library(self._json_file) self._tracks = dict((t.uri, t) for t in library.get('tracks', [])) with timer.time_logger('Building browse cache'): self._browse_cache = _BrowseCache(sorted(self._tracks.keys())) return len(self._tracks) def lookup(self, uri): try: return [self._tracks[uri]] except KeyError: return [] def get_distinct(self, field, query=None): if field == 'track': def distinct(track): return {track.name} elif field == 'artist': def distinct(track): return {a.name for a in track.artists} elif field == 'albumartist': def distinct(track): album = track.album or models.Album() return {a.name for a in album.artists} elif field == 'album': def distinct(track): album = track.album or models.Album() return {album.name} elif field == 'composer': def distinct(track): return {a.name for a in track.composers} elif field == 'performer': def distinct(track): return {a.name for a in track.performers} elif field == 'date': def distinct(track): return {track.date} elif field == 'genre': def distinct(track): return {track.genre} else: return set() distinct_result = set() search_result = search.search(self._tracks.values(), query, limit=None) for track in search_result.tracks: distinct_result.update(distinct(track)) return distinct_result - {None} def search(self, query=None, limit=100, offset=0, uris=None, exact=False): tracks = self._tracks.values() if exact: return search.find_exact( tracks, query=query, limit=limit, offset=offset, uris=uris) else: return search.search( tracks, query=query, limit=limit, offset=offset, uris=uris) def begin(self): return compat.itervalues(self._tracks) def add(self, track): self._tracks[track.uri] = track def remove(self, uri): self._tracks.pop(uri, None) def close(self): write_library(self._json_file, {'tracks': self._tracks.values()}) def clear(self): try: os.remove(self._json_file) return True except OSError: return False Mopidy-2.0.0/mopidy/local/library.py0000664000175000017500000000325012575004517017577 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging from mopidy import backend, local, models logger = logging.getLogger(__name__) class LocalLibraryProvider(backend.LibraryProvider): """Proxy library that delegates work to our active local library.""" root_directory = models.Ref.directory( uri=local.Library.ROOT_DIRECTORY_URI, name='Local media') def __init__(self, backend, library): super(LocalLibraryProvider, self).__init__(backend) self._library = library self.refresh() def browse(self, uri): if not self._library: return [] return self._library.browse(uri) def get_distinct(self, field, query=None): if not self._library: return set() return self._library.get_distinct(field, query) def get_images(self, uris): if not self._library: return {} return self._library.get_images(uris) def refresh(self, uri=None): if not self._library: return 0 num_tracks = self._library.load() logger.info('Loaded %d local tracks using %s', num_tracks, self._library.name) def lookup(self, uri): if not self._library: return [] tracks = self._library.lookup(uri) if tracks is None: logger.debug('Failed to lookup %r', uri) return [] if isinstance(tracks, models.Track): tracks = [tracks] return tracks def search(self, query=None, uris=None, exact=False): if not self._library: return None return self._library.search(query=query, uris=uris, exact=exact) Mopidy-2.0.0/mopidy/local/ext.conf0000664000175000017500000000035012660436420017223 0ustar jodaljodal00000000000000[local] enabled = true library = json media_dir = $XDG_MUSIC_DIR scan_timeout = 1000 scan_flush_threshold = 100 scan_follow_symlinks = false excluded_file_extensions = .directory .html .jpeg .jpg .log .nfo .png .txt Mopidy-2.0.0/mopidy/local/actor.py0000664000175000017500000000212112505224626017235 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging import pykka from mopidy import backend from mopidy.local import storage from mopidy.local.library import LocalLibraryProvider from mopidy.local.playback import LocalPlaybackProvider logger = logging.getLogger(__name__) class LocalBackend(pykka.ThreadingActor, backend.Backend): uri_schemes = ['local'] libraries = [] def __init__(self, config, audio): super(LocalBackend, self).__init__() self.config = config storage.check_dirs_and_files(config) libraries = dict((l.name, l) for l in self.libraries) library_name = config['local']['library'] if library_name in libraries: library = libraries[library_name](config) logger.debug('Using %s as the local library', library_name) else: library = None logger.warning('Local library %s not found', library_name) self.playback = LocalPlaybackProvider(audio=audio, backend=self) self.library = LocalLibraryProvider(backend=self, library=library) Mopidy-2.0.0/mopidy/commands.py0000664000175000017500000003626312660436420016651 0ustar jodaljodal00000000000000from __future__ import absolute_import, print_function, unicode_literals import argparse import collections import contextlib import logging import os import signal import sys import pykka from mopidy import config as config_lib, exceptions from mopidy.audio import Audio from mopidy.core import Core from mopidy.internal import deps, process, timer, versioning from mopidy.internal.gi import GLib logger = logging.getLogger(__name__) _default_config = [] for base in GLib.get_system_config_dirs() + [GLib.get_user_config_dir()]: _default_config.append(os.path.join(base, b'mopidy', b'mopidy.conf')) DEFAULT_CONFIG = b':'.join(_default_config) def config_files_type(value): return value.split(b':') def config_override_type(value): try: section, remainder = value.split(b'/', 1) key, value = remainder.split(b'=', 1) return (section.strip(), key.strip(), value.strip()) except ValueError: raise argparse.ArgumentTypeError( '%s must have the format section/key=value' % value) class _ParserError(Exception): def __init__(self, message): self.message = message class _HelpError(Exception): pass class _ArgumentParser(argparse.ArgumentParser): def error(self, message): raise _ParserError(message) class _HelpAction(argparse.Action): def __init__(self, option_strings, dest=None, help=None): super(_HelpAction, self).__init__( option_strings=option_strings, dest=dest or argparse.SUPPRESS, default=argparse.SUPPRESS, nargs=0, help=help) def __call__(self, parser, namespace, values, option_string=None): raise _HelpError() class Command(object): """Command parser and runner for building trees of commands. This class provides a wraper around :class:`argparse.ArgumentParser` for handling this type of command line application in a better way than argprases own sub-parser handling. """ help = None #: Help text to display in help output. def __init__(self): self._children = collections.OrderedDict() self._arguments = [] self._overrides = {} def _build(self): actions = [] parser = _ArgumentParser(add_help=False) parser.register('action', 'help', _HelpAction) for args, kwargs in self._arguments: actions.append(parser.add_argument(*args, **kwargs)) parser.add_argument('_args', nargs=argparse.REMAINDER, help=argparse.SUPPRESS) return parser, actions def add_child(self, name, command): """Add a child parser to consider using. :param name: name to use for the sub-command that is being added. :type name: string """ self._children[name] = command def add_argument(self, *args, **kwargs): """Add an argument to the parser. This method takes all the same arguments as the :class:`argparse.ArgumentParser` version of this method. """ self._arguments.append((args, kwargs)) def set(self, **kwargs): """Override a value in the finaly result of parsing.""" self._overrides.update(kwargs) def exit(self, status_code=0, message=None, usage=None): """Optionally print a message and exit.""" print('\n\n'.join(m for m in (usage, message) if m)) sys.exit(status_code) def format_usage(self, prog=None): """Format usage for current parser.""" actions = self._build()[1] prog = prog or os.path.basename(sys.argv[0]) return self._usage(actions, prog) + '\n' def _usage(self, actions, prog): formatter = argparse.HelpFormatter(prog) formatter.add_usage(None, actions, []) return formatter.format_help().strip() def format_help(self, prog=None): """Format help for current parser and children.""" actions = self._build()[1] prog = prog or os.path.basename(sys.argv[0]) formatter = argparse.HelpFormatter(prog) formatter.add_usage(None, actions, []) if self.help: formatter.add_text(self.help) if actions: formatter.add_text('OPTIONS:') formatter.start_section(None) formatter.add_arguments(actions) formatter.end_section() subhelp = [] for name, child in self._children.items(): child._subhelp(name, subhelp) if subhelp: formatter.add_text('COMMANDS:') subhelp.insert(0, '') return formatter.format_help() + '\n'.join(subhelp) def _subhelp(self, name, result): actions = self._build()[1] if self.help or actions: formatter = argparse.HelpFormatter(name) formatter.add_usage(None, actions, [], '') formatter.start_section(None) formatter.add_text(self.help) formatter.start_section(None) formatter.add_arguments(actions) formatter.end_section() formatter.end_section() result.append(formatter.format_help()) for childname, child in self._children.items(): child._subhelp(' '.join((name, childname)), result) def parse(self, args, prog=None): """Parse command line arguments. Will recursively parse commands until a final parser is found or an error occurs. In the case of errors we will print a message and exit. Otherwise, any overrides are applied and the current parser stored in the command attribute of the return value. :param args: list of arguments to parse :type args: list of strings :param prog: name to use for program :type prog: string :rtype: :class:`argparse.Namespace` """ prog = prog or os.path.basename(sys.argv[0]) try: return self._parse( args, argparse.Namespace(), self._overrides.copy(), prog) except _HelpError: self.exit(0, self.format_help(prog)) def _parse(self, args, namespace, overrides, prog): overrides.update(self._overrides) parser, actions = self._build() try: result = parser.parse_args(args, namespace) except _ParserError as e: self.exit(1, e.message, self._usage(actions, prog)) if not result._args: for attr, value in overrides.items(): setattr(result, attr, value) delattr(result, '_args') result.command = self return result child = result._args.pop(0) if child not in self._children: usage = self._usage(actions, prog) self.exit(1, 'unrecognized command: %s' % child, usage) return self._children[child]._parse( result._args, result, overrides, ' '.join([prog, child])) def run(self, *args, **kwargs): """Run the command. Must be implemented by sub-classes that are not simply an intermediate in the command namespace. """ raise NotImplementedError @contextlib.contextmanager def _actor_error_handling(name): try: yield except exceptions.BackendError as exc: logger.error( 'Backend (%s) initialization error: %s', name, exc.message) except exceptions.FrontendError as exc: logger.error( 'Frontend (%s) initialization error: %s', name, exc.message) except exceptions.MixerError as exc: logger.error( 'Mixer (%s) initialization error: %s', name, exc.message) except Exception: logger.exception('Got un-handled exception from %s', name) # TODO: move out of this utility class class RootCommand(Command): def __init__(self): super(RootCommand, self).__init__() self.set(base_verbosity_level=0) self.add_argument( '-h', '--help', action='help', help='Show this message and exit') self.add_argument( '--version', action='version', version='Mopidy %s' % versioning.get_version()) self.add_argument( '-q', '--quiet', action='store_const', const=-1, dest='verbosity_level', help='less output (warning level)') self.add_argument( '-v', '--verbose', action='count', dest='verbosity_level', default=0, help='more output (repeat up to 3 times for even more)') self.add_argument( '--save-debug-log', action='store_true', dest='save_debug_log', help='save debug log to "./mopidy.log"') self.add_argument( '--config', action='store', dest='config_files', type=config_files_type, default=DEFAULT_CONFIG, metavar='FILES', help='config files to use, colon seperated, later files override') self.add_argument( '-o', '--option', action='append', dest='config_overrides', type=config_override_type, metavar='OPTIONS', help='`section/key=value` values to override config options') def run(self, args, config): def on_sigterm(loop): logger.info('GLib mainloop got SIGTERM. Exiting...') loop.quit() loop = GLib.MainLoop() GLib.unix_signal_add( GLib.PRIORITY_DEFAULT, signal.SIGTERM, on_sigterm, loop) mixer_class = self.get_mixer_class(config, args.registry['mixer']) backend_classes = args.registry['backend'] frontend_classes = args.registry['frontend'] exit_status_code = 0 try: mixer = None if mixer_class is not None: mixer = self.start_mixer(config, mixer_class) if mixer: self.configure_mixer(config, mixer) audio = self.start_audio(config, mixer) backends = self.start_backends(config, backend_classes, audio) core = self.start_core(config, mixer, backends, audio) self.start_frontends(config, frontend_classes, core) logger.info('Starting GLib mainloop') loop.run() except (exceptions.BackendError, exceptions.FrontendError, exceptions.MixerError): logger.info('Initialization error. Exiting...') exit_status_code = 1 except KeyboardInterrupt: logger.info('Interrupted. Exiting...') except Exception: logger.exception('Uncaught exception') finally: loop.quit() self.stop_frontends(frontend_classes) self.stop_core() self.stop_backends(backend_classes) self.stop_audio() if mixer_class is not None: self.stop_mixer(mixer_class) process.stop_remaining_actors() return exit_status_code def get_mixer_class(self, config, mixer_classes): logger.debug( 'Available Mopidy mixers: %s', ', '.join(m.__name__ for m in mixer_classes) or 'none') if config['audio']['mixer'] == 'none': logger.debug('Mixer disabled') return None selected_mixers = [ m for m in mixer_classes if m.name == config['audio']['mixer']] if len(selected_mixers) != 1: logger.error( 'Did not find unique mixer "%s". Alternatives are: %s', config['audio']['mixer'], ', '.join([m.name for m in mixer_classes]) + ', none' or 'none') process.exit_process() return selected_mixers[0] def start_mixer(self, config, mixer_class): logger.info('Starting Mopidy mixer: %s', mixer_class.__name__) with _actor_error_handling(mixer_class.__name__): mixer = mixer_class.start(config=config).proxy() try: mixer.ping().get() return mixer except pykka.ActorDeadError as exc: logger.error('Actor died: %s', exc) return None def configure_mixer(self, config, mixer): volume = config['audio']['mixer_volume'] if volume is not None: mixer.set_volume(volume) logger.info('Mixer volume set to %d', volume) else: logger.debug('Mixer volume left unchanged') def start_audio(self, config, mixer): logger.info('Starting Mopidy audio') return Audio.start(config=config, mixer=mixer).proxy() def start_backends(self, config, backend_classes, audio): logger.info( 'Starting Mopidy backends: %s', ', '.join(b.__name__ for b in backend_classes) or 'none') backends = [] for backend_class in backend_classes: with _actor_error_handling(backend_class.__name__): with timer.time_logger(backend_class.__name__): backend = backend_class.start( config=config, audio=audio).proxy() backends.append(backend) # Block until all on_starts have finished, letting them run in parallel for backend in backends[:]: try: backend.ping().get() except pykka.ActorDeadError as exc: backends.remove(backend) logger.error('Actor died: %s', exc) return backends def start_core(self, config, mixer, backends, audio): logger.info('Starting Mopidy core') return Core.start( config=config, mixer=mixer, backends=backends, audio=audio).proxy() def start_frontends(self, config, frontend_classes, core): logger.info( 'Starting Mopidy frontends: %s', ', '.join(f.__name__ for f in frontend_classes) or 'none') for frontend_class in frontend_classes: with _actor_error_handling(frontend_class.__name__): with timer.time_logger(frontend_class.__name__): frontend_class.start(config=config, core=core) def stop_frontends(self, frontend_classes): logger.info('Stopping Mopidy frontends') for frontend_class in frontend_classes: process.stop_actors_by_class(frontend_class) def stop_core(self): logger.info('Stopping Mopidy core') process.stop_actors_by_class(Core) def stop_backends(self, backend_classes): logger.info('Stopping Mopidy backends') for backend_class in backend_classes: process.stop_actors_by_class(backend_class) def stop_audio(self): logger.info('Stopping Mopidy audio') process.stop_actors_by_class(Audio) def stop_mixer(self, mixer_class): logger.info('Stopping Mopidy mixer') process.stop_actors_by_class(mixer_class) class ConfigCommand(Command): help = 'Show currently active configuration.' def __init__(self): super(ConfigCommand, self).__init__() self.set(base_verbosity_level=-1) def run(self, config, errors, schemas): print(config_lib.format(config, schemas, errors)) return 0 class DepsCommand(Command): help = 'Show dependencies and debug information.' def __init__(self): super(DepsCommand, self).__init__() self.set(base_verbosity_level=-1) def run(self): print(deps.format_dependency_list()) return 0 Mopidy-2.0.0/mopidy/mpd/0000775000175000017500000000000012660436443015251 5ustar jodaljodal00000000000000Mopidy-2.0.0/mopidy/mpd/translator.py0000664000175000017500000001356412653464377020036 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import datetime import logging import re from mopidy.models import TlTrack from mopidy.mpd.protocol import tagtype_list logger = logging.getLogger(__name__) # TODO: special handling of local:// uri scheme normalize_path_re = re.compile(r'[^/]+') def normalize_path(path, relative=False): parts = normalize_path_re.findall(path or '') if not relative: parts.insert(0, '') return '/'.join(parts) def track_to_mpd_format(track, position=None, stream_title=None): """ Format track for output to MPD client. :param track: the track :type track: :class:`mopidy.models.Track` or :class:`mopidy.models.TlTrack` :param position: track's position in playlist :type position: integer :param stream_title: the current streams title :type position: string :rtype: list of two-tuples """ if isinstance(track, TlTrack): (tlid, track) = track else: (tlid, track) = (None, track) if not track.uri: logger.warning('Ignoring track without uri') return [] result = [ ('file', track.uri), ('Time', track.length and (track.length // 1000) or 0), ('Artist', concat_multi_values(track.artists, 'name')), ('Album', track.album and track.album.name or ''), ] if stream_title is not None: result.append(('Title', stream_title)) if track.name: result.append(('Name', track.name)) else: result.append(('Title', track.name or '')) if track.date: result.append(('Date', track.date)) if track.album is not None and track.album.num_tracks is not None: result.append(('Track', '%d/%d' % ( track.track_no or 0, track.album.num_tracks))) else: result.append(('Track', track.track_no or 0)) if position is not None and tlid is not None: result.append(('Pos', position)) result.append(('Id', tlid)) if track.album is not None and track.album.musicbrainz_id is not None: result.append(('MUSICBRAINZ_ALBUMID', track.album.musicbrainz_id)) if track.album is not None and track.album.artists: result.append( ('AlbumArtist', concat_multi_values(track.album.artists, 'name'))) musicbrainz_ids = concat_multi_values( track.album.artists, 'musicbrainz_id') if musicbrainz_ids: result.append(('MUSICBRAINZ_ALBUMARTISTID', musicbrainz_ids)) if track.artists: musicbrainz_ids = concat_multi_values(track.artists, 'musicbrainz_id') if musicbrainz_ids: result.append(('MUSICBRAINZ_ARTISTID', musicbrainz_ids)) if track.composers: result.append( ('Composer', concat_multi_values(track.composers, 'name'))) if track.performers: result.append( ('Performer', concat_multi_values(track.performers, 'name'))) if track.genre: result.append(('Genre', track.genre)) if track.disc_no: result.append(('Disc', track.disc_no)) if track.last_modified: datestring = datetime.datetime.utcfromtimestamp( track.last_modified // 1000).isoformat() result.append(('Last-Modified', datestring + 'Z')) if track.musicbrainz_id is not None: result.append(('MUSICBRAINZ_TRACKID', track.musicbrainz_id)) if track.album and track.album.uri: result.append(('X-AlbumUri', track.album.uri)) if track.album and track.album.images: images = ';'.join(i for i in track.album.images if i is not '') result.append(('X-AlbumImage', images)) result = [element for element in result if _has_value(*element)] return result def _has_value(tagtype, value): """ Determine whether to add the tagtype to the output or not. :param tagtype: the MPD tagtype :type tagtype: string :param value: the tag value :rtype: bool """ if tagtype in tagtype_list.TAGTYPE_LIST: return bool(value) return True def concat_multi_values(models, attribute): """ Format Mopidy model values for output to MPD client. :param models: the models :type models: array of :class:`mopidy.models.Artist`, :class:`mopidy.models.Album` or :class:`mopidy.models.Track` :param attribute: the attribute to use :type attribute: string :rtype: string """ # Don't sort the values. MPD doesn't appear to (or if it does it's not # strict alphabetical). If we just use them in the order in which they come # in then the musicbrainz ids have a higher chance of staying in sync return ';'.join( getattr(m, attribute) for m in models if getattr(m, attribute, None) is not None ) def tracks_to_mpd_format(tracks, start=0, end=None): """ Format list of tracks for output to MPD client. Optionally limit output to the slice ``[start:end]`` of the list. :param tracks: the tracks :type tracks: list of :class:`mopidy.models.Track` or :class:`mopidy.models.TlTrack` :param start: position of first track to include in output :type start: int (positive or negative) :param end: position after last track to include in output :type end: int (positive or negative) or :class:`None` for end of list :rtype: list of lists of two-tuples """ if end is None: end = len(tracks) tracks = tracks[start:end] positions = range(start, end) assert len(tracks) == len(positions) result = [] for track, position in zip(tracks, positions): formatted_track = track_to_mpd_format(track, position) if formatted_track: result.append(formatted_track) return result def playlist_to_mpd_format(playlist, *args, **kwargs): """ Format playlist for output to MPD client. Arguments as for :func:`tracks_to_mpd_format`, except the first one. """ return tracks_to_mpd_format(playlist.tracks, *args, **kwargs) Mopidy-2.0.0/mopidy/mpd/tokenize.py0000664000175000017500000000627212505224626017456 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import re from mopidy.mpd import exceptions WORD_RE = re.compile(r""" ^ (\s*) # Leading whitespace not allowed, capture it to report. ([a-z][a-z0-9_]*) # A command name (?:\s+|$) # trailing whitespace or EOS (.*) # Possibly a remainder to be parsed """, re.VERBOSE) # Quotes matching is an unrolled version of "(?:[^"\\]|\\.)*" PARAM_RE = re.compile(r""" ^ # Leading whitespace is not allowed (?: ([^%(unprintable)s"']+) # ord(char) < 0x20, not ", not ' | # or "([^"\\]*(?:\\.[^"\\]*)*)" # anything surrounded by quotes ) (?:\s+|$) # trailing whitespace or EOS (.*) # Possibly a remainder to be parsed """ % {'unprintable': ''.join(map(chr, range(0x21)))}, re.VERBOSE) BAD_QUOTED_PARAM_RE = re.compile(r""" ^ "[^"\\]*(?:\\.[^"\\]*)* # start of a quoted value (?: # followed by: ("[^\s]) # non-escaped quote, followed by non-whitespace | # or ([^"]) # anything that is not a quote ) """, re.VERBOSE) UNESCAPE_RE = re.compile(r'\\(.)') # Backslash escapes any following char. def split(line): """Splits a line into tokens using same rules as MPD. - Lines may not start with whitespace - Tokens are split by arbitrary amount of spaces or tabs - First token must match `[a-z][a-z0-9_]*` - Remaining tokens can be unquoted or quoted tokens. - Unquoted tokens consist of all printable characters except double quotes, single quotes, spaces and tabs. - Quoted tokens are surrounded by a matching pair of double quotes. - The closing quote must be followed by space, tab or end of line. - Any value is allowed inside a quoted token. Including double quotes, assuming it is correctly escaped. - Backslash inside a quoted token is used to escape the following character. For examples see the tests for this function. """ if not line.strip(): raise exceptions.MpdNoCommand('No command given') match = WORD_RE.match(line) if not match: raise exceptions.MpdUnknownError('Invalid word character') whitespace, command, remainder = match.groups() if whitespace: raise exceptions.MpdUnknownError('Letter expected') result = [command] while remainder: match = PARAM_RE.match(remainder) if not match: msg = _determine_error_message(remainder) raise exceptions.MpdArgError(msg, command=command) unquoted, quoted, remainder = match.groups() result.append(unquoted or UNESCAPE_RE.sub(r'\g<1>', quoted)) return result def _determine_error_message(remainder): """Helper to emulate MPD errors.""" # Following checks are simply to match MPD error messages: match = BAD_QUOTED_PARAM_RE.match(remainder) if match: if match.group(1): return 'Space expected after closing \'"\'' else: return 'Missing closing \'"\'' return 'Invalid unquoted character' Mopidy-2.0.0/mopidy/mpd/dispatcher.py0000664000175000017500000002566012647257461017770 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging import re import pykka from mopidy.mpd import exceptions, protocol, tokenize logger = logging.getLogger(__name__) protocol.load_protocol_modules() class MpdDispatcher(object): """ The MPD session feeds the MPD dispatcher with requests. The dispatcher finds the correct handler, processes the request and sends the response back to the MPD session. """ _noidle = re.compile(r'^noidle$') def __init__(self, session=None, config=None, core=None, uri_map=None): self.config = config self.authenticated = False self.command_list_receiving = False self.command_list_ok = False self.command_list = [] self.command_list_index = None self.context = MpdContext( self, session=session, config=config, core=core, uri_map=uri_map) def handle_request(self, request, current_command_list_index=None): """Dispatch incoming requests to the correct handler.""" self.command_list_index = current_command_list_index response = [] filter_chain = [ self._catch_mpd_ack_errors_filter, self._authenticate_filter, self._command_list_filter, self._idle_filter, self._add_ok_filter, self._call_handler_filter, ] return self._call_next_filter(request, response, filter_chain) def handle_idle(self, subsystem): # TODO: validate against mopidy/mpd/protocol/status.SUBSYSTEMS self.context.events.add(subsystem) subsystems = self.context.subscriptions.intersection( self.context.events) if not subsystems: return response = [] for subsystem in subsystems: response.append('changed: %s' % subsystem) response.append('OK') self.context.subscriptions = set() self.context.events = set() self.context.session.send_lines(response) def _call_next_filter(self, request, response, filter_chain): if filter_chain: next_filter = filter_chain.pop(0) return next_filter(request, response, filter_chain) else: return response # Filter: catch MPD ACK errors def _catch_mpd_ack_errors_filter(self, request, response, filter_chain): try: return self._call_next_filter(request, response, filter_chain) except exceptions.MpdAckError as mpd_ack_error: if self.command_list_index is not None: mpd_ack_error.index = self.command_list_index return [mpd_ack_error.get_mpd_ack()] # Filter: authenticate def _authenticate_filter(self, request, response, filter_chain): if self.authenticated: return self._call_next_filter(request, response, filter_chain) elif self.config['mpd']['password'] is None: self.authenticated = True return self._call_next_filter(request, response, filter_chain) else: command_name = request.split(' ')[0] command = protocol.commands.handlers.get(command_name) if command and not command.auth_required: return self._call_next_filter(request, response, filter_chain) else: raise exceptions.MpdPermissionError(command=command_name) # Filter: command list def _command_list_filter(self, request, response, filter_chain): if self._is_receiving_command_list(request): self.command_list.append(request) return [] else: response = self._call_next_filter(request, response, filter_chain) if (self._is_receiving_command_list(request) or self._is_processing_command_list(request)): if response and response[-1] == 'OK': response = response[:-1] return response def _is_receiving_command_list(self, request): return ( self.command_list_receiving and request != 'command_list_end') def _is_processing_command_list(self, request): return ( self.command_list_index is not None and request != 'command_list_end') # Filter: idle def _idle_filter(self, request, response, filter_chain): if self._is_currently_idle() and not self._noidle.match(request): logger.debug( 'Client sent us %s, only %s is allowed while in ' 'the idle state', repr(request), repr('noidle')) self.context.session.close() return [] if not self._is_currently_idle() and self._noidle.match(request): return [] # noidle was called before idle response = self._call_next_filter(request, response, filter_chain) if self._is_currently_idle(): return [] else: return response def _is_currently_idle(self): return bool(self.context.subscriptions) # Filter: add OK def _add_ok_filter(self, request, response, filter_chain): response = self._call_next_filter(request, response, filter_chain) if not self._has_error(response): response.append('OK') return response def _has_error(self, response): return response and response[-1].startswith('ACK') # Filter: call handler def _call_handler_filter(self, request, response, filter_chain): try: response = self._format_response(self._call_handler(request)) return self._call_next_filter(request, response, filter_chain) except pykka.ActorDeadError as e: logger.warning('Tried to communicate with dead actor.') raise exceptions.MpdSystemError(e) def _call_handler(self, request): tokens = tokenize.split(request) # TODO: check that blacklist items are valid commands? blacklist = self.config['mpd'].get('command_blacklist', []) if tokens and tokens[0] in blacklist: logger.warning( 'MPD client used blacklisted command: %s', tokens[0]) raise exceptions.MpdDisabled(command=tokens[0]) try: return protocol.commands.call(tokens, context=self.context) except exceptions.MpdAckError as exc: if exc.command is None: exc.command = tokens[0] raise def _format_response(self, response): formatted_response = [] for element in self._listify_result(response): formatted_response.extend(self._format_lines(element)) return formatted_response def _listify_result(self, result): if result is None: return [] if isinstance(result, set): return self._flatten(list(result)) if not isinstance(result, list): return [result] return self._flatten(result) def _flatten(self, the_list): result = [] for element in the_list: if isinstance(element, list): result.extend(self._flatten(element)) else: result.append(element) return result def _format_lines(self, line): if isinstance(line, dict): return ['%s: %s' % (key, value) for (key, value) in line.items()] if isinstance(line, tuple): (key, value) = line return ['%s: %s' % (key, value)] return [line] class MpdContext(object): """ This object is passed as the first argument to all MPD command handlers to give the command handlers access to important parts of Mopidy. """ #: The current :class:`MpdDispatcher`. dispatcher = None #: The current :class:`mopidy.mpd.MpdSession`. session = None #: The MPD password password = None #: The Mopidy core API. An instance of :class:`mopidy.core.Core`. core = None #: The active subsystems that have pending events. events = None #: The subsytems that we want to be notified about in idle mode. subscriptions = None _uri_map = None def __init__(self, dispatcher, session=None, config=None, core=None, uri_map=None): self.dispatcher = dispatcher self.session = session if config is not None: self.password = config['mpd']['password'] self.core = core self.events = set() self.subscriptions = set() self._uri_map = uri_map def lookup_playlist_uri_from_name(self, name): """ Helper function to retrieve a playlist from its unique MPD name. """ return self._uri_map.playlist_uri_from_name(name) def lookup_playlist_name_from_uri(self, uri): """ Helper function to retrieve the unique MPD playlist name from its uri. """ return self._uri_map.playlist_name_from_uri(uri) def browse(self, path, recursive=True, lookup=True): """ Browse the contents of a given directory path. Returns a sequence of two-tuples ``(path, data)``. If ``recursive`` is true, it returns results for all entries in the given path. If ``lookup`` is true and the ``path`` is to a track, the returned ``data`` is a future which will contain the results from looking up the URI with :meth:`mopidy.core.LibraryController.lookup`. If ``lookup`` is false and the ``path`` is to a track, the returned ``data`` will be a :class:`mopidy.models.Ref` for the track. For all entries that are not tracks, the returned ``data`` will be :class:`None`. """ path_parts = re.findall(r'[^/]+', path or '') root_path = '/'.join([''] + path_parts) uri = self._uri_map.uri_from_name(root_path) if uri is None: for part in path_parts: for ref in self.core.library.browse(uri).get(): if ref.type != ref.TRACK and ref.name == part: uri = ref.uri break else: raise exceptions.MpdNoExistError('Not found') root_path = self._uri_map.insert(root_path, uri) if recursive: yield (root_path, None) path_and_futures = [(root_path, self.core.library.browse(uri))] while path_and_futures: base_path, future = path_and_futures.pop() for ref in future.get(): path = '/'.join([base_path, ref.name.replace('/', '')]) path = self._uri_map.insert(path, ref.uri) if ref.type == ref.TRACK: if lookup: # TODO: can we lookup all the refs at once now? yield (path, self.core.library.lookup(uris=[ref.uri])) else: yield (path, ref) else: yield (path, None) if recursive: path_and_futures.append( (path, self.core.library.browse(ref.uri))) Mopidy-2.0.0/mopidy/mpd/__init__.py0000664000175000017500000000213212660436420017353 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import os import mopidy from mopidy import config, ext class Extension(ext.Extension): dist_name = 'Mopidy-MPD' ext_name = 'mpd' version = mopidy.__version__ def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') return config.read(conf_file) def get_config_schema(self): schema = super(Extension, self).get_config_schema() schema['hostname'] = config.Hostname() schema['port'] = config.Port() schema['password'] = config.Secret(optional=True) schema['max_connections'] = config.Integer(minimum=1) schema['connection_timeout'] = config.Integer(minimum=1) schema['zeroconf'] = config.String(optional=True) schema['command_blacklist'] = config.List(optional=True) schema['default_playlist_scheme'] = config.String() return schema def validate_environment(self): pass def setup(self, registry): from .actor import MpdFrontend registry.add('frontend', MpdFrontend) Mopidy-2.0.0/mopidy/mpd/uri_mapper.py0000664000175000017500000000512212660436420017761 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import re # TOOD: refactor this into a generic mapper that does not know about browse # or playlists and then use one instance for each case? class MpdUriMapper(object): """ Maintains the mappings between uniquified MPD names and URIs. """ #: The Mopidy core API. An instance of :class:`mopidy.core.Core`. core = None _invalid_browse_chars = re.compile(r'[\n\r]') _invalid_playlist_chars = re.compile(r'[/]') def __init__(self, core=None): self.core = core self._uri_from_name = {} self._browse_name_from_uri = {} self._playlist_name_from_uri = {} def _create_unique_name(self, name, uri): stripped_name = self._invalid_browse_chars.sub(' ', name) name = stripped_name i = 2 while name in self._uri_from_name: if self._uri_from_name[name] == uri: return name name = '%s [%d]' % (stripped_name, i) i += 1 return name def insert(self, name, uri, playlist=False): """ Create a unique and MPD compatible name that maps to the given URI. """ name = self._create_unique_name(name, uri) self._uri_from_name[name] = uri if playlist: self._playlist_name_from_uri[uri] = name else: self._browse_name_from_uri[uri] = name return name def uri_from_name(self, name): """ Return the uri for the given MPD name. """ return self._uri_from_name.get(name) def refresh_playlists_mapping(self): """ Maintain map between playlists and unique playlist names to be used by MPD. """ if self.core is None: return for playlist_ref in self.core.playlists.as_list().get(): if not playlist_ref.name: continue name = self._invalid_playlist_chars.sub('|', playlist_ref.name) self.insert(name, playlist_ref.uri, playlist=True) def playlist_uri_from_name(self, name): """ Helper function to retrieve a playlist URI from its unique MPD name. """ if name not in self._uri_from_name: self.refresh_playlists_mapping() return self._uri_from_name.get(name) def playlist_name_from_uri(self, uri): """ Helper function to retrieve the unique MPD playlist name from its URI. """ if uri not in self._playlist_name_from_uri: self.refresh_playlists_mapping() return self._playlist_name_from_uri[uri] Mopidy-2.0.0/mopidy/mpd/ext.conf0000664000175000017500000000033612660436420016715 0ustar jodaljodal00000000000000[mpd] enabled = true hostname = 127.0.0.1 port = 6600 password = max_connections = 20 connection_timeout = 60 zeroconf = Mopidy MPD server on $hostname command_blacklist = listall,listallinfo default_playlist_scheme = m3u Mopidy-2.0.0/mopidy/mpd/exceptions.py0000664000175000017500000001012612660436420017777 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mopidy.exceptions import MopidyException class MpdAckError(MopidyException): """See fields on this class for available MPD error codes""" ACK_ERROR_NOT_LIST = 1 ACK_ERROR_ARG = 2 ACK_ERROR_PASSWORD = 3 ACK_ERROR_PERMISSION = 4 ACK_ERROR_UNKNOWN = 5 ACK_ERROR_NO_EXIST = 50 ACK_ERROR_PLAYLIST_MAX = 51 ACK_ERROR_SYSTEM = 52 ACK_ERROR_PLAYLIST_LOAD = 53 ACK_ERROR_UPDATE_ALREADY = 54 ACK_ERROR_PLAYER_SYNC = 55 ACK_ERROR_EXIST = 56 error_code = 0 def __init__(self, message='', index=0, command=None): super(MpdAckError, self).__init__(message, index, command) self.message = message self.index = index self.command = command def get_mpd_ack(self): """ MPD error code format:: ACK [%(error_code)i@%(index)i] {%(command)s} description """ return 'ACK [%i@%i] {%s} %s' % ( self.__class__.error_code, self.index, self.command, self.message) class MpdArgError(MpdAckError): error_code = MpdAckError.ACK_ERROR_ARG class MpdPasswordError(MpdAckError): error_code = MpdAckError.ACK_ERROR_PASSWORD class MpdPermissionError(MpdAckError): error_code = MpdAckError.ACK_ERROR_PERMISSION def __init__(self, *args, **kwargs): super(MpdPermissionError, self).__init__(*args, **kwargs) assert self.command is not None, 'command must be given explicitly' self.message = 'you don\'t have permission for "%s"' % self.command class MpdUnknownError(MpdAckError): error_code = MpdAckError.ACK_ERROR_UNKNOWN class MpdUnknownCommand(MpdUnknownError): def __init__(self, *args, **kwargs): super(MpdUnknownCommand, self).__init__(*args, **kwargs) assert self.command is not None, 'command must be given explicitly' self.message = 'unknown command "%s"' % self.command self.command = '' class MpdNoCommand(MpdUnknownCommand): def __init__(self, *args, **kwargs): kwargs['command'] = '' super(MpdNoCommand, self).__init__(*args, **kwargs) self.message = 'No command given' class MpdNoExistError(MpdAckError): error_code = MpdAckError.ACK_ERROR_NO_EXIST class MpdExistError(MpdAckError): error_code = MpdAckError.ACK_ERROR_EXIST class MpdSystemError(MpdAckError): error_code = MpdAckError.ACK_ERROR_SYSTEM class MpdInvalidPlaylistName(MpdAckError): error_code = MpdAckError.ACK_ERROR_ARG def __init__(self, *args, **kwargs): super(MpdInvalidPlaylistName, self).__init__(*args, **kwargs) self.message = ('playlist name is invalid: playlist names may not ' 'contain slashes, newlines or carriage returns') class MpdNotImplemented(MpdAckError): error_code = 0 def __init__(self, *args, **kwargs): super(MpdNotImplemented, self).__init__(*args, **kwargs) self.message = 'Not implemented' class MpdInvalidTrackForPlaylist(MpdAckError): # NOTE: This is a custom error for Mopidy that does not exist in MPD. error_code = 0 def __init__(self, playlist_scheme, track_scheme, *args, **kwargs): super(MpdInvalidTrackForPlaylist, self).__init__(*args, **kwargs) self.message = ( 'Playlist with scheme "%s" can\'t store track scheme "%s"' % (playlist_scheme, track_scheme)) class MpdFailedToSavePlaylist(MpdAckError): # NOTE: This is a custom error for Mopidy that does not exist in MPD. error_code = 0 def __init__(self, backend_scheme, *args, **kwargs): super(MpdFailedToSavePlaylist, self).__init__(*args, **kwargs) self.message = 'Backend with scheme "%s" failed to save playlist' % ( backend_scheme) class MpdDisabled(MpdAckError): # NOTE: This is a custom error for Mopidy that does not exist in MPD. error_code = 0 def __init__(self, *args, **kwargs): super(MpdDisabled, self).__init__(*args, **kwargs) assert self.command is not None, 'command must be given explicitly' self.message = '"%s" has been disabled in the server' % self.command Mopidy-2.0.0/mopidy/mpd/session.py0000664000175000017500000000331012660436420017276 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging from mopidy.internal import formatting, network from mopidy.mpd import dispatcher, protocol logger = logging.getLogger(__name__) class MpdSession(network.LineProtocol): """ The MPD client session. Keeps track of a single client session. Any requests from the client is passed on to the MPD request dispatcher. """ terminator = protocol.LINE_TERMINATOR encoding = protocol.ENCODING delimiter = r'\r?\n' def __init__(self, connection, config=None, core=None, uri_map=None): super(MpdSession, self).__init__(connection) self.dispatcher = dispatcher.MpdDispatcher( session=self, config=config, core=core, uri_map=uri_map) def on_start(self): logger.info('New MPD connection from [%s]:%s', self.host, self.port) self.send_lines(['OK MPD %s' % protocol.VERSION]) def on_line_received(self, line): logger.debug('Request from [%s]:%s: %s', self.host, self.port, line) response = self.dispatcher.handle_request(line) if not response: return logger.debug( 'Response to [%s]:%s: %s', self.host, self.port, formatting.indent(self.terminator.join(response))) self.send_lines(response) def on_event(self, subsystem): self.dispatcher.handle_idle(subsystem) def decode(self, line): try: return super(MpdSession, self).decode(line) except ValueError: logger.warning( 'Stopping actor due to unescaping error, data ' 'supplied by client was not valid.') self.stop() def close(self): self.stop() Mopidy-2.0.0/mopidy/mpd/actor.py0000664000175000017500000000544012660436420016731 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging import pykka from mopidy import exceptions, listener, zeroconf from mopidy.core import CoreListener from mopidy.internal import encoding, network, process from mopidy.mpd import session, uri_mapper logger = logging.getLogger(__name__) _CORE_EVENTS_TO_IDLE_SUBSYSTEMS = { 'track_playback_paused': None, 'track_playback_resumed': None, 'track_playback_started': None, 'track_playback_ended': None, 'playback_state_changed': 'player', 'tracklist_changed': 'playlist', 'playlists_loaded': 'stored_playlist', 'playlist_changed': 'stored_playlist', 'playlist_deleted': 'stored_playlist', 'options_changed': 'options', 'volume_changed': 'mixer', 'mute_changed': 'output', 'seeked': 'player', 'stream_title_changed': 'playlist', } class MpdFrontend(pykka.ThreadingActor, CoreListener): def __init__(self, config, core): super(MpdFrontend, self).__init__() self.hostname = network.format_hostname(config['mpd']['hostname']) self.port = config['mpd']['port'] self.uri_map = uri_mapper.MpdUriMapper(core) self.zeroconf_name = config['mpd']['zeroconf'] self.zeroconf_service = None self._setup_server(config, core) def _setup_server(self, config, core): try: network.Server( self.hostname, self.port, protocol=session.MpdSession, protocol_kwargs={ 'config': config, 'core': core, 'uri_map': self.uri_map, }, max_connections=config['mpd']['max_connections'], timeout=config['mpd']['connection_timeout']) except IOError as error: raise exceptions.FrontendError( 'MPD server startup failed: %s' % encoding.locale_decode(error)) logger.info('MPD server running at [%s]:%s', self.hostname, self.port) def on_start(self): if self.zeroconf_name: self.zeroconf_service = zeroconf.Zeroconf( name=self.zeroconf_name, stype='_mpd._tcp', port=self.port) self.zeroconf_service.publish() def on_stop(self): if self.zeroconf_service: self.zeroconf_service.unpublish() process.stop_actors_by_class(session.MpdSession) def on_event(self, event, **kwargs): if event not in _CORE_EVENTS_TO_IDLE_SUBSYSTEMS: logger.warning( 'Got unexpected event: %s(%s)', event, ', '.join(kwargs)) else: self.send_idle(_CORE_EVENTS_TO_IDLE_SUBSYSTEMS[event]) def send_idle(self, subsystem): if subsystem: listener.send(session.MpdSession, subsystem) Mopidy-2.0.0/mopidy/mpd/protocol/0000775000175000017500000000000012660436443017112 5ustar jodaljodal00000000000000Mopidy-2.0.0/mopidy/mpd/protocol/playback.py0000664000175000017500000003454112660436420021254 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mopidy.core import PlaybackState from mopidy.internal import deprecation from mopidy.mpd import exceptions, protocol @protocol.commands.add('consume', state=protocol.BOOL) def consume(context, state): """ *musicpd.org, playback section:* ``consume {STATE}`` Sets consume state to ``STATE``, ``STATE`` should be 0 or 1. When consume is activated, each song played is removed from playlist. """ context.core.tracklist.set_consume(state) @protocol.commands.add('crossfade', seconds=protocol.UINT) def crossfade(context, seconds): """ *musicpd.org, playback section:* ``crossfade {SECONDS}`` Sets crossfading between songs. """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('mixrampdb') def mixrampdb(context, decibels): """ *musicpd.org, playback section:* ``mixrampdb {deciBels}`` Sets the threshold at which songs will be overlapped. Like crossfading but doesn't fade the track volume, just overlaps. The songs need to have MixRamp tags added by an external tool. 0dB is the normalized maximum volume so use negative values, I prefer -17dB. In the absence of mixramp tags crossfading will be used. See http://sourceforge.net/projects/mixramp """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('mixrampdelay', seconds=protocol.UINT) def mixrampdelay(context, seconds): """ *musicpd.org, playback section:* ``mixrampdelay {SECONDS}`` Additional time subtracted from the overlap calculated by mixrampdb. A value of "nan" disables MixRamp overlapping and falls back to crossfading. """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('next') def next_(context): """ *musicpd.org, playback section:* ``next`` Plays next song in the playlist. *MPD's behaviour when affected by repeat/random/single/consume:* Given a playlist of three tracks numbered 1, 2, 3, and a currently playing track ``c``. ``next_track`` is defined at the track that will be played upon calls to ``next``. Tests performed on MPD 0.15.4-1ubuntu3. ====== ====== ====== ======= ===== ===== ===== ===== Inputs next_track ------------------------------- ------------------- ----- repeat random single consume c = 1 c = 2 c = 3 Notes ====== ====== ====== ======= ===== ===== ===== ===== T T T T 2 3 EOPL T T T . Rand Rand Rand [1] T T . T Rand Rand Rand [4] T T . . Rand Rand Rand [4] T . T T 2 3 EOPL T . T . 2 3 1 T . . T 3 3 EOPL T . . . 2 3 1 . T T T Rand Rand Rand [3] . T T . Rand Rand Rand [3] . T . T Rand Rand Rand [2] . T . . Rand Rand Rand [2] . . T T 2 3 EOPL . . T . 2 3 EOPL . . . T 2 3 EOPL . . . . 2 3 EOPL ====== ====== ====== ======= ===== ===== ===== ===== - When end of playlist (EOPL) is reached, the current track is unset. - [1] When *random* and *single* is combined, ``next`` selects a track randomly at each invocation, and not just the next track in an internal prerandomized playlist. - [2] When *random* is active, ``next`` will skip through all tracks in the playlist in random order, and finally EOPL is reached. - [3] *single* has no effect in combination with *random* alone, or *random* and *consume*. - [4] When *random* and *repeat* is active, EOPL is never reached, but the playlist is played again, in the same random order as the first time. """ return context.core.playback.next().get() @protocol.commands.add('pause', state=protocol.BOOL) def pause(context, state=None): """ *musicpd.org, playback section:* ``pause {PAUSE}`` Toggles pause/resumes playing, ``PAUSE`` is 0 or 1. *MPDroid:* - Calls ``pause`` without any arguments to toogle pause. """ if state is None: deprecation.warn('mpd.protocol.playback.pause:state_arg') playback_state = context.core.playback.get_state().get() if (playback_state == PlaybackState.PLAYING): context.core.playback.pause().get() elif (playback_state == PlaybackState.PAUSED): context.core.playback.resume().get() elif state: context.core.playback.pause().get() else: context.core.playback.resume().get() @protocol.commands.add('play', songpos=protocol.INT) def play(context, songpos=None): """ *musicpd.org, playback section:* ``play [SONGPOS]`` Begins playing the playlist at song number ``SONGPOS``. The original MPD server resumes from the paused state on ``play`` without arguments. *Clarifications:* - ``play "-1"`` when playing is ignored. - ``play "-1"`` when paused resumes playback. - ``play "-1"`` when stopped with a current track starts playback at the current track. - ``play "-1"`` when stopped without a current track, e.g. after playlist replacement, starts playback at the first track. *BitMPC:* - issues ``play 6`` without quotes around the argument. """ if songpos is None: return context.core.playback.play().get() elif songpos == -1: return _play_minus_one(context) try: tl_track = context.core.tracklist.slice(songpos, songpos + 1).get()[0] return context.core.playback.play(tl_track).get() except IndexError: raise exceptions.MpdArgError('Bad song index') def _play_minus_one(context): playback_state = context.core.playback.get_state().get() if playback_state == PlaybackState.PLAYING: return # Nothing to do elif playback_state == PlaybackState.PAUSED: return context.core.playback.resume().get() current_tl_track = context.core.playback.get_current_tl_track().get() if current_tl_track is not None: return context.core.playback.play(current_tl_track).get() tl_tracks = context.core.tracklist.slice(0, 1).get() if tl_tracks: return context.core.playback.play(tl_tracks[0]).get() return # Fail silently @protocol.commands.add('playid', tlid=protocol.INT) def playid(context, tlid): """ *musicpd.org, playback section:* ``playid [SONGID]`` Begins playing the playlist at song ``SONGID``. *Clarifications:* - ``playid "-1"`` when playing is ignored. - ``playid "-1"`` when paused resumes playback. - ``playid "-1"`` when stopped with a current track starts playback at the current track. - ``playid "-1"`` when stopped without a current track, e.g. after playlist replacement, starts playback at the first track. """ if tlid == -1: return _play_minus_one(context) tl_tracks = context.core.tracklist.filter({'tlid': [tlid]}).get() if not tl_tracks: raise exceptions.MpdNoExistError('No such song') return context.core.playback.play(tl_tracks[0]).get() @protocol.commands.add('previous') def previous(context): """ *musicpd.org, playback section:* ``previous`` Plays previous song in the playlist. *MPD's behaviour when affected by repeat/random/single/consume:* Given a playlist of three tracks numbered 1, 2, 3, and a currently playing track ``c``. ``previous_track`` is defined at the track that will be played upon ``previous`` calls. Tests performed on MPD 0.15.4-1ubuntu3. ====== ====== ====== ======= ===== ===== ===== Inputs previous_track ------------------------------- ------------------- repeat random single consume c = 1 c = 2 c = 3 ====== ====== ====== ======= ===== ===== ===== T T T T Rand? Rand? Rand? T T T . 3 1 2 T T . T Rand? Rand? Rand? T T . . 3 1 2 T . T T 3 1 2 T . T . 3 1 2 T . . T 3 1 2 T . . . 3 1 2 . T T T c c c . T T . c c c . T . T c c c . T . . c c c . . T T 1 1 2 . . T . 1 1 2 . . . T 1 1 2 . . . . 1 1 2 ====== ====== ====== ======= ===== ===== ===== - If :attr:`time_position` of the current track is 15s or more, ``previous`` should do a seek to time position 0. """ return context.core.playback.previous().get() @protocol.commands.add('random', state=protocol.BOOL) def random(context, state): """ *musicpd.org, playback section:* ``random {STATE}`` Sets random state to ``STATE``, ``STATE`` should be 0 or 1. """ context.core.tracklist.set_random(state) @protocol.commands.add('repeat', state=protocol.BOOL) def repeat(context, state): """ *musicpd.org, playback section:* ``repeat {STATE}`` Sets repeat state to ``STATE``, ``STATE`` should be 0 or 1. """ context.core.tracklist.set_repeat(state) @protocol.commands.add('replay_gain_mode') def replay_gain_mode(context, mode): """ *musicpd.org, playback section:* ``replay_gain_mode {MODE}`` Sets the replay gain mode. One of ``off``, ``track``, ``album``. Changing the mode during playback may take several seconds, because the new settings does not affect the buffered data. This command triggers the options idle event. """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('replay_gain_status') def replay_gain_status(context): """ *musicpd.org, playback section:* ``replay_gain_status`` Prints replay gain options. Currently, only the variable ``replay_gain_mode`` is returned. """ return 'off' # TODO @protocol.commands.add('seek', songpos=protocol.UINT, seconds=protocol.UINT) def seek(context, songpos, seconds): """ *musicpd.org, playback section:* ``seek {SONGPOS} {TIME}`` Seeks to the position ``TIME`` (in seconds) of entry ``SONGPOS`` in the playlist. *Droid MPD:* - issues ``seek 1 120`` without quotes around the arguments. """ tl_track = context.core.playback.get_current_tl_track().get() if context.core.tracklist.index(tl_track).get() != songpos: play(context, songpos) context.core.playback.seek(seconds * 1000).get() @protocol.commands.add('seekid', tlid=protocol.UINT, seconds=protocol.UINT) def seekid(context, tlid, seconds): """ *musicpd.org, playback section:* ``seekid {SONGID} {TIME}`` Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. """ tl_track = context.core.playback.get_current_tl_track().get() if not tl_track or tl_track.tlid != tlid: playid(context, tlid) context.core.playback.seek(seconds * 1000).get() @protocol.commands.add('seekcur') def seekcur(context, time): """ *musicpd.org, playback section:* ``seekcur {TIME}`` Seeks to the position ``TIME`` within the current song. If prefixed by '+' or '-', then the time is relative to the current playing position. """ if time.startswith(('+', '-')): position = context.core.playback.get_time_position().get() position += protocol.INT(time) * 1000 context.core.playback.seek(position).get() else: position = protocol.UINT(time) * 1000 context.core.playback.seek(position).get() @protocol.commands.add('setvol', volume=protocol.INT) def setvol(context, volume): """ *musicpd.org, playback section:* ``setvol {VOL}`` Sets volume to ``VOL``, the range of volume is 0-100. *Droid MPD:* - issues ``setvol 50`` without quotes around the argument. """ # NOTE: we use INT as clients can pass in +N etc. value = min(max(0, volume), 100) success = context.core.mixer.set_volume(value).get() if not success: raise exceptions.MpdSystemError('problems setting volume') @protocol.commands.add('single', state=protocol.BOOL) def single(context, state): """ *musicpd.org, playback section:* ``single {STATE}`` Sets single state to ``STATE``, ``STATE`` should be 0 or 1. When single is activated, playback is stopped after current song, or song is repeated if the ``repeat`` mode is enabled. """ context.core.tracklist.set_single(state) @protocol.commands.add('stop') def stop(context): """ *musicpd.org, playback section:* ``stop`` Stops playing. """ context.core.playback.stop() @protocol.commands.add('volume', change=protocol.INT) def volume(context, change): """ *musicpd.org, playback section:* ``volume {CHANGE}`` Changes volume by amount ``CHANGE``. Note: ``volume`` is deprecated, use ``setvol`` instead. """ if change < -100 or change > 100: raise exceptions.MpdArgError('Invalid volume value') old_volume = context.core.mixer.get_volume().get() if old_volume is None: raise exceptions.MpdSystemError('problems setting volume') new_volume = min(max(0, old_volume + change), 100) success = context.core.mixer.set_volume(new_volume).get() if not success: raise exceptions.MpdSystemError('problems setting volume') Mopidy-2.0.0/mopidy/mpd/protocol/audio_output.py0000664000175000017500000000366412575004517022214 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mopidy.mpd import exceptions, protocol @protocol.commands.add('disableoutput', outputid=protocol.UINT) def disableoutput(context, outputid): """ *musicpd.org, audio output section:* ``disableoutput {ID}`` Turns an output off. """ if outputid == 0: success = context.core.mixer.set_mute(False).get() if not success: raise exceptions.MpdSystemError('problems disabling output') else: raise exceptions.MpdNoExistError('No such audio output') @protocol.commands.add('enableoutput', outputid=protocol.UINT) def enableoutput(context, outputid): """ *musicpd.org, audio output section:* ``enableoutput {ID}`` Turns an output on. """ if outputid == 0: success = context.core.mixer.set_mute(True).get() if not success: raise exceptions.MpdSystemError('problems enabling output') else: raise exceptions.MpdNoExistError('No such audio output') @protocol.commands.add('toggleoutput', outputid=protocol.UINT) def toggleoutput(context, outputid): """ *musicpd.org, audio output section:* ``toggleoutput {ID}`` Turns an output on or off, depending on the current state. """ if outputid == 0: mute_status = context.core.mixer.get_mute().get() success = context.core.mixer.set_mute(not mute_status) if not success: raise exceptions.MpdSystemError('problems toggling output') else: raise exceptions.MpdNoExistError('No such audio output') @protocol.commands.add('outputs') def outputs(context): """ *musicpd.org, audio output section:* ``outputs`` Shows information about all outputs. """ muted = 1 if context.core.mixer.get_mute().get() else 0 return [ ('outputid', 0), ('outputname', 'Mute'), ('outputenabled', muted), ] Mopidy-2.0.0/mopidy/mpd/protocol/reflection.py0000664000175000017500000000600012575004517021610 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mopidy.mpd import exceptions, protocol from mopidy.mpd.protocol import tagtype_list @protocol.commands.add('config', list_command=False) def config(context): """ *musicpd.org, reflection section:* ``config`` Dumps configuration values that may be interesting for the client. This command is only permitted to "local" clients (connected via UNIX domain socket). """ raise exceptions.MpdPermissionError(command='config') @protocol.commands.add('commands', auth_required=False) def commands(context): """ *musicpd.org, reflection section:* ``commands`` Shows which commands the current user has access to. """ command_names = set() for name, handler in protocol.commands.handlers.items(): if not handler.list_command: continue if context.dispatcher.authenticated or not handler.auth_required: command_names.add(name) return [ ('command', command_name) for command_name in sorted(command_names)] @protocol.commands.add('decoders') def decoders(context): """ *musicpd.org, reflection section:* ``decoders`` Print a list of decoder plugins, followed by their supported suffixes and MIME types. Example response:: plugin: mad suffix: mp3 suffix: mp2 mime_type: audio/mpeg plugin: mpcdec suffix: mpc *Clarifications:* - ncmpcpp asks for decoders the first time you open the browse view. By returning nothing and OK instead of an not implemented error, we avoid "Not implemented" showing up in the ncmpcpp interface, and we get the list of playlists without having to enter the browse interface twice. """ return # TODO @protocol.commands.add('notcommands', auth_required=False) def notcommands(context): """ *musicpd.org, reflection section:* ``notcommands`` Shows which commands the current user does not have access to. """ command_names = set(['config', 'kill']) # No permission to use for name, handler in protocol.commands.handlers.items(): if not handler.list_command: continue if not context.dispatcher.authenticated and handler.auth_required: command_names.add(name) return [ ('command', command_name) for command_name in sorted(command_names)] @protocol.commands.add('tagtypes') def tagtypes(context): """ *musicpd.org, reflection section:* ``tagtypes`` Shows a list of available song metadata. """ return [ ('tagtype', tagtype) for tagtype in tagtype_list.TAGTYPE_LIST ] @protocol.commands.add('urlhandlers') def urlhandlers(context): """ *musicpd.org, reflection section:* ``urlhandlers`` Gets a list of available URL handlers. """ return [ ('handler', uri_scheme) for uri_scheme in context.core.get_uri_schemes().get()] Mopidy-2.0.0/mopidy/mpd/protocol/current_playlist.py0000664000175000017500000003366712660436420023101 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mopidy.compat import urllib from mopidy.internal import deprecation from mopidy.mpd import exceptions, protocol, translator @protocol.commands.add('add') def add(context, uri): """ *musicpd.org, current playlist section:* ``add {URI}`` Adds the file ``URI`` to the playlist (directories add recursively). ``URI`` can also be a single file. *Clarifications:* - ``add ""`` should add all tracks in the library to the current playlist. """ if not uri.strip('/'): return # If we have an URI just try and add it directly without bothering with # jumping through browse... if urllib.parse.urlparse(uri).scheme != '': if context.core.tracklist.add(uris=[uri]).get(): return try: uris = [] for path, ref in context.browse(uri, lookup=False): if ref: uris.append(ref.uri) except exceptions.MpdNoExistError as e: e.message = 'directory or file not found' raise if not uris: raise exceptions.MpdNoExistError('directory or file not found') context.core.tracklist.add(uris=uris).get() @protocol.commands.add('addid', songpos=protocol.UINT) def addid(context, uri, songpos=None): """ *musicpd.org, current playlist section:* ``addid {URI} [POSITION]`` Adds a song to the playlist (non-recursive) and returns the song id. ``URI`` is always a single file or URL. For example:: addid "foo.mp3" Id: 999 OK *Clarifications:* - ``addid ""`` should return an error. """ if not uri: raise exceptions.MpdNoExistError('No such song') length = context.core.tracklist.get_length() if songpos is not None and songpos > length.get(): raise exceptions.MpdArgError('Bad song index') tl_tracks = context.core.tracklist.add( uris=[uri], at_position=songpos).get() if not tl_tracks: raise exceptions.MpdNoExistError('No such song') return ('Id', tl_tracks[0].tlid) @protocol.commands.add('delete', songrange=protocol.RANGE) def delete(context, songrange): """ *musicpd.org, current playlist section:* ``delete [{POS} | {START:END}]`` Deletes a song from the playlist. """ start = songrange.start end = songrange.stop if end is None: end = context.core.tracklist.get_length().get() tl_tracks = context.core.tracklist.slice(start, end).get() if not tl_tracks: raise exceptions.MpdArgError('Bad song index', command='delete') for (tlid, _) in tl_tracks: context.core.tracklist.remove({'tlid': [tlid]}) @protocol.commands.add('deleteid', tlid=protocol.UINT) def deleteid(context, tlid): """ *musicpd.org, current playlist section:* ``deleteid {SONGID}`` Deletes the song ``SONGID`` from the playlist """ tl_tracks = context.core.tracklist.remove({'tlid': [tlid]}).get() if not tl_tracks: raise exceptions.MpdNoExistError('No such song') @protocol.commands.add('clear') def clear(context): """ *musicpd.org, current playlist section:* ``clear`` Clears the current playlist. """ context.core.tracklist.clear() @protocol.commands.add('move', songrange=protocol.RANGE, to=protocol.UINT) def move_range(context, songrange, to): """ *musicpd.org, current playlist section:* ``move [{FROM} | {START:END}] {TO}`` Moves the song at ``FROM`` or range of songs at ``START:END`` to ``TO`` in the playlist. """ start = songrange.start end = songrange.stop if end is None: end = context.core.tracklist.get_length().get() context.core.tracklist.move(start, end, to) @protocol.commands.add('moveid', tlid=protocol.UINT, to=protocol.UINT) def moveid(context, tlid, to): """ *musicpd.org, current playlist section:* ``moveid {FROM} {TO}`` Moves the song with ``FROM`` (songid) to ``TO`` (playlist index) in the playlist. If ``TO`` is negative, it is relative to the current song in the playlist (if there is one). """ tl_tracks = context.core.tracklist.filter({'tlid': [tlid]}).get() if not tl_tracks: raise exceptions.MpdNoExistError('No such song') position = context.core.tracklist.index(tl_tracks[0]).get() context.core.tracklist.move(position, position + 1, to) @protocol.commands.add('playlist') def playlist(context): """ *musicpd.org, current playlist section:* ``playlist`` Displays the current playlist. .. note:: Do not use this, instead use ``playlistinfo``. """ deprecation.warn('mpd.protocol.current_playlist.playlist') return playlistinfo(context) @protocol.commands.add('playlistfind') def playlistfind(context, tag, needle): """ *musicpd.org, current playlist section:* ``playlistfind {TAG} {NEEDLE}`` Finds songs in the current playlist with strict matching. """ if tag == 'filename': tl_tracks = context.core.tracklist.filter({'uri': [needle]}).get() if not tl_tracks: return None position = context.core.tracklist.index(tl_tracks[0]).get() return translator.track_to_mpd_format(tl_tracks[0], position=position) raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('playlistid', tlid=protocol.UINT) def playlistid(context, tlid=None): """ *musicpd.org, current playlist section:* ``playlistid {SONGID}`` Displays a list of songs in the playlist. ``SONGID`` is optional and specifies a single song to display info for. """ if tlid is not None: tl_tracks = context.core.tracklist.filter({'tlid': [tlid]}).get() if not tl_tracks: raise exceptions.MpdNoExistError('No such song') position = context.core.tracklist.index(tl_tracks[0]).get() return translator.track_to_mpd_format(tl_tracks[0], position=position) else: return translator.tracks_to_mpd_format( context.core.tracklist.get_tl_tracks().get()) @protocol.commands.add('playlistinfo') def playlistinfo(context, parameter=None): """ *musicpd.org, current playlist section:* ``playlistinfo [[SONGPOS] | [START:END]]`` Displays a list of all songs in the playlist, or if the optional argument is given, displays information only for the song ``SONGPOS`` or the range of songs ``START:END``. *ncmpc and mpc:* - uses negative indexes, like ``playlistinfo "-1"``, to request the entire playlist """ if parameter is None or parameter == '-1': start, end = 0, None else: tracklist_slice = protocol.RANGE(parameter) start, end = tracklist_slice.start, tracklist_slice.stop tl_tracks = context.core.tracklist.get_tl_tracks().get() if start and start > len(tl_tracks): raise exceptions.MpdArgError('Bad song index') if end and end > len(tl_tracks): end = None return translator.tracks_to_mpd_format(tl_tracks, start, end) @protocol.commands.add('playlistsearch') def playlistsearch(context, tag, needle): """ *musicpd.org, current playlist section:* ``playlistsearch {TAG} {NEEDLE}`` Searches case-sensitively for partial matches in the current playlist. *GMPC:* - uses ``filename`` and ``any`` as tags """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('plchanges', version=protocol.INT) def plchanges(context, version): """ *musicpd.org, current playlist section:* ``plchanges {VERSION}`` Displays changed songs currently in the playlist since ``VERSION``. To detect songs that were deleted at the end of the playlist, use ``playlistlength`` returned by status command. *MPDroid:* - Calls ``plchanges "-1"`` two times per second to get the entire playlist. """ # XXX Naive implementation that returns all tracks as changed tracklist_version = context.core.tracklist.get_version().get() if version < tracklist_version: return translator.tracks_to_mpd_format( context.core.tracklist.get_tl_tracks().get()) elif version == tracklist_version: # A version match could indicate this is just a metadata update, so # check for a stream ref and let the client know about the change. stream_title = context.core.playback.get_stream_title().get() if stream_title is None: return None tl_track = context.core.playback.get_current_tl_track().get() position = context.core.tracklist.index(tl_track).get() return translator.track_to_mpd_format( tl_track, position=position, stream_title=stream_title) @protocol.commands.add('plchangesposid', version=protocol.INT) def plchangesposid(context, version): """ *musicpd.org, current playlist section:* ``plchangesposid {VERSION}`` Displays changed songs currently in the playlist since ``VERSION``. This function only returns the position and the id of the changed song, not the complete metadata. This is more bandwidth efficient. To detect songs that were deleted at the end of the playlist, use ``playlistlength`` returned by status command. """ # XXX Naive implementation that returns all tracks as changed if int(version) != context.core.tracklist.get_version().get(): result = [] for (position, (tlid, _)) in enumerate( context.core.tracklist.get_tl_tracks().get()): result.append(('cpos', position)) result.append(('Id', tlid)) return result @protocol.commands.add( 'prio', priority=protocol.UINT, position=protocol.RANGE) def prio(context, priority, position): """ *musicpd.org, current playlist section:* ``prio {PRIORITY} {START:END...}`` Set the priority of the specified songs. A higher priority means that it will be played first when "random" mode is enabled. A priority is an integer between 0 and 255. The default priority of new songs is 0. """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('prioid') def prioid(context, *args): """ *musicpd.org, current playlist section:* ``prioid {PRIORITY} {ID...}`` Same as prio, but address the songs with their id. """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('rangeid', tlid=protocol.UINT, songrange=protocol.RANGE) def rangeid(context, tlid, songrange): """ *musicpd.org, current playlist section:* ``rangeid {ID} {START:END}`` Specifies the portion of the song that shall be played. START and END are offsets in seconds (fractional seconds allowed); both are optional. Omitting both (i.e. sending just ":") means "remove the range, play everything". A song that is currently playing cannot be manipulated this way. .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('shuffle', songrange=protocol.RANGE) def shuffle(context, songrange=None): """ *musicpd.org, current playlist section:* ``shuffle [START:END]`` Shuffles the current playlist. ``START:END`` is optional and specifies a range of songs. """ if songrange is None: start, end = None, None else: start, end = songrange.start, songrange.stop context.core.tracklist.shuffle(start, end) @protocol.commands.add('swap', songpos1=protocol.UINT, songpos2=protocol.UINT) def swap(context, songpos1, songpos2): """ *musicpd.org, current playlist section:* ``swap {SONG1} {SONG2}`` Swaps the positions of ``SONG1`` and ``SONG2``. """ tracks = context.core.tracklist.get_tracks().get() song1 = tracks[songpos1] song2 = tracks[songpos2] del tracks[songpos1] tracks.insert(songpos1, song2) del tracks[songpos2] tracks.insert(songpos2, song1) # TODO: do we need a tracklist.replace() context.core.tracklist.clear() with deprecation.ignore('core.tracklist.add:tracks_arg'): context.core.tracklist.add(tracks=tracks).get() @protocol.commands.add('swapid', tlid1=protocol.UINT, tlid2=protocol.UINT) def swapid(context, tlid1, tlid2): """ *musicpd.org, current playlist section:* ``swapid {SONG1} {SONG2}`` Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids). """ tl_tracks1 = context.core.tracklist.filter({'tlid': [tlid1]}).get() tl_tracks2 = context.core.tracklist.filter({'tlid': [tlid2]}).get() if not tl_tracks1 or not tl_tracks2: raise exceptions.MpdNoExistError('No such song') position1 = context.core.tracklist.index(tl_tracks1[0]).get() position2 = context.core.tracklist.index(tl_tracks2[0]).get() swap(context, position1, position2) @protocol.commands.add('addtagid', tlid=protocol.UINT) def addtagid(context, tlid, tag, value): """ *musicpd.org, current playlist section:* ``addtagid {SONGID} {TAG} {VALUE}`` Adds a tag to the specified song. Editing song tags is only possible for remote songs. This change is volatile: it may be overwritten by tags received from the server, and the data is gone when the song gets removed from the queue. .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('cleartagid', tlid=protocol.UINT) def cleartagid(context, tlid, tag): """ *musicpd.org, current playlist section:* ``cleartagid {SONGID} [TAG]`` Removes tags from the specified song. If TAG is not specified, then all tag values will be removed. Editing song tags is only possible for remote songs. .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO Mopidy-2.0.0/mopidy/mpd/protocol/__init__.py0000664000175000017500000001432112575004517021222 0ustar jodaljodal00000000000000""" This is Mopidy's MPD protocol implementation. This is partly based upon the `MPD protocol documentation `_, which is a useful resource, but it is rather incomplete with regards to data formats, both for requests and responses. Thus, we have had to talk a great deal with the the original `MPD server `_ using telnet to get the details we need to implement our own MPD server which is compatible with the numerous existing `MPD clients `_. """ from __future__ import absolute_import, unicode_literals import inspect from mopidy.mpd import exceptions #: The MPD protocol uses UTF-8 for encoding all data. ENCODING = 'UTF-8' #: The MPD protocol uses ``\n`` as line terminator. LINE_TERMINATOR = '\n' #: The MPD protocol version is 0.19.0. VERSION = '0.19.0' def load_protocol_modules(): """ The protocol modules must be imported to get them registered in :attr:`commands`. """ from . import ( # noqa audio_output, channels, command_list, connection, current_playlist, mount, music_db, playback, reflection, status, stickers, stored_playlists) def INT(value): # noqa: N802 """Converts a value that matches [+-]?\d+ into and integer.""" if value is None: raise ValueError('None is not a valid integer') # TODO: check for whitespace via value != value.strip()? return int(value) def UINT(value): # noqa: N802 """Converts a value that matches \d+ into an integer.""" if value is None: raise ValueError('None is not a valid integer') if not value.isdigit(): raise ValueError('Only positive numbers are allowed') return int(value) def BOOL(value): # noqa: N802 """Convert the values 0 and 1 into booleans.""" if value in ('1', '0'): return bool(int(value)) raise ValueError('%r is not 0 or 1' % value) def RANGE(value): # noqa: N802 """Convert a single integer or range spec into a slice ``n`` should become ``slice(n, n+1)`` ``n:`` should become ``slice(n, None)`` ``n:m`` should become ``slice(n, m)`` and ``m > n`` must hold """ if ':' in value: start, stop = value.split(':', 1) start = UINT(start) if stop.strip(): stop = UINT(stop) if start >= stop: raise ValueError('End must be larger than start') else: stop = None else: start = UINT(value) stop = start + 1 return slice(start, stop) class Commands(object): """Collection of MPD commands to expose to users. Normally used through the global instance which command handlers have been installed into. """ def __init__(self): self.handlers = {} # TODO: consider removing auth_required and list_command in favour of # additional command instances to register in? def add(self, name, auth_required=True, list_command=True, **validators): """Create a decorator that registers a handler and validation rules. Additional keyword arguments are treated as converters/validators to apply to tokens converting them to proper Python types. Requirements for valid handlers: - must accept a context argument as the first arg. - may not use variable keyword arguments, ``**kwargs``. - may use variable arguments ``*args`` *or* a mix of required and optional arguments. Decorator returns the unwrapped function so that tests etc can use the functions with values with correct python types instead of strings. :param string name: Name of the command being registered. :param bool auth_required: If authorization is required. :param bool list_command: If command should be listed in reflection. """ def wrapper(func): if name in self.handlers: raise ValueError('%s already registered' % name) args, varargs, keywords, defaults = inspect.getargspec(func) defaults = dict(zip(args[-len(defaults or []):], defaults or [])) if not args and not varargs: raise TypeError('Handler must accept at least one argument.') if len(args) > 1 and varargs: raise TypeError( '*args may not be combined with regular arguments') if not set(validators.keys()).issubset(args): raise TypeError('Validator for non-existent arg passed') if keywords: raise TypeError('**kwargs are not permitted') def validate(*args, **kwargs): if varargs: return func(*args, **kwargs) try: callargs = inspect.getcallargs(func, *args, **kwargs) except TypeError: raise exceptions.MpdArgError( 'wrong number of arguments for "%s"' % name) for key, value in callargs.items(): default = defaults.get(key, object()) if key in validators and value != default: try: callargs[key] = validators[key](value) except ValueError: raise exceptions.MpdArgError('incorrect arguments') return func(**callargs) validate.auth_required = auth_required validate.list_command = list_command self.handlers[name] = validate return func return wrapper def call(self, tokens, context=None): """Find and run the handler registered for the given command. If the handler was registered with any converters/validators they will be run before calling the real handler. :param list tokens: List of tokens to process :param context: MPD context. :type context: :class:`~mopidy.mpd.dispatcher.MpdContext` """ if not tokens: raise exceptions.MpdNoCommand() if tokens[0] not in self.handlers: raise exceptions.MpdUnknownCommand(command=tokens[0]) return self.handlers[tokens[0]](context, *tokens[1:]) #: Global instance to install commands into commands = Commands() Mopidy-2.0.0/mopidy/mpd/protocol/tagtype_list.py0000664000175000017500000000063112575004517022172 0ustar jodaljodal00000000000000from __future__ import unicode_literals TAGTYPE_LIST = [ 'Artist', 'ArtistSort', 'Album', 'AlbumArtist', 'AlbumArtistSort', 'Title', 'Track', 'Name', 'Genre', 'Date', 'Composer', 'Performer', 'Disc', 'MUSICBRAINZ_ARTISTID', 'MUSICBRAINZ_ALBUMID', 'MUSICBRAINZ_ALBUMARTISTID', 'MUSICBRAINZ_TRACKID', 'X-AlbumUri', 'X-AlbumImage', ] Mopidy-2.0.0/mopidy/mpd/protocol/stored_playlists.py0000664000175000017500000003216612660436420023073 0ustar jodaljodal00000000000000from __future__ import absolute_import, division, unicode_literals import datetime import logging import re import warnings from mopidy.compat import urllib from mopidy.mpd import exceptions, protocol, translator logger = logging.getLogger(__name__) def _check_playlist_name(name): if re.search('[/\n\r]', name): raise exceptions.MpdInvalidPlaylistName() @protocol.commands.add('listplaylist') def listplaylist(context, name): """ *musicpd.org, stored playlists section:* ``listplaylist {NAME}`` Lists the files in the playlist ``NAME.m3u``. Output format:: file: relative/path/to/file1.flac file: relative/path/to/file2.ogg file: relative/path/to/file3.mp3 """ uri = context.lookup_playlist_uri_from_name(name) playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: raise exceptions.MpdNoExistError('No such playlist') return ['file: %s' % t.uri for t in playlist.tracks] @protocol.commands.add('listplaylistinfo') def listplaylistinfo(context, name): """ *musicpd.org, stored playlists section:* ``listplaylistinfo {NAME}`` Lists songs in the playlist ``NAME.m3u``. Output format: Standard track listing, with fields: file, Time, Title, Date, Album, Artist, Track """ uri = context.lookup_playlist_uri_from_name(name) playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: raise exceptions.MpdNoExistError('No such playlist') return translator.playlist_to_mpd_format(playlist) @protocol.commands.add('listplaylists') def listplaylists(context): """ *musicpd.org, stored playlists section:* ``listplaylists`` Prints a list of the playlist directory. After each playlist name the server sends its last modification time as attribute ``Last-Modified`` in ISO 8601 format. To avoid problems due to clock differences between clients and the server, clients should not compare this value with their local clock. Output format:: playlist: a Last-Modified: 2010-02-06T02:10:25Z playlist: b Last-Modified: 2010-02-06T02:11:08Z *Clarifications:* - ncmpcpp 0.5.10 segfaults if we return 'playlist: ' on a line, so we must ignore playlists without names, which isn't very useful anyway. """ last_modified = _get_last_modified() result = [] for playlist_ref in context.core.playlists.as_list().get(): if not playlist_ref.name: continue name = context.lookup_playlist_name_from_uri(playlist_ref.uri) result.append(('playlist', name)) result.append(('Last-Modified', last_modified)) return result # TODO: move to translators? def _get_last_modified(last_modified=None): """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 last_modified is None: # If unknown, assume the playlist is modified dt = datetime.datetime.utcnow() else: dt = datetime.datetime.utcfromtimestamp(last_modified / 1000.0) dt = dt.replace(microsecond=0) return '%sZ' % dt.isoformat() @protocol.commands.add('load', playlist_slice=protocol.RANGE) def load(context, name, playlist_slice=slice(0, None)): """ *musicpd.org, stored playlists section:* ``load {NAME} [START:END]`` Loads the playlist into the current queue. Playlist plugins are supported. A range may be specified to load only a part of the playlist. *Clarifications:* - ``load`` appends the given playlist to the current playlist. - MPD 0.17.1 does not support open-ended ranges, i.e. without end specified, for the ``load`` command, even though MPD's general range docs allows open-ended ranges. - MPD 0.17.1 does not fail if the specified range is outside the playlist, in either or both ends. """ uri = context.lookup_playlist_uri_from_name(name) playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: raise exceptions.MpdNoExistError('No such playlist') with warnings.catch_warnings(): warnings.filterwarnings('ignore', 'tracklist.add.*"tracks".*') context.core.tracklist.add(playlist.tracks[playlist_slice]).get() @protocol.commands.add('playlistadd') def playlistadd(context, name, track_uri): """ *musicpd.org, stored playlists section:* ``playlistadd {NAME} {URI}`` Adds ``URI`` to the playlist ``NAME.m3u``. ``NAME.m3u`` will be created if it does not exist. """ _check_playlist_name(name) uri = context.lookup_playlist_uri_from_name(name) old_playlist = uri is not None and context.core.playlists.lookup(uri).get() if not old_playlist: # Create new playlist with this single track lookup_res = context.core.library.lookup(uris=[track_uri]).get() tracks = [ track for uri_tracks in lookup_res.values() for track in uri_tracks] _create_playlist(context, name, tracks) else: # Add track to existing playlist lookup_res = context.core.library.lookup(uris=[track_uri]).get() new_tracks = [ track for uri_tracks in lookup_res.values() for track in uri_tracks] new_playlist = old_playlist.replace( tracks=list(old_playlist.tracks) + new_tracks) saved_playlist = context.core.playlists.save(new_playlist).get() if saved_playlist is None: playlist_scheme = urllib.parse.urlparse(old_playlist.uri).scheme uri_scheme = urllib.parse.urlparse(track_uri).scheme raise exceptions.MpdInvalidTrackForPlaylist( playlist_scheme, uri_scheme) def _create_playlist(context, name, tracks): """ Creates new playlist using backend appropriate for the given tracks """ uri_schemes = set([urllib.parse.urlparse(t.uri).scheme for t in tracks]) for scheme in uri_schemes: new_playlist = context.core.playlists.create(name, scheme).get() if new_playlist is None: logger.debug( "Backend for scheme %s can't create playlists", scheme) continue # Backend can't create playlists at all new_playlist = new_playlist.replace(tracks=tracks) saved_playlist = context.core.playlists.save(new_playlist).get() if saved_playlist is not None: return # Created and saved else: continue # Failed to save using this backend # Can't use backend appropriate for passed URI schemes, use default one default_scheme = context.dispatcher.config[ 'mpd']['default_playlist_scheme'] new_playlist = context.core.playlists.create(name, default_scheme).get() if new_playlist is None: # If even MPD's default backend can't save playlist, everything is lost logger.warning("MPD's default backend can't create playlists") raise exceptions.MpdFailedToSavePlaylist(default_scheme) new_playlist = new_playlist.replace(tracks=tracks) saved_playlist = context.core.playlists.save(new_playlist).get() if saved_playlist is None: uri_scheme = urllib.parse.urlparse(new_playlist.uri).scheme raise exceptions.MpdFailedToSavePlaylist(uri_scheme) @protocol.commands.add('playlistclear') def playlistclear(context, name): """ *musicpd.org, stored playlists section:* ``playlistclear {NAME}`` Clears the playlist ``NAME.m3u``. The playlist will be created if it does not exist. """ _check_playlist_name(name) uri = context.lookup_playlist_uri_from_name(name) playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: playlist = context.core.playlists.create(name).get() # Just replace tracks with empty list and save playlist = playlist.replace(tracks=[]) if context.core.playlists.save(playlist).get() is None: raise exceptions.MpdFailedToSavePlaylist( urllib.parse.urlparse(uri).scheme) @protocol.commands.add('playlistdelete', songpos=protocol.UINT) def playlistdelete(context, name, songpos): """ *musicpd.org, stored playlists section:* ``playlistdelete {NAME} {SONGPOS}`` Deletes ``SONGPOS`` from the playlist ``NAME.m3u``. """ _check_playlist_name(name) uri = context.lookup_playlist_uri_from_name(name) playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: raise exceptions.MpdNoExistError('No such playlist') try: # Convert tracks to list and remove requested tracks = list(playlist.tracks) tracks.pop(songpos) except IndexError: raise exceptions.MpdArgError('Bad song index') # Replace tracks and save playlist playlist = playlist.replace(tracks=tracks) saved_playlist = context.core.playlists.save(playlist).get() if saved_playlist is None: raise exceptions.MpdFailedToSavePlaylist( urllib.parse.urlparse(uri).scheme) @protocol.commands.add( 'playlistmove', from_pos=protocol.UINT, to_pos=protocol.UINT) def playlistmove(context, name, from_pos, to_pos): """ *musicpd.org, stored playlists section:* ``playlistmove {NAME} {SONGID} {SONGPOS}`` Moves ``SONGID`` in the playlist ``NAME.m3u`` to the position ``SONGPOS``. *Clarifications:* - The second argument is not a ``SONGID`` as used elsewhere in the protocol documentation, but just the ``SONGPOS`` to move *from*, i.e. ``playlistmove {NAME} {FROM_SONGPOS} {TO_SONGPOS}``. """ if from_pos == to_pos: return _check_playlist_name(name) uri = context.lookup_playlist_uri_from_name(name) playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: raise exceptions.MpdNoExistError('No such playlist') if from_pos == to_pos: return # Nothing to do try: # Convert tracks to list and perform move tracks = list(playlist.tracks) track = tracks.pop(from_pos) tracks.insert(to_pos, track) except IndexError: raise exceptions.MpdArgError('Bad song index') # Replace tracks and save playlist playlist = playlist.replace(tracks=tracks) saved_playlist = context.core.playlists.save(playlist).get() if saved_playlist is None: raise exceptions.MpdFailedToSavePlaylist( urllib.parse.urlparse(uri).scheme) @protocol.commands.add('rename') def rename(context, old_name, new_name): """ *musicpd.org, stored playlists section:* ``rename {NAME} {NEW_NAME}`` Renames the playlist ``NAME.m3u`` to ``NEW_NAME.m3u``. """ _check_playlist_name(old_name) _check_playlist_name(new_name) old_uri = context.lookup_playlist_uri_from_name(old_name) if not old_uri: raise exceptions.MpdNoExistError('No such playlist') old_playlist = context.core.playlists.lookup(old_uri).get() if not old_playlist: raise exceptions.MpdNoExistError('No such playlist') new_uri = context.lookup_playlist_uri_from_name(new_name) if new_uri and context.core.playlists.lookup(new_uri).get(): raise exceptions.MpdExistError('Playlist already exists') # TODO: should we purge the mapping in an else? # Create copy of the playlist and remove original uri_scheme = urllib.parse.urlparse(old_uri).scheme new_playlist = context.core.playlists.create(new_name, uri_scheme).get() new_playlist = new_playlist.replace(tracks=old_playlist.tracks) saved_playlist = context.core.playlists.save(new_playlist).get() if saved_playlist is None: raise exceptions.MpdFailedToSavePlaylist(uri_scheme) context.core.playlists.delete(old_playlist.uri).get() @protocol.commands.add('rm') def rm(context, name): """ *musicpd.org, stored playlists section:* ``rm {NAME}`` Removes the playlist ``NAME.m3u`` from the playlist directory. """ _check_playlist_name(name) uri = context.lookup_playlist_uri_from_name(name) if not uri: raise exceptions.MpdNoExistError('No such playlist') context.core.playlists.delete(uri).get() @protocol.commands.add('save') def save(context, name): """ *musicpd.org, stored playlists section:* ``save {NAME}`` Saves the current playlist to ``NAME.m3u`` in the playlist directory. """ _check_playlist_name(name) tracks = context.core.tracklist.get_tracks().get() uri = context.lookup_playlist_uri_from_name(name) playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: # Create new playlist _create_playlist(context, name, tracks) else: # Overwrite existing playlist new_playlist = playlist.replace(tracks=tracks) saved_playlist = context.core.playlists.save(new_playlist).get() if saved_playlist is None: raise exceptions.MpdFailedToSavePlaylist( urllib.parse.urlparse(uri).scheme) Mopidy-2.0.0/mopidy/mpd/protocol/mount.py0000664000175000017500000000367112575004517020633 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mopidy.mpd import exceptions, protocol @protocol.commands.add('mount') def mount(context, path, uri): """ *musicpd.org, mounts and neighbors section:* ``mount {PATH} {URI}`` Mount the specified remote storage URI at the given path. Example:: mount foo nfs://192.168.1.4/export/mp3 .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('unmount') def unmount(context, path): """ *musicpd.org, mounts and neighbors section:* ``unmount {PATH}`` Unmounts the specified path. Example:: unmount foo .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('listmounts') def listmounts(context): """ *musicpd.org, mounts and neighbors section:* ``listmounts`` Queries a list of all mounts. By default, this contains just the configured music_directory. Example:: listmounts mount: storage: /home/foo/music mount: foo storage: nfs://192.168.1.4/export/mp3 OK .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('listneighbors') def listneighbors(context): """ *musicpd.org, mounts and neighbors section:* ``listneighbors`` Queries a list of "neighbors" (e.g. accessible file servers on the local net). Items on that list may be used with the mount command. Example:: listneighbors neighbor: smb://FOO name: FOO (Samba 4.1.11-Debian) OK .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO Mopidy-2.0.0/mopidy/mpd/protocol/connection.py0000664000175000017500000000224212505224626021617 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mopidy.mpd import exceptions, protocol @protocol.commands.add('close', auth_required=False) def close(context): """ *musicpd.org, connection section:* ``close`` Closes the connection to MPD. """ context.session.close() @protocol.commands.add('kill', list_command=False) def kill(context): """ *musicpd.org, connection section:* ``kill`` Kills MPD. """ raise exceptions.MpdPermissionError(command='kill') @protocol.commands.add('password', auth_required=False) def password(context, password): """ *musicpd.org, connection section:* ``password {PASSWORD}`` This is used for authentication with the server. ``PASSWORD`` is simply the plaintext password. """ if password == context.password: context.dispatcher.authenticated = True else: raise exceptions.MpdPasswordError('incorrect password') @protocol.commands.add('ping', auth_required=False) def ping(context): """ *musicpd.org, connection section:* ``ping`` Does nothing but return ``OK``. """ pass Mopidy-2.0.0/mopidy/mpd/protocol/channels.py0000664000175000017500000000336712505224626021264 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mopidy.mpd import exceptions, protocol @protocol.commands.add('subscribe') def subscribe(context, channel): """ *musicpd.org, client to client section:* ``subscribe {NAME}`` Subscribe to a channel. The channel is created if it does not exist already. The name may consist of alphanumeric ASCII characters plus underscore, dash, dot and colon. """ # TODO: match channel against [A-Za-z0-9:._-]+ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('unsubscribe') def unsubscribe(context, channel): """ *musicpd.org, client to client section:* ``unsubscribe {NAME}`` Unsubscribe from a channel. """ # TODO: match channel against [A-Za-z0-9:._-]+ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('channels') def channels(context): """ *musicpd.org, client to client section:* ``channels`` Obtain a list of all channels. The response is a list of "channel:" lines. """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('readmessages') def readmessages(context): """ *musicpd.org, client to client section:* ``readmessages`` Reads messages for this client. The response is a list of "channel:" and "message:" lines. """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('sendmessage') def sendmessage(context, channel, text): """ *musicpd.org, client to client section:* ``sendmessage {CHANNEL} {TEXT}`` Send a message to the specified channel. """ # TODO: match channel against [A-Za-z0-9:._-]+ raise exceptions.MpdNotImplemented # TODO Mopidy-2.0.0/mopidy/mpd/protocol/music_db.py0000664000175000017500000004076512575004517021263 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import functools import itertools from mopidy.internal import deprecation from mopidy.models import Track from mopidy.mpd import exceptions, protocol, translator _SEARCH_MAPPING = { 'album': 'album', 'albumartist': 'albumartist', 'any': 'any', 'artist': 'artist', 'comment': 'comment', 'composer': 'composer', 'date': 'date', 'file': 'uri', 'filename': 'uri', 'genre': 'genre', 'performer': 'performer', 'title': 'track_name', 'track': 'track_no'} _LIST_MAPPING = { 'title': 'track', 'album': 'album', 'albumartist': 'albumartist', 'artist': 'artist', 'composer': 'composer', 'date': 'date', 'genre': 'genre', 'performer': 'performer'} _LIST_NAME_MAPPING = { 'track': 'Title', 'album': 'Album', 'albumartist': 'AlbumArtist', 'artist': 'Artist', 'composer': 'Composer', 'date': 'Date', 'genre': 'Genre', 'performer': 'Performer'} def _query_from_mpd_search_parameters(parameters, mapping): query = {} parameters = list(parameters) while parameters: # TODO: does it matter that this is now case insensitive field = mapping.get(parameters.pop(0).lower()) if not field: raise exceptions.MpdArgError('incorrect arguments') if not parameters: raise ValueError value = parameters.pop(0) if value.strip(): query.setdefault(field, []).append(value) return query def _get_field(field, search_results): return list(itertools.chain(*[getattr(r, field) for r in search_results])) _get_albums = functools.partial(_get_field, 'albums') _get_artists = functools.partial(_get_field, 'artists') _get_tracks = functools.partial(_get_field, 'tracks') def _album_as_track(album): return Track( uri=album.uri, name='Album: ' + album.name, artists=album.artists, album=album, date=album.date) def _artist_as_track(artist): return Track( uri=artist.uri, name='Artist: ' + artist.name, artists=[artist]) @protocol.commands.add('count') def count(context, *args): """ *musicpd.org, music database section:* ``count {TAG} {NEEDLE}`` Counts the number of songs and their total playtime in the db matching ``TAG`` exactly. *GMPC:* - use multiple tag-needle pairs to make more specific searches. """ try: query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: raise exceptions.MpdArgError('incorrect arguments') results = context.core.library.search(query=query, exact=True).get() result_tracks = _get_tracks(results) return [ ('songs', len(result_tracks)), ('playtime', sum(t.length for t in result_tracks if t.length) / 1000), ] @protocol.commands.add('find') def find(context, *args): """ *musicpd.org, music database section:* ``find {TYPE} {WHAT}`` Finds songs in the db that are exactly ``WHAT``. ``TYPE`` can be any tag supported by MPD, or one of the two special parameters - ``file`` to search by full path (relative to database root), and ``any`` to match against all available tags. ``WHAT`` is what to find. *GMPC:* - also uses ``find album "[ALBUM]" artist "[ARTIST]"`` to list album tracks. *ncmpc:* - capitalizes the type argument. *ncmpcpp:* - also uses the search type "date". - uses "file" instead of "filename". """ try: query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return with deprecation.ignore('core.library.search:empty_query'): results = context.core.library.search(query=query, exact=True).get() result_tracks = [] if ('artist' not in query and 'albumartist' not in query and 'composer' not in query and 'performer' not in query): result_tracks += [_artist_as_track(a) for a in _get_artists(results)] if 'album' not in query: result_tracks += [_album_as_track(a) for a in _get_albums(results)] result_tracks += _get_tracks(results) return translator.tracks_to_mpd_format(result_tracks) @protocol.commands.add('findadd') def findadd(context, *args): """ *musicpd.org, music database section:* ``findadd {TYPE} {WHAT}`` Finds songs in the db that are exactly ``WHAT`` and adds them to current playlist. Parameters have the same meaning as for ``find``. """ try: query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return results = context.core.library.search(query=query, exact=True).get() with deprecation.ignore('core.tracklist.add:tracks_arg'): # TODO: for now just use tracks as other wise we have to lookup the # tracks we just got from the search. context.core.tracklist.add(tracks=_get_tracks(results)).get() @protocol.commands.add('list') def list_(context, *args): """ *musicpd.org, music database section:* ``list {TYPE} [ARTIST]`` Lists all tags of the specified type. ``TYPE`` should be ``album``, ``artist``, ``albumartist``, ``date``, or ``genre``. ``ARTIST`` is an optional parameter when type is ``album``, ``date``, or ``genre``. This filters the result list by an artist. *Clarifications:* The musicpd.org documentation for ``list`` is far from complete. The command also supports the following variant: ``list {TYPE} {QUERY}`` Where ``QUERY`` applies to all ``TYPE``. ``QUERY`` is one or more pairs of a field name and a value. If the ``QUERY`` consists of more than one pair, the pairs are AND-ed together to find the result. Examples of valid queries and what they should return: ``list "artist" "artist" "ABBA"`` List artists where the artist name is "ABBA". Response:: Artist: ABBA OK ``list "album" "artist" "ABBA"`` Lists albums where the artist name is "ABBA". Response:: Album: More ABBA Gold: More ABBA Hits Album: Absolute More Christmas Album: Gold: Greatest Hits OK ``list "artist" "album" "Gold: Greatest Hits"`` Lists artists where the album name is "Gold: Greatest Hits". Response:: Artist: ABBA OK ``list "artist" "artist" "ABBA" "artist" "TLC"`` Lists artists where the artist name is "ABBA" *and* "TLC". Should never match anything. Response:: OK ``list "date" "artist" "ABBA"`` Lists dates where artist name is "ABBA". Response:: Date: Date: 1992 Date: 1993 OK ``list "date" "artist" "ABBA" "album" "Gold: Greatest Hits"`` Lists dates where artist name is "ABBA" and album name is "Gold: Greatest Hits". Response:: Date: 1992 OK ``list "genre" "artist" "The Rolling Stones"`` Lists genres where artist name is "The Rolling Stones". Response:: Genre: Genre: Rock OK *ncmpc:* - capitalizes the field argument. """ params = list(args) if not params: raise exceptions.MpdArgError('incorrect arguments') field = params.pop(0).lower() field = _LIST_MAPPING.get(field) if field is None: raise exceptions.MpdArgError('incorrect arguments') query = None if len(params) == 1: if field != 'album': raise exceptions.MpdArgError('should be "Album" for 3 arguments') if params[0].strip(): query = {'artist': params} else: try: query = _query_from_mpd_search_parameters(params, _LIST_MAPPING) except exceptions.MpdArgError as e: e.message = 'not able to parse args' raise except ValueError: return name = _LIST_NAME_MAPPING[field] result = context.core.library.get_distinct(field, query) return [(name, value) for value in result.get()] @protocol.commands.add('listall') def listall(context, uri=None): """ *musicpd.org, music database section:* ``listall [URI]`` Lists all songs and directories in ``URI``. Do not use this command. Do not manage a client-side copy of MPD's database. That is fragile and adds huge overhead. It will break with large databases. Instead, query MPD whenever you need something. .. warning:: This command is disabled by default in Mopidy installs. """ result = [] for path, track_ref in context.browse(uri, lookup=False): if not track_ref: result.append(('directory', path)) else: result.append(('file', track_ref.uri)) if not result: raise exceptions.MpdNoExistError('Not found') return result @protocol.commands.add('listallinfo') def listallinfo(context, uri=None): """ *musicpd.org, music database section:* ``listallinfo [URI]`` Same as ``listall``, except it also returns metadata info in the same format as ``lsinfo``. Do not use this command. Do not manage a client-side copy of MPD's database. That is fragile and adds huge overhead. It will break with large databases. Instead, query MPD whenever you need something. .. warning:: This command is disabled by default in Mopidy installs. """ result = [] for path, lookup_future in context.browse(uri): if not lookup_future: result.append(('directory', path)) else: for tracks in lookup_future.get().values(): for track in tracks: result.extend(translator.track_to_mpd_format(track)) return result @protocol.commands.add('listfiles') def listfiles(context, uri=None): """ *musicpd.org, music database section:* ``listfiles [URI]`` Lists the contents of the directory URI, including files are not recognized by MPD. URI can be a path relative to the music directory or an URI understood by one of the storage plugins. The response contains at least one line for each directory entry with the prefix "file: " or "directory: ", and may be followed by file attributes such as "Last-Modified" and "size". For example, "smb://SERVER" returns a list of all shares on the given SMB/CIFS server; "nfs://servername/path" obtains a directory listing from the NFS server. .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('lsinfo') def lsinfo(context, uri=None): """ *musicpd.org, music database section:* ``lsinfo [URI]`` Lists the contents of the directory ``URI``. When listing the root directory, this currently returns the list of stored playlists. This behavior is deprecated; use ``listplaylists`` instead. MPD returns the same result, including both playlists and the files and directories located at the root level, for both ``lsinfo``, ``lsinfo ""``, and ``lsinfo "/"``. """ result = [] for path, lookup_future in context.browse(uri, recursive=False): if not lookup_future: result.append(('directory', path.lstrip('/'))) else: for tracks in lookup_future.get().values(): if tracks: result.extend(translator.track_to_mpd_format(tracks[0])) if uri in (None, '', '/'): result.extend(protocol.stored_playlists.listplaylists(context)) return result @protocol.commands.add('rescan') def rescan(context, uri=None): """ *musicpd.org, music database section:* ``rescan [URI]`` Same as ``update``, but also rescans unmodified files. """ return {'updating_db': 0} # TODO @protocol.commands.add('search') def search(context, *args): """ *musicpd.org, music database section:* ``search {TYPE} {WHAT} [...]`` Searches for any song that contains ``WHAT``. Parameters have the same meaning as for ``find``, except that search is not case sensitive. *GMPC:* - uses the undocumented field ``any``. - searches for multiple words like this:: search any "foo" any "bar" any "baz" *ncmpc:* - capitalizes the field argument. *ncmpcpp:* - also uses the search type "date". - uses "file" instead of "filename". """ try: query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return with deprecation.ignore('core.library.search:empty_query'): results = context.core.library.search(query).get() artists = [_artist_as_track(a) for a in _get_artists(results)] albums = [_album_as_track(a) for a in _get_albums(results)] tracks = _get_tracks(results) return translator.tracks_to_mpd_format(artists + albums + tracks) @protocol.commands.add('searchadd') def searchadd(context, *args): """ *musicpd.org, music database section:* ``searchadd {TYPE} {WHAT} [...]`` Searches for any song that contains ``WHAT`` in tag ``TYPE`` and adds them to current playlist. Parameters have the same meaning as for ``find``, except that search is not case sensitive. """ try: query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return results = context.core.library.search(query).get() with deprecation.ignore('core.tracklist.add:tracks_arg'): # TODO: for now just use tracks as other wise we have to lookup the # tracks we just got from the search. context.core.tracklist.add(_get_tracks(results)).get() @protocol.commands.add('searchaddpl') def searchaddpl(context, *args): """ *musicpd.org, music database section:* ``searchaddpl {NAME} {TYPE} {WHAT} [...]`` Searches for any song that contains ``WHAT`` in tag ``TYPE`` and adds them to the playlist named ``NAME``. If a playlist by that name doesn't exist it is created. Parameters have the same meaning as for ``find``, except that search is not case sensitive. """ parameters = list(args) if not parameters: raise exceptions.MpdArgError('incorrect arguments') playlist_name = parameters.pop(0) try: query = _query_from_mpd_search_parameters(parameters, _SEARCH_MAPPING) except ValueError: return results = context.core.library.search(query).get() uri = context.lookup_playlist_uri_from_name(playlist_name) playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: playlist = context.core.playlists.create(playlist_name).get() tracks = list(playlist.tracks) + _get_tracks(results) playlist = playlist.replace(tracks=tracks) context.core.playlists.save(playlist) @protocol.commands.add('update') def update(context, uri=None): """ *musicpd.org, music database section:* ``update [URI]`` Updates the music database: find new files, remove deleted files, update modified files. ``URI`` is a particular directory or song/file to update. If you do not specify it, everything is updated. Prints ``updating_db: JOBID`` where ``JOBID`` is a positive number identifying the update job. You can read the current job id in the ``status`` response. """ return {'updating_db': 0} # TODO # TODO: add at least reflection tests before adding NotImplemented version # @protocol.commands.add('readcomments') def readcomments(context, uri): """ *musicpd.org, music database section:* ``readcomments [URI]`` Read "comments" (i.e. key-value pairs) from the file specified by "URI". This "URI" can be a path relative to the music directory or a URL in the form "file:///foo/bar.ogg". This command may be used to list metadata of remote files (e.g. URI beginning with "http://" or "smb://"). The response consists of lines in the form "KEY: VALUE". Comments with suspicious characters (e.g. newlines) are ignored silently. The meaning of these depends on the codec, and not all decoder plugins support it. For example, on Ogg files, this lists the Vorbis comments. """ pass Mopidy-2.0.0/mopidy/mpd/protocol/stickers.py0000664000175000017500000000236012505224626021310 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mopidy.mpd import exceptions, protocol @protocol.commands.add('sticker', list_command=False) def sticker(context, action, field, uri, name=None, value=None): """ *musicpd.org, sticker section:* ``sticker list {TYPE} {URI}`` Lists the stickers for the specified object. ``sticker find {TYPE} {URI} {NAME}`` Searches the sticker database for stickers with the specified name, below the specified directory (``URI``). For each matching song, it prints the ``URI`` and that one sticker's value. ``sticker get {TYPE} {URI} {NAME}`` Reads a sticker value for the specified object. ``sticker set {TYPE} {URI} {NAME} {VALUE}`` Adds a sticker value to the specified object. If a sticker item with that name already exists, it is replaced. ``sticker delete {TYPE} {URI} [NAME]`` Deletes a sticker value from the specified object. If you do not specify a sticker name, all sticker values are deleted. """ # TODO: check that action in ('list', 'find', 'get', 'set', 'delete') # TODO: check name/value matches with action raise exceptions.MpdNotImplemented # TODO Mopidy-2.0.0/mopidy/mpd/protocol/status.py0000664000175000017500000002275612575004517021021 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import pykka from mopidy.core import PlaybackState from mopidy.mpd import exceptions, protocol, translator #: Subsystems that can be registered with idle command. SUBSYSTEMS = [ 'database', 'mixer', 'options', 'output', 'player', 'playlist', 'stored_playlist', 'update'] @protocol.commands.add('clearerror') def clearerror(context): """ *musicpd.org, status section:* ``clearerror`` Clears the current error message in status (this is also accomplished by any command that starts playback). """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('currentsong') def currentsong(context): """ *musicpd.org, status section:* ``currentsong`` Displays the song info of the current song (same song that is identified in status). """ tl_track = context.core.playback.get_current_tl_track().get() stream_title = context.core.playback.get_stream_title().get() if tl_track is not None: position = context.core.tracklist.index(tl_track).get() return translator.track_to_mpd_format( tl_track, position=position, stream_title=stream_title) @protocol.commands.add('idle', list_command=False) def idle(context, *subsystems): """ *musicpd.org, status section:* ``idle [SUBSYSTEMS...]`` Waits until there is a noteworthy change in one or more of MPD's subsystems. As soon as there is one, it lists all changed systems in a line in the format ``changed: SUBSYSTEM``, where ``SUBSYSTEM`` is one of the following: - ``database``: the song database has been modified after update. - ``update``: a database update has started or finished. If the database was modified during the update, the database event is also emitted. - ``stored_playlist``: a stored playlist has been modified, renamed, created or deleted - ``playlist``: the current playlist has been modified - ``player``: the player has been started, stopped or seeked - ``mixer``: the volume has been changed - ``output``: an audio output has been enabled or disabled - ``options``: options like repeat, random, crossfade, replay gain While a client is waiting for idle results, the server disables timeouts, allowing a client to wait for events as long as MPD runs. The idle command can be canceled by sending the command ``noidle`` (no other commands are allowed). MPD will then leave idle mode and print results immediately; might be empty at this time. If the optional ``SUBSYSTEMS`` argument is used, MPD will only send notifications when something changed in one of the specified subsystems. """ # TODO: test against valid subsystems if not subsystems: subsystems = SUBSYSTEMS for subsystem in subsystems: context.subscriptions.add(subsystem) active = context.subscriptions.intersection(context.events) if not active: context.session.prevent_timeout = True return response = [] context.events = set() context.subscriptions = set() for subsystem in active: response.append('changed: %s' % subsystem) return response @protocol.commands.add('noidle', list_command=False) def noidle(context): """See :meth:`_status_idle`.""" if not context.subscriptions: return context.subscriptions = set() context.events = set() context.session.prevent_timeout = False @protocol.commands.add('stats') def stats(context): """ *musicpd.org, status section:* ``stats`` Displays statistics. - ``artists``: number of artists - ``songs``: number of albums - ``uptime``: daemon uptime in seconds - ``db_playtime``: sum of all song times in the db - ``db_update``: last db update in UNIX time - ``playtime``: time length of music played """ return { 'artists': 0, # TODO 'albums': 0, # TODO 'songs': 0, # TODO 'uptime': 0, # TODO 'db_playtime': 0, # TODO 'db_update': 0, # TODO 'playtime': 0, # TODO } @protocol.commands.add('status') def status(context): """ *musicpd.org, status section:* ``status`` Reports the current status of the player and the volume level. - ``volume``: 0-100 or -1 - ``repeat``: 0 or 1 - ``single``: 0 or 1 - ``consume``: 0 or 1 - ``playlist``: 31-bit unsigned integer, the playlist version number - ``playlistlength``: integer, the length of the playlist - ``state``: play, stop, or pause - ``song``: playlist song number of the current song stopped on or playing - ``songid``: playlist songid of the current song stopped on or playing - ``nextsong``: playlist song number of the next song to be played - ``nextsongid``: playlist songid of the next song to be played - ``time``: total time elapsed (of current playing/paused song) - ``elapsed``: Total time elapsed within the current song, but with higher resolution. - ``bitrate``: instantaneous bitrate in kbps - ``xfade``: crossfade in seconds - ``audio``: sampleRate``:bits``:channels - ``updatings_db``: job id - ``error``: if there is an error, returns message here *Clarifications based on experience implementing* - ``volume``: can also be -1 if no output is set. - ``elapsed``: Higher resolution means time in seconds with three decimal places for millisecond precision. """ tl_track = context.core.playback.get_current_tl_track() futures = { 'tracklist.length': context.core.tracklist.get_length(), 'tracklist.version': context.core.tracklist.get_version(), 'mixer.volume': context.core.mixer.get_volume(), 'tracklist.consume': context.core.tracklist.get_consume(), 'tracklist.random': context.core.tracklist.get_random(), 'tracklist.repeat': context.core.tracklist.get_repeat(), 'tracklist.single': context.core.tracklist.get_single(), 'playback.state': context.core.playback.get_state(), 'playback.current_tl_track': tl_track, 'tracklist.index': context.core.tracklist.index(tl_track.get()), 'playback.time_position': context.core.playback.get_time_position(), } pykka.get_all(futures.values()) result = [ ('volume', _status_volume(futures)), ('repeat', _status_repeat(futures)), ('random', _status_random(futures)), ('single', _status_single(futures)), ('consume', _status_consume(futures)), ('playlist', _status_playlist_version(futures)), ('playlistlength', _status_playlist_length(futures)), ('xfade', _status_xfade(futures)), ('state', _status_state(futures)), ] # TODO: add nextsong and nextsongid if futures['playback.current_tl_track'].get() is not None: result.append(('song', _status_songpos(futures))) result.append(('songid', _status_songid(futures))) if futures['playback.state'].get() in ( PlaybackState.PLAYING, PlaybackState.PAUSED): result.append(('time', _status_time(futures))) result.append(('elapsed', _status_time_elapsed(futures))) result.append(('bitrate', _status_bitrate(futures))) return result def _status_bitrate(futures): current_tl_track = futures['playback.current_tl_track'].get() if current_tl_track is None: return 0 if current_tl_track.track.bitrate is None: return 0 return current_tl_track.track.bitrate def _status_consume(futures): if futures['tracklist.consume'].get(): return 1 else: return 0 def _status_playlist_length(futures): return futures['tracklist.length'].get() def _status_playlist_version(futures): return futures['tracklist.version'].get() def _status_random(futures): return int(futures['tracklist.random'].get()) def _status_repeat(futures): return int(futures['tracklist.repeat'].get()) def _status_single(futures): return int(futures['tracklist.single'].get()) def _status_songid(futures): current_tl_track = futures['playback.current_tl_track'].get() if current_tl_track is not None: return current_tl_track.tlid else: return _status_songpos(futures) def _status_songpos(futures): return futures['tracklist.index'].get() def _status_state(futures): state = futures['playback.state'].get() if state == PlaybackState.PLAYING: return 'play' elif state == PlaybackState.STOPPED: return 'stop' elif state == PlaybackState.PAUSED: return 'pause' def _status_time(futures): return '%d:%d' % ( futures['playback.time_position'].get() // 1000, _status_time_total(futures) // 1000) def _status_time_elapsed(futures): return '%.3f' % (futures['playback.time_position'].get() / 1000.0) def _status_time_total(futures): current_tl_track = futures['playback.current_tl_track'].get() if current_tl_track is None: return 0 elif current_tl_track.track.length is None: return 0 else: return current_tl_track.track.length def _status_volume(futures): volume = futures['mixer.volume'].get() if volume is not None: return volume else: return -1 def _status_xfade(futures): return 0 # Not supported Mopidy-2.0.0/mopidy/mpd/protocol/command_list.py0000664000175000017500000000463112505224626022135 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mopidy.mpd import exceptions, protocol @protocol.commands.add('command_list_begin', list_command=False) def command_list_begin(context): """ *musicpd.org, command list section:* To facilitate faster adding of files etc. you can pass a list of commands all at once using a command list. The command list begins with ``command_list_begin`` or ``command_list_ok_begin`` and ends with ``command_list_end``. It does not execute any commands until the list has ended. The return value is whatever the return for a list of commands is. On success for all commands, ``OK`` is returned. If a command fails, no more commands are executed and the appropriate ``ACK`` error is returned. If ``command_list_ok_begin`` is used, ``list_OK`` is returned for each successful command executed in the command list. """ context.dispatcher.command_list_receiving = True context.dispatcher.command_list_ok = False context.dispatcher.command_list = [] @protocol.commands.add('command_list_end', list_command=False) def command_list_end(context): """See :meth:`command_list_begin()`.""" # TODO: batch consecutive add commands if not context.dispatcher.command_list_receiving: raise exceptions.MpdUnknownCommand(command='command_list_end') context.dispatcher.command_list_receiving = False (command_list, context.dispatcher.command_list) = ( context.dispatcher.command_list, []) (command_list_ok, context.dispatcher.command_list_ok) = ( context.dispatcher.command_list_ok, False) command_list_response = [] for index, command in enumerate(command_list): response = context.dispatcher.handle_request( command, current_command_list_index=index) command_list_response.extend(response) if (command_list_response and command_list_response[-1].startswith('ACK')): return command_list_response if command_list_ok: command_list_response.append('list_OK') return command_list_response @protocol.commands.add('command_list_ok_begin', list_command=False) def command_list_ok_begin(context): """See :meth:`command_list_begin()`.""" context.dispatcher.command_list_receiving = True context.dispatcher.command_list_ok = True context.dispatcher.command_list = [] Mopidy-2.0.0/mopidy/__main__.py0000664000175000017500000001734012660436420016563 0ustar jodaljodal00000000000000from __future__ import absolute_import, print_function, unicode_literals import logging import os import signal import sys from mopidy.internal.gi import Gst # noqa: Import to initialize try: # Make GObject's mainloop the event loop for python-dbus import dbus.mainloop.glib dbus.mainloop.glib.threads_init() dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) except ImportError: pass import pykka.debug from mopidy import commands, config as config_lib, ext from mopidy.internal import encoding, log, path, process, versioning logger = logging.getLogger(__name__) def main(): log.bootstrap_delayed_logging() logger.info('Starting Mopidy %s', versioning.get_version()) signal.signal(signal.SIGTERM, process.sigterm_handler) # Windows does not have signal.SIGUSR1 if hasattr(signal, 'SIGUSR1'): signal.signal(signal.SIGUSR1, pykka.debug.log_thread_tracebacks) try: registry = ext.Registry() root_cmd = commands.RootCommand() config_cmd = commands.ConfigCommand() deps_cmd = commands.DepsCommand() root_cmd.set(extension=None, registry=registry) root_cmd.add_child('config', config_cmd) root_cmd.add_child('deps', deps_cmd) extensions_data = ext.load_extensions() for data in extensions_data: if data.command: # TODO: check isinstance? data.command.set(extension=data.extension) root_cmd.add_child(data.extension.ext_name, data.command) args = root_cmd.parse(sys.argv[1:]) config, config_errors = config_lib.load( args.config_files, [d.config_schema for d in extensions_data], [d.config_defaults for d in extensions_data], args.config_overrides) create_core_dirs(config) create_initial_config_file(args, extensions_data) verbosity_level = args.base_verbosity_level if args.verbosity_level: verbosity_level += args.verbosity_level log.setup_logging(config, verbosity_level, args.save_debug_log) extensions = { 'validate': [], 'config': [], 'disabled': [], 'enabled': []} for data in extensions_data: extension = data.extension # TODO: factor out all of this to a helper that can be tested if not ext.validate_extension_data(data): 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: extensions['enabled'].append(extension) log_extension_info([d.extension for d in extensions_data], extensions['enabled']) # Config and deps commands are simply special cased for now. if args.command == config_cmd: schemas = [d.config_schema for d in extensions_data] return args.command.run(config, config_errors, schemas) elif args.command == deps_cmd: return args.command.run() 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 extensions['enabled']: logger.error( 'Unable to run command provided by disabled extension %s', args.extension.ext_name) return 1 for extension in extensions['enabled']: try: extension.setup(registry) except Exception: # TODO: would be nice a transactional registry. But sadly this # is a bit tricky since our current API is giving out a mutable # list. We might however be able to replace this with a # collections.Sequence to provide a RO view. logger.exception('Extension %s failed during setup, this might' ' have left the registry in a bad state.', extension.ext_name) # Anything that wants to exit after this point must use # mopidy.internal.process.exit_process as actors can have been started. try: return args.command.run(args, proxied_config) except NotImplementedError: print(root_cmd.format_help()) return 1 except KeyboardInterrupt: pass except Exception as ex: logger.exception(ex) raise def create_core_dirs(config): path.get_or_create_dir(config['core']['cache_dir']) path.get_or_create_dir(config['core']['config_dir']) path.get_or_create_dir(config['core']['data_dir']) def create_initial_config_file(args, extensions_data): """Initialize whatever the last config file is with defaults""" config_file = args.config_files[-1] if os.path.exists(path.expand_path(config_file)): return try: default = config_lib.format_initial(extensions_data) path.get_or_create_file(config_file, mkdir=False, content=default) logger.info('Initialized %s with default config', config_file) except IOError as error: logger.warning( 'Unable to initialize %s with default config: %s', config_file, encoding.locale_decode(error)) def log_extension_info(all_extensions, enabled_extensions): # TODO: distinguish disabled vs blocked by env? enabled_names = set(e.ext_name for e in enabled_extensions) disabled_names = set(e.ext_name for e in all_extensions) - enabled_names logger.info( 'Enabled extensions: %s', ', '.join(enabled_names) or 'none') logger.info( 'Disabled extensions: %s', ', '.join(disabled_names) or 'none') 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__': sys.exit(main()) Mopidy-2.0.0/mopidy/http/0000775000175000017500000000000012660436443015450 5ustar jodaljodal00000000000000Mopidy-2.0.0/mopidy/http/__init__.py0000664000175000017500000000266512505224626017566 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging import os import mopidy from mopidy import config as config_lib, exceptions, ext logger = logging.getLogger(__name__) class Extension(ext.Extension): dist_name = 'Mopidy-HTTP' ext_name = 'http' version = mopidy.__version__ def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') return config_lib.read(conf_file) def get_config_schema(self): schema = super(Extension, self).get_config_schema() schema['hostname'] = config_lib.Hostname() schema['port'] = config_lib.Port() schema['static_dir'] = config_lib.Path(optional=True) schema['zeroconf'] = config_lib.String(optional=True) return schema def validate_environment(self): try: import tornado.web # noqa except ImportError as e: raise exceptions.ExtensionError('tornado library not found', e) def setup(self, registry): from .actor import HttpFrontend from .handlers import make_mopidy_app_factory HttpFrontend.apps = registry['http:app'] HttpFrontend.statics = registry['http:static'] registry.add('frontend', HttpFrontend) registry.add('http:app', { 'name': 'mopidy', 'factory': make_mopidy_app_factory( registry['http:app'], registry['http:static']), }) Mopidy-2.0.0/mopidy/http/handlers.py0000664000175000017500000001607612575004517017632 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import functools import logging import os import socket import tornado.escape import tornado.ioloop import tornado.web import tornado.websocket import mopidy from mopidy import core, models from mopidy.internal import encoding, jsonrpc logger = logging.getLogger(__name__) def make_mopidy_app_factory(apps, statics): def mopidy_app_factory(config, core): return [ (r'/ws/?', WebSocketHandler, { 'core': core, }), (r'/rpc', JsonRpcHandler, { 'core': core, }), (r'/(.+)', StaticFileHandler, { 'path': os.path.join(os.path.dirname(__file__), 'data'), }), (r'/', ClientListHandler, { 'apps': apps, 'statics': statics, }), ] return mopidy_app_factory def make_jsonrpc_wrapper(core_actor): inspector = jsonrpc.JsonRpcInspector( objects={ 'core.get_uri_schemes': core.Core.get_uri_schemes, 'core.get_version': core.Core.get_version, 'core.history': core.HistoryController, 'core.library': core.LibraryController, 'core.mixer': core.MixerController, 'core.playback': core.PlaybackController, 'core.playlists': core.PlaylistsController, 'core.tracklist': core.TracklistController, }) return jsonrpc.JsonRpcWrapper( objects={ 'core.describe': inspector.describe, 'core.get_uri_schemes': core_actor.get_uri_schemes, 'core.get_version': core_actor.get_version, 'core.history': core_actor.history, 'core.library': core_actor.library, 'core.mixer': core_actor.mixer, 'core.playback': core_actor.playback, 'core.playlists': core_actor.playlists, 'core.tracklist': core_actor.tracklist, }, decoders=[models.model_json_decoder], encoders=[models.ModelJSONEncoder] ) def _send_broadcast(client, msg): # We could check for client.ws_connection, but we don't really # care why the broadcast failed, we just want the rest of them # to succeed, so catch everything. try: client.write_message(msg) except Exception as e: error_msg = encoding.locale_decode(e) logger.debug('Broadcast of WebSocket message to %s failed: %s', client.request.remote_ip, error_msg) # TODO: should this do the same cleanup as the on_message code? class WebSocketHandler(tornado.websocket.WebSocketHandler): # XXX This set is shared by all WebSocketHandler objects. This isn't # optimal, but there's currently no use case for having more than one of # these anyway. clients = set() @classmethod def broadcast(cls, msg): if hasattr(tornado.ioloop.IOLoop, 'current'): loop = tornado.ioloop.IOLoop.current() else: loop = tornado.ioloop.IOLoop.instance() # Fallback for pre 3.0 # This can be called from outside the Tornado ioloop, so we need to # safely cross the thread boundary by adding a callback to the loop. for client in cls.clients: # One callback per client to keep time we hold up the loop short # NOTE: Pre 3.0 does not support *args or **kwargs... loop.add_callback(functools.partial(_send_broadcast, client, msg)) def initialize(self, core): self.jsonrpc = make_jsonrpc_wrapper(core) def open(self): if hasattr(self, 'set_nodelay'): # New in Tornado 3.1 self.set_nodelay(True) else: self.stream.socket.setsockopt( socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) self.clients.add(self) logger.debug( 'New WebSocket connection from %s', self.request.remote_ip) def on_close(self): self.clients.discard(self) logger.debug( 'Closed WebSocket connection from %s', self.request.remote_ip) def on_message(self, message): if not message: return logger.debug( 'Received WebSocket message from %s: %r', self.request.remote_ip, message) try: response = self.jsonrpc.handle_json( tornado.escape.native_str(message)) if response and self.write_message(response): logger.debug( 'Sent WebSocket message to %s: %r', self.request.remote_ip, response) except Exception as e: error_msg = encoding.locale_decode(e) logger.error('WebSocket request error: %s', error_msg) if self.ws_connection: # Tornado 3.2+ checks if self.ws_connection is None before # using it, but not older versions. self.close() def check_origin(self, origin): # Allow cross-origin WebSocket connections, like Tornado before 4.0 # defaulted to. return True def set_mopidy_headers(request_handler): request_handler.set_header('Cache-Control', 'no-cache') request_handler.set_header( 'X-Mopidy-Version', mopidy.__version__.encode('utf-8')) class JsonRpcHandler(tornado.web.RequestHandler): def initialize(self, core): self.jsonrpc = make_jsonrpc_wrapper(core) def head(self): self.set_extra_headers() self.finish() def post(self): data = self.request.body if not data: return logger.debug( 'Received RPC message from %s: %r', self.request.remote_ip, data) try: self.set_extra_headers() response = self.jsonrpc.handle_json( tornado.escape.native_str(data)) if response and self.write(response): logger.debug( 'Sent RPC message to %s: %r', self.request.remote_ip, response) except Exception as e: logger.error('HTTP JSON-RPC request error: %s', e) self.write_error(500) def set_extra_headers(self): set_mopidy_headers(self) self.set_header('Accept', 'application/json') self.set_header('Content-Type', 'application/json; utf-8') class ClientListHandler(tornado.web.RequestHandler): def initialize(self, apps, statics): self.apps = apps self.statics = statics def get_template_path(self): return os.path.dirname(__file__) def get(self): set_mopidy_headers(self) names = set() for app in self.apps: names.add(app['name']) for static in self.statics: names.add(static['name']) names.discard('mopidy') self.render('data/clients.html', apps=sorted(list(names))) class StaticFileHandler(tornado.web.StaticFileHandler): def set_extra_headers(self, path): set_mopidy_headers(self) class AddSlashHandler(tornado.web.RequestHandler): @tornado.web.addslash def prepare(self): return super(AddSlashHandler, self).prepare() Mopidy-2.0.0/mopidy/http/ext.conf0000664000175000017500000000015712441116635017115 0ustar jodaljodal00000000000000[http] enabled = true hostname = 127.0.0.1 port = 6680 static_dir = zeroconf = Mopidy HTTP server on $hostname Mopidy-2.0.0/mopidy/http/actor.py0000664000175000017500000001350012660436420017124 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import json import logging import os import threading import pykka import tornado.httpserver import tornado.ioloop import tornado.netutil import tornado.web import tornado.websocket from mopidy import exceptions, models, zeroconf from mopidy.core import CoreListener from mopidy.http import handlers from mopidy.internal import encoding, formatting, network logger = logging.getLogger(__name__) class HttpFrontend(pykka.ThreadingActor, CoreListener): apps = [] statics = [] def __init__(self, config, core): super(HttpFrontend, self).__init__() self.hostname = network.format_hostname(config['http']['hostname']) self.port = config['http']['port'] tornado_hostname = config['http']['hostname'] if tornado_hostname == '::': tornado_hostname = None try: logger.debug('Starting HTTP server') sockets = tornado.netutil.bind_sockets(self.port, tornado_hostname) self.server = HttpServer( config=config, core=core, sockets=sockets, apps=self.apps, statics=self.statics) except IOError as error: raise exceptions.FrontendError( 'HTTP server startup failed: %s' % encoding.locale_decode(error)) self.zeroconf_name = config['http']['zeroconf'] self.zeroconf_http = None self.zeroconf_mopidy_http = None def on_start(self): logger.info( 'HTTP server running at [%s]:%s', self.hostname, self.port) self.server.start() if self.zeroconf_name: self.zeroconf_http = zeroconf.Zeroconf( name=self.zeroconf_name, stype='_http._tcp', port=self.port) self.zeroconf_mopidy_http = zeroconf.Zeroconf( name=self.zeroconf_name, stype='_mopidy-http._tcp', port=self.port) self.zeroconf_http.publish() self.zeroconf_mopidy_http.publish() def on_stop(self): if self.zeroconf_http: self.zeroconf_http.unpublish() if self.zeroconf_mopidy_http: self.zeroconf_mopidy_http.unpublish() self.server.stop() def on_event(self, name, **data): on_event(name, **data) def on_event(name, **data): event = data event['event'] = name message = json.dumps(event, cls=models.ModelJSONEncoder) handlers.WebSocketHandler.broadcast(message) class HttpServer(threading.Thread): name = 'HttpServer' def __init__(self, config, core, sockets, apps, statics): super(HttpServer, self).__init__() self.config = config self.core = core self.sockets = sockets self.apps = apps self.statics = statics self.app = None self.server = None def run(self): self.app = tornado.web.Application(self._get_request_handlers()) self.server = tornado.httpserver.HTTPServer(self.app) self.server.add_sockets(self.sockets) tornado.ioloop.IOLoop.instance().start() logger.debug('Stopped HTTP server') def stop(self): logger.debug('Stopping HTTP server') tornado.ioloop.IOLoop.instance().add_callback( tornado.ioloop.IOLoop.instance().stop) def _get_request_handlers(self): request_handlers = [] request_handlers.extend(self._get_app_request_handlers()) request_handlers.extend(self._get_static_request_handlers()) request_handlers.extend(self._get_mopidy_request_handlers()) logger.debug( 'HTTP routes from extensions: %s', formatting.indent('\n'.join( '%r: %r' % (r[0], r[1]) for r in request_handlers))) return request_handlers def _get_app_request_handlers(self): result = [] for app in self.apps: try: request_handlers = app['factory'](self.config, self.core) except Exception: logger.exception('Loading %s failed.', app['name']) continue result.append(( r'/%s' % app['name'], handlers.AddSlashHandler )) for handler in request_handlers: handler = list(handler) handler[0] = '/%s%s' % (app['name'], handler[0]) result.append(tuple(handler)) logger.debug('Loaded HTTP extension: %s', app['name']) return result def _get_static_request_handlers(self): result = [] for static in self.statics: result.append(( r'/%s' % static['name'], handlers.AddSlashHandler )) result.append(( r'/%s/(.*)' % static['name'], handlers.StaticFileHandler, { 'path': static['path'], 'default_filename': 'index.html' } )) logger.debug('Loaded static HTTP extension: %s', static['name']) return result def _get_mopidy_request_handlers(self): # Either default Mopidy or user defined path to files static_dir = self.config['http']['static_dir'] if static_dir and not os.path.exists(static_dir): logger.warning( 'Configured http/static_dir %s does not exist. ' 'Falling back to default HTTP handler.', static_dir) static_dir = None if static_dir: return [(r'/(.*)', handlers.StaticFileHandler, { 'path': self.config['http']['static_dir'], 'default_filename': 'index.html', })] else: return [(r'/', tornado.web.RedirectHandler, { 'url': '/mopidy/', 'permanent': False, })] Mopidy-2.0.0/mopidy/http/data/0000775000175000017500000000000012660436443016361 5ustar jodaljodal00000000000000Mopidy-2.0.0/mopidy/http/data/mopidy.min.js0000664000175000017500000007446112505224626021012 0ustar jodaljodal00000000000000/*! Mopidy.js v0.5.0 - built 2015-01-31 * http://www.mopidy.com/ * Copyright (c) 2015 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ !function(a){if("object"==typeof exports)module.exports=a();else if("function"==typeof define&&define.amd)define(a);else{var b;"undefined"!=typeof window?b=window:"undefined"!=typeof global?b=global:"undefined"!=typeof self&&(b=self),b.Mopidy=a()}}(function(){var a;return function b(a,c,d){function e(g,h){if(!c[g]){if(!a[g]){var i="function"==typeof require&&require;if(!h&&i)return i(g,!0);if(f)return f(g,!0);throw new Error("Cannot find module '"+g+"'")}var j=c[g]={exports:{}};a[g][0].call(j.exports,function(b){var c=a[g][1][b];return e(c?c:b)},j,j.exports,b,a,c,d)}return c[g].exports}for(var f="function"==typeof require&&require,g=0;g0)for(d=0;e>d;++d)c[d](a,b);else setTimeout(function(){throw b.message=a+" listener threw error: "+b.message,b},0)}function b(a){if("function"!=typeof a)throw new TypeError("Listener is not function");return a}function c(a){return a.supervisors||(a.supervisors=[]),a.supervisors}function d(a,b){return a.listeners||(a.listeners={}),b&&!a.listeners[b]&&(a.listeners[b]=[]),b?a.listeners[b]:a.listeners}function e(a){return a.errbacks||(a.errbacks=[]),a.errbacks}function f(f){function h(b,c,d){try{c.listener.apply(c.thisp||f,d)}catch(g){a(b,g,e(f))}}return f=f||{},f.on=function(a,e,f){return"function"==typeof a?c(this).push({listener:a,thisp:e}):void d(this,a).push({listener:b(e),thisp:f})},f.off=function(a,b){var f,g,h,i;if(!a){f=c(this),f.splice(0,f.length),g=d(this);for(h in g)g.hasOwnProperty(h)&&(f=d(this,h),f.splice(0,f.length));return f=e(this),void f.splice(0,f.length)}if("function"==typeof a?(f=c(this),b=a):f=d(this,a),!b)return void f.splice(0,f.length);for(h=0,i=f.length;i>h;++h)if(f[h].listener===b)return void f.splice(h,1)},f.once=function(a,b,c){var d=function(){f.off(a,d),b.apply(this,arguments)};f.on(a,d,c)},f.bind=function(a,b){var c,d,e;if(b)for(d=0,e=b.length;e>d;++d){if("function"!=typeof a[b[d]])throw new Error("No such method "+b[d]);this.on(b[d],a[b[d]],a)}else for(c in a)"function"==typeof a[c]&&this.on(c,a[c],a);return a},f.emit=function(a){var b,e,f=c(this),i=g.call(arguments);for(b=0,e=f.length;e>b;++b)h(a,f[b],i);for(f=d(this,a).slice(),i=g.call(arguments,1),b=0,e=f.length;e>b;++b)h(a,f[b],i)},f.errback=function(a){this.errbacks||(this.errbacks=[]),this.errbacks.push(b(a))},f}var g=Array.prototype.slice;return{createEventEmitter:f,aggregate:function(a){var b=f();return a.forEach(function(a){a.on(function(a,c){b.emit(a,c)})}),b}}})},{}],3:[function(a,b){function c(){}var d=b.exports={};d.nextTick=function(){var a="undefined"!=typeof window&&window.setImmediate,b="undefined"!=typeof window&&window.postMessage&&window.addEventListener;if(a)return function(a){return window.setImmediate(a)};if(b){var c=[];return window.addEventListener("message",function(a){var b=a.source;if((b===window||null===b)&&"process-tick"===a.data&&(a.stopPropagation(),c.length>0)){var d=c.shift();d()}},!0),function(a){c.push(a),window.postMessage("process-tick","*")}}return function(a){setTimeout(a,0)}}(),d.title="browser",d.browser=!0,d.env={},d.argv=[],d.on=c,d.addListener=c,d.once=c,d.off=c,d.removeListener=c,d.removeAllListeners=c,d.emit=c,d.binding=function(){throw new Error("process.binding is not supported")},d.cwd=function(){return"/"},d.chdir=function(){throw new Error("process.chdir is not supported")}},{}],4:[function(b,c){!function(a){"use strict";a(function(a){var b=a("./makePromise"),c=a("./Scheduler"),d=a("./env").asap;return b({scheduler:new c(d)})})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"./Scheduler":5,"./env":17,"./makePromise":19}],5:[function(b,c){!function(a){"use strict";a(function(){function a(a){this._async=a,this._running=!1,this._queue=this,this._queueLen=0,this._afterQueue={},this._afterQueueLen=0;var b=this;this.drain=function(){b._drain()}}return a.prototype.enqueue=function(a){this._queue[this._queueLen++]=a,this.run()},a.prototype.afterQueue=function(a){this._afterQueue[this._afterQueueLen++]=a,this.run()},a.prototype.run=function(){this._running||(this._running=!0,this._async(this.drain))},a.prototype._drain=function(){for(var a=0;a>>0,j=i,k=[],l=0;i>l;++l)if(f=b[l],void 0!==f||l in b){if(e=a._handler(f),e.state()>0){h.become(e),a._visitRemaining(b,l,e);break}e.visit(h,c,d)}else--j;return 0===j&&h.reject(new RangeError("any(): array must not be empty")),g}function e(b,c){function d(a){this.resolved||(k.push(a),0===--n&&(l=null,this.resolve(k)))}function e(a){this.resolved||(l.push(a),0===--f&&(k=null,this.reject(l)))}var f,g,h,i=a._defer(),j=i._handler,k=[],l=[],m=b.length>>>0,n=0;for(h=0;m>h;++h)g=b[h],(void 0!==g||h in b)&&++n;for(c=Math.max(c,0),f=n-c+1,n=Math.min(c,n),c>n?j.reject(new RangeError("some(): array must contain at least "+c+" item(s), but had "+n)):0===n&&j.resolve(k),h=0;m>h;++h)g=b[h],(void 0!==g||h in b)&&a._handler(g).visit(j,d,e,j.notify);return i}function f(b,c){return a._traverse(c,b)}function g(b,c){var d=s.call(b);return a._traverse(c,d).then(function(a){return h(d,a)})}function h(b,c){for(var d=c.length,e=new Array(d),f=0,g=0;d>f;++f)c[f]&&(e[g++]=a._handler(b[f]).value);return e.length=g,e}function i(a){return p(a.map(j))}function j(c){var d=a._handler(c);return 0===d.state()?o(c).then(b.fulfilled,b.rejected):(d._unreport(),b.inspect(d))}function k(a,b){return arguments.length>2?q.call(a,m(b),arguments[2]):q.call(a,m(b))}function l(a,b){return arguments.length>2?r.call(a,m(b),arguments[2]):r.call(a,m(b))}function m(a){return function(b,c,d){return n(a,void 0,[b,c,d])}}var n=c(a),o=a.resolve,p=a.all,q=Array.prototype.reduce,r=Array.prototype.reduceRight,s=Array.prototype.slice;return a.any=d,a.some=e,a.settle=i,a.map=f,a.filter=g,a.reduce=k,a.reduceRight=l,a.prototype.spread=function(a){return this.then(p).then(function(b){return a.apply(this,b)})},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../apply":7,"../state":20}],9:[function(b,c){!function(a){"use strict";a(function(){function a(){throw new TypeError("catch predicate must be a function")}function b(a,b){return c(b)?a instanceof b:b(a)}function c(a){return a===Error||null!=a&&a.prototype instanceof Error}function d(a){return("object"==typeof a||"function"==typeof a)&&null!==a}function e(a){return a}return function(c){function f(a,c){return function(d){return b(d,c)?a.call(this,d):j(d)}}function g(a,b,c,e){var f=a.call(b);return d(f)?h(f,c,e):c(e)}function h(a,b,c){return i(a).then(function(){return b(c)})}var i=c.resolve,j=c.reject,k=c.prototype["catch"];return c.prototype.done=function(a,b){this._handler.visit(this._handler.receiver,a,b)},c.prototype["catch"]=c.prototype.otherwise=function(b){return arguments.length<2?k.call(this,b):"function"!=typeof b?this.ensure(a):k.call(this,f(arguments[1],b))},c.prototype["finally"]=c.prototype.ensure=function(a){return"function"!=typeof a?this:this.then(function(b){return g(a,this,e,b)},function(b){return g(a,this,j,b)})},c.prototype["else"]=c.prototype.orElse=function(a){return this.then(void 0,function(){return a})},c.prototype["yield"]=function(a){return this.then(function(){return a})},c.prototype.tap=function(a){return this.then(a)["yield"](this)},c}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],10:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype.fold=function(b,c){var d=this._beget();return this._handler.fold(function(c,d,e){a._handler(c).fold(function(a,c,d){d.resolve(b.call(this,c,a))},d,this,e)},c,d._handler.receiver,d._handler),d},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],11:[function(b,c){!function(a){"use strict";a(function(a){var b=a("../state").inspect;return function(a){return a.prototype.inspect=function(){return b(a._handler(this))},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../state":20}],12:[function(b,c){!function(a){"use strict";a(function(){return function(a){function b(a,b,d,e){return c(function(b){return[b,a(b)]},b,d,e)}function c(a,b,e,f){function g(f,g){return d(e(f)).then(function(){return c(a,b,e,g)})}return d(f).then(function(c){return d(b(c)).then(function(b){return b?c:d(a(c)).spread(g)})})}var d=a.resolve;return a.iterate=b,a.unfold=c,a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],13:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype.progress=function(a){return this.then(void 0,void 0,a)},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],14:[function(b,c){!function(a){"use strict";a(function(a){function b(a,b,d,e){return c.setTimer(function(){a(d,e,b)},b)}var c=a("../env"),d=a("../TimeoutError");return function(a){function e(a,c,d){b(f,a,c,d)}function f(a,b){b.resolve(a)}function g(a,b,c){var e="undefined"==typeof a?new d("timed out after "+c+"ms"):a;b.reject(e)}return a.prototype.delay=function(a){var b=this._beget();return this._handler.fold(e,a,void 0,b._handler),b},a.prototype.timeout=function(a,d){var e=this._beget(),f=e._handler,h=b(g,a,d,e._handler);return this._handler.visit(f,function(a){c.clearTimer(h),this.resolve(a)},function(a){c.clearTimer(h),this.reject(a)},f.notify),e},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../TimeoutError":6,"../env":17}],15:[function(b,c){!function(a){"use strict";a(function(a){function b(a){throw a}function c(){}var d=a("../env").setTimer,e=a("../format");return function(a){function f(a){a.handled||(n.push(a),k("Potentially unhandled rejection ["+a.id+"] "+e.formatError(a.value)))}function g(a){var b=n.indexOf(a);b>=0&&(n.splice(b,1),l("Handled previous rejection ["+a.id+"] "+e.formatObject(a.value)))}function h(a,b){m.push(a,b),null===o&&(o=d(i,0))}function i(){for(o=null;m.length>0;)m.shift()(m.shift())}var j,k=c,l=c;"undefined"!=typeof console&&(j=console,k="undefined"!=typeof j.error?function(a){j.error(a)}:function(a){j.log(a)},l="undefined"!=typeof j.info?function(a){j.info(a)}:function(a){j.log(a)}),a.onPotentiallyUnhandledRejection=function(a){h(f,a)},a.onPotentiallyUnhandledRejectionHandled=function(a){h(g,a)},a.onFatalRejection=function(a){h(b,a.value)};var m=[],n=[],o=null;return a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../env":17,"../format":18}],16:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype["with"]=a.prototype.withThis=function(a){var b=this._beget(),c=b._handler;return c.receiver=a,this._handler.chain(c,a),b},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],17:[function(b,c){(function(d){!function(a){"use strict";a(function(a){function b(){return"undefined"!=typeof d&&null!==d&&"function"==typeof d.nextTick}function c(){return"function"==typeof MutationObserver&&MutationObserver||"function"==typeof WebKitMutationObserver&&WebKitMutationObserver}function e(a){function b(){var a=c;c=void 0,a()}var c,d=document.createTextNode(""),e=new a(b);e.observe(d,{characterData:!0});var f=0;return function(a){c=a,d.data=f^=1}}var f,g="undefined"!=typeof setTimeout&&setTimeout,h=function(a,b){return setTimeout(a,b)},i=function(a){return clearTimeout(a)},j=function(a){return g(a,0)};if(b())j=function(a){return d.nextTick(a)};else if(f=c())j=e(f);else if(!g){var k=a,l=k("vertx");h=function(a,b){return l.setTimer(b,a)},i=l.cancelTimer,j=l.runOnLoop||l.runOnContext}return{setTimer:h,clearTimer:i,asap:j}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})}).call(this,b("FWaASH"))},{FWaASH:3}],18:[function(b,c){!function(a){"use strict";a(function(){function a(a){var c="object"==typeof a&&null!==a&&a.stack?a.stack:b(a);return a instanceof Error?c:c+" (WARNING: non-Error used)"}function b(a){var b=String(a);return"[object Object]"===b&&"undefined"!=typeof JSON&&(b=c(a,b)),b}function c(a,b){try{return JSON.stringify(a)}catch(c){return b}}return{formatError:a,formatObject:b,tryStringify:c}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],19:[function(b,c){(function(b){!function(a){"use strict";a(function(){return function(a){function c(a,b){this._handler=a===u?b:d(a)}function d(a){function b(a){e.resolve(a)}function c(a){e.reject(a)}function d(a){e.notify(a)}var e=new w;try{a(b,c,d)}catch(f){c(f)}return e}function e(a){return J(a)?a:new c(u,new x(r(a)))}function f(a){return new c(u,new x(new A(a)))}function g(){return ab}function h(){return new c(u,new w)}function i(a,b){var c=new w(a.receiver,a.join().context);return new b(u,c)}function j(a){return l(T,null,a)}function k(a,b){return l(O,a,b)}function l(a,b,d){function e(c,e,g){g.resolved||m(d,f,c,a(b,e,c),g)}function f(a,b,c){k[a]=b,0===--j&&c.become(new z(k))}for(var g,h="function"==typeof b?e:f,i=new w,j=d.length>>>0,k=new Array(j),l=0;l0?b(c,f.value,e):(e.become(f),n(a,c+1,f))}else b(c,d,e)}function n(a,b,c){for(var d=b;dc&&a._unreport()}}function p(a){return"object"!=typeof a||null===a?f(new TypeError("non-iterable passed to race()")):0===a.length?g():1===a.length?e(a[0]):q(a)}function q(a){var b,d,e,f=new w;for(b=0;b0||"function"!=typeof b&&0>e)return new this.constructor(u,d);var f=this._beget(),g=f._handler;return d.chain(g,d.receiver,a,b,c),f},c.prototype["catch"]=function(a){return this.then(void 0,a)},c.prototype._beget=function(){return i(this._handler,this.constructor)},c.all=j,c.race=p,c._traverse=k,c._visitRemaining=n,u.prototype.when=u.prototype.become=u.prototype.notify=u.prototype.fail=u.prototype._unreport=u.prototype._report=U,u.prototype._state=0,u.prototype.state=function(){return this._state},u.prototype.join=function(){for(var a=this;void 0!==a.handler;)a=a.handler;return a},u.prototype.chain=function(a,b,c,d,e){this.when({resolver:a,receiver:b,fulfilled:c,rejected:d,progress:e})},u.prototype.visit=function(a,b,c,d){this.chain(Z,a,b,c,d)},u.prototype.fold=function(a,b,c,d){this.when(new I(a,b,c,d))},S(u,v),v.prototype.become=function(a){a.fail()};var Z=new v;S(u,w),w.prototype._state=0,w.prototype.resolve=function(a){this.become(r(a))},w.prototype.reject=function(a){this.resolved||this.become(new A(a))},w.prototype.join=function(){if(!this.resolved)return this;for(var a=this;void 0!==a.handler;)if(a=a.handler,a===this)return this.handler=D();return a},w.prototype.run=function(){var a=this.consumers,b=this.handler;this.handler=this.handler.join(),this.consumers=void 0;for(var c=0;c0?c(d.value):b(d.value)}return{pending:a,fulfilled:c,rejected:b,inspect:d}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],21:[function(b,c){!function(a){"use strict";a(function(a){function b(a,b,c,d){var e=x.resolve(a);return arguments.length<2?e:e.then(b,c,d)}function c(a){return new x(a)}function d(a){return function(){for(var b=0,c=arguments.length,d=new Array(c);c>b;++b)d[b]=arguments[b];return y(a,this,d)}}function e(a){for(var b=0,c=arguments.length-1,d=new Array(c);c>b;++b)d[b]=arguments[b+1];return y(a,this,d)}function f(){return new g}function g(){function a(a){d._handler.resolve(a)}function b(a){d._handler.reject(a)}function c(a){d._handler.notify(a)}var d=x._defer();this.promise=d,this.resolve=a,this.reject=b,this.notify=c,this.resolver={resolve:a,reject:b,notify:c}}function h(a){return a&&"function"==typeof a.then}function i(){return x.all(arguments)}function j(a){return b(a,x.all)}function k(a){return b(a,x.settle)}function l(a,c){return b(a,function(a){return x.map(a,c)})}function m(a,c){return b(a,function(a){return x.filter(a,c)})}var n=a("./lib/decorators/timed"),o=a("./lib/decorators/array"),p=a("./lib/decorators/flow"),q=a("./lib/decorators/fold"),r=a("./lib/decorators/inspect"),s=a("./lib/decorators/iterate"),t=a("./lib/decorators/progress"),u=a("./lib/decorators/with"),v=a("./lib/decorators/unhandledRejection"),w=a("./lib/TimeoutError"),x=[o,p,q,s,t,r,u,n,v].reduce(function(a,b){return b(a)},a("./lib/Promise")),y=a("./lib/apply")(x);return b.promise=c,b.resolve=x.resolve,b.reject=x.reject,b.lift=d,b["try"]=e,b.attempt=e,b.iterate=x.iterate,b.unfold=x.unfold,b.join=i,b.all=j,b.settle=k,b.any=d(x.any),b.some=d(x.some),b.race=d(x.race),b.map=l,b.filter=m,b.reduce=d(x.reduce),b.reduceRight=d(x.reduceRight),b.isPromiseLike=h,b.Promise=x,b.defer=f,b.TimeoutError=w,b})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"./lib/Promise":4,"./lib/TimeoutError":6,"./lib/apply":7,"./lib/decorators/array":8,"./lib/decorators/flow":9,"./lib/decorators/fold":10,"./lib/decorators/inspect":11,"./lib/decorators/iterate":12,"./lib/decorators/progress":13,"./lib/decorators/timed":14,"./lib/decorators/unhandledRejection":15,"./lib/decorators/with":16}],22:[function(a,b){function c(a){return this instanceof c?(this._console=this._getConsole(a||{}),this._settings=this._configure(a||{}),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,d.createEventEmitter(this),this._delegateEvents(),void(this._settings.autoConnect&&this.connect())):new c(a)}var d=a("bane"),e=a("../lib/websocket/"),f=a("when");c.ConnectionError=function(a){this.name="ConnectionError",this.message=a},c.ConnectionError.prototype=Object.create(Error.prototype),c.ConnectionError.prototype.constructor=c.ConnectionError,c.ServerError=function(a){this.name="ServerError",this.message=a},c.ServerError.prototype=Object.create(Error.prototype),c.ServerError.prototype.constructor=c.ServerError,c.WebSocket=e.Client,c.when=f,c.prototype._getConsole=function(a){if("undefined"!=typeof a.console)return a.console;var b="undefined"!=typeof console&&console||{};return b.log=b.log||function(){},b.warn=b.warn||function(){},b.error=b.error||function(){},b},c.prototype._configure=function(a){var b="undefined"!=typeof document&&"https:"===document.location.protocol?"wss://":"ws://",c="undefined"!=typeof document&&document.location.host||"localhost";return a.webSocketUrl=a.webSocketUrl||b+c+"/mopidy/ws",a.autoConnect!==!1&&(a.autoConnect=!0),a.backoffDelayMin=a.backoffDelayMin||1e3,a.backoffDelayMax=a.backoffDelayMax||64e3,"undefined"==typeof a.callingConvention&&this._console.warn("Mopidy.js is using the default calling convention. The default will change in the future. You should explicitly specify which calling convention you use."),a.callingConvention=a.callingConvention||"by-position-only",a},c.prototype._delegateEvents=function(){this.off("websocket:close"),this.off("websocket:error"),this.off("websocket:incomingMessage"),this.off("websocket:open"),this.off("state:offline"),this.on("websocket:close",this._cleanup),this.on("websocket:error",this._handleWebSocketError),this.on("websocket:incomingMessage",this._handleMessage),this.on("websocket:open",this._resetBackoffDelay),this.on("websocket:open",this._getApiSpec),this.on("state:offline",this._reconnect)},c.prototype.connect=function(){if(this._webSocket){if(this._webSocket.readyState===c.WebSocket.OPEN)return;this._webSocket.close()}this._webSocket=this._settings.webSocket||new c.WebSocket(this._settings.webSocketUrl),this._webSocket.onclose=function(a){this.emit("websocket:close",a)}.bind(this),this._webSocket.onerror=function(a){this.emit("websocket:error",a)}.bind(this),this._webSocket.onopen=function(){this.emit("websocket:open")}.bind(this),this._webSocket.onmessage=function(a){this.emit("websocket:incomingMessage",a)}.bind(this)},c.prototype._cleanup=function(a){Object.keys(this._pendingRequests).forEach(function(b){var d=this._pendingRequests[b];delete this._pendingRequests[b];var e=new c.ConnectionError("WebSocket closed");e.closeEvent=a,d.reject(e)}.bind(this)),this.emit("state:offline")},c.prototype._reconnect=function(){this.emit("reconnectionPending",{timeToAttempt:this._backoffDelay}),setTimeout(function(){this.emit("reconnecting"),this.connect()}.bind(this),this._backoffDelay),this._backoffDelay=2*this._backoffDelay,this._backoffDelay>this._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},c.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},c.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},c.prototype._handleWebSocketError=function(a){this._console.warn("WebSocket error:",a.stack||a)},c.prototype._send=function(a){switch(this._webSocket.readyState){case c.WebSocket.CONNECTING:return f.reject(new c.ConnectionError("WebSocket is still connecting"));case c.WebSocket.CLOSING:return f.reject(new c.ConnectionError("WebSocket is closing"));case c.WebSocket.CLOSED:return f.reject(new c.ConnectionError("WebSocket is closed"));default:var b=f.defer();return a.jsonrpc="2.0",a.id=this._nextRequestId(),this._pendingRequests[a.id]=b.resolver,this._webSocket.send(JSON.stringify(a)),this.emit("websocket:outgoingMessage",a),b.promise}},c.prototype._nextRequestId=function(){var a=-1;return function(){return a+=1}}(),c.prototype._handleMessage=function(a){try{var b=JSON.parse(a.data);b.hasOwnProperty("id")?this._handleResponse(b):b.hasOwnProperty("event")?this._handleEvent(b):this._console.warn("Unknown message type received. Message was: "+a.data)}catch(c){if(!(c instanceof SyntaxError))throw c;this._console.warn("WebSocket message parsing failed. Message was: "+a.data)}},c.prototype._handleResponse=function(a){if(!this._pendingRequests.hasOwnProperty(a.id))return void this._console.warn("Unexpected response received. Message was:",a);var b,d=this._pendingRequests[a.id];delete this._pendingRequests[a.id],a.hasOwnProperty("result")?d.resolve(a.result):a.hasOwnProperty("error")?(b=new c.ServerError(a.error.message),b.code=a.error.code,b.data=a.error.data,d.reject(b),this._console.warn("Server returned error:",a.error)):(b=new Error("Response without 'result' or 'error' received"),b.data={response:a},d.reject(b),this._console.warn("Response without 'result' or 'error' received. Message was:",a))},c.prototype._handleEvent=function(a){var b=a.event,c=a;delete c.event,this.emit("event:"+this._snakeToCamel(b),c)},c.prototype._getApiSpec=function(){return this._send({method:"core.describe"}).then(this._createApi.bind(this))["catch"](this._handleWebSocketError)},c.prototype._createApi=function(a){var b="by-position-or-by-name"===this._settings.callingConvention,c=function(a){return function(){var c={method:a};return 0===arguments.length?this._send(c):b?arguments.length>1?f.reject(new Error("Expected zero arguments, a single array, or a single object.")):Array.isArray(arguments[0])||arguments[0]===Object(arguments[0])?(c.params=arguments[0],this._send(c)):f.reject(new TypeError("Expected an array or an object.")):(c.params=Array.prototype.slice.call(arguments),this._send(c))}.bind(this)}.bind(this),d=function(a){var b=a.split(".");return b.length>=1&&"core"===b[0]&&(b=b.slice(1)),b},e=function(a){var b=this;return a.forEach(function(a){a=this._snakeToCamel(a),b[a]=b[a]||{},b=b[a]}.bind(this)),b}.bind(this),g=function(b){var f=d(b),g=this._snakeToCamel(f.slice(-1)[0]),h=e(f.slice(0,-1));h[g]=c(b),h[g].description=a[b].description,h[g].params=a[b].params}.bind(this);Object.keys(a).forEach(g),this.emit("state:online")},c.prototype._snakeToCamel=function(a){return a.replace(/(_[a-z])/g,function(a){return a.toUpperCase().replace("_","")})},b.exports=c},{"../lib/websocket/":1,bane:2,when:21}]},{},[22])(22)});Mopidy-2.0.0/mopidy/http/data/favicon.ico0000664000175000017500000001355512441116635020506 0ustar jodaljodal00000000000000PNG  IHDR>abKGD pHYs  tIME +3tEXtCommentCreated with GIMPWIDATxo(,ɇ[ovt-'E l:v|EIaiӌF#sdg{xQ ݵόr#>{a@wHxx 7ähD>'tG'B]pPw #ⓁNyA|mpO:[^g8V#w> h &tCH|XG=nL0@ t ={6ns Èa۶]e|X,v\,!"`ٽӕ^7j4Mc8vz}}}*b脦~PNq-@$j. 뺄 }eie0i;pC\ }%01 I\r& Ȩ S^/:1FкO[ Ś= p| P:8q²a1]y۶ 0b~QeK^0\=Oݠk`+@$I 18y4MdXue-H\uQKaR$IM@@f09GI= R'''j*mQQ8$fgg[DGQA{ pF)|p S!t)p3MlZ MbFcj&irVXl5+JX, !`k%u:;;K4 ۷xdaiq:y71`mNu]:g6K~R---eAKYT*eiGQZϲac>ovnfCRCS@$h꺮#`;cY8#u/ˆaA#aa|k:KQŜRd[}Dx Hr'~=$>==΄N)B&@,BhMQ0a&q .5_F`==zVej^gfj%zg/ iRE4]%UU3 $ 8@<aNT2J VK?}ے E$I~Luƶmu]F1rN$]-6bHmllp^~hcl۶eYẮ=rzײn2/..f%IȲe9vS$N'!I/2 C^4McEmEGg܆cDZL4CVD6?~Xo{LOWVӇ7oޔvwwK'''yqR^t^cLj5ŋb\r9>q89Aii?`'RK a$tzvjj*j2${:5%` ۶i]yY9EQ\.G5ގb.aX5wݻwl6fptt4ŋ/_5̈́a䈙‡uI/v#!nB 'e@v.xF@AJ>766ZpiZӧ $IEv[ C!$I̲,(ʽ*gY1?|/_10J>{l ( EQN2ԋŢmS!a{N쀻4ͺ_?OݿF`YaYֹyFJEeYB"n-@yAB!K( ,rV(l6gYoHth6EQ:>V"~aq?nDpq|RwuJ pS ˵DQ0 1k⇞_*\EQPۥ{*1F Xt333s:55UiZN1n11) ˲Nkۉ~Ϲh6|R쯬zQq@#>|Vh)4ԹoͣG٬2(y%|\Lb1%掿曣R cF|ޑ?o?Y{y^0$J\ޫT*KR._iӧOO<Ϋ* p:~cQ` xI˗{{{^HQJ777w~޿Ɛ) D'̤~5[V,Ϛ)r8njGI|p(:KI`u…O|X$$ C LS@ٶMV(M&V&1tl |> H u?8Ꙁ<$ҵZ--˲i`f0 lgMӤla, eX, mzz\ZZ qG)] 5 5@ Ϟ=+z\Պn7 ,ˢ0WB@CfAMRrP謮6677;el6ы@իW󟕽壣z>-rz0EUUe5M,">E\%-~d[3M=;;KYEV9iDswvv>}~xx8h42a0AKP+!PUVUj0qcLAK\a"0?kt;;; Ϟ=[{BN83<{KσqdV5Mc84M"0HP10$3|Gl&,|}VeM{~OjFzv!۶cd~EHj|ەrNſjg,dYOTF\FuZNF4M|CkΉ`i4cN'l6,17nBr pBBZL /ݞC8zLQLجU#b aeYpݾay @Rme.$-d2pg5W$I[8H/4)D8.Xwq  XJͦiH$T*M$ E3iivbgqqxnn4urk/2/Ԥ(JfbXK=/⾩=|p{}}}e>|y%'5pRL%%IUUe]n<ϛw>|yp%'=tᢐf}݁(iZe7 2BA^XX8]^^ѣG_r5 M(d Aښs||ۛQvi N'l ?dR)'>z_}ࢊq(BTAX(jccjEqh4ZV t·(Ŭx[[[;X[[;,Jl6* d2ك2.:FU/i7N[_}U( q ]I2 a0P^G874m<~.kUWVV?>~ȷ迎 0 ݀/ޥqccceaM}Qi1Mbre1`u]#;< h D?Hɤ,bwzz̽x=0-Bb(u>5 񣣣iBe^uֶmʶmq8r]Hs\~~[*z]ez!@+  cFt>I8o088`8.MԔ4Z v+d!Έm |J(oCieYiBٶMz"!u]۶$I8; ugwJk`NE Ј#a7@a  paâO2`5}ȧ3ly;/pE޳;+`>#Gv>޻;*#<*"lYIENDB`Mopidy-2.0.0/mopidy/http/data/mopidy.js0000664000175000017500000024021412505224626020217 0ustar jodaljodal00000000000000/*! Mopidy.js v0.5.0 - built 2015-01-31 * http://www.mopidy.com/ * Copyright (c) 2015 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ !function(e){if("object"==typeof exports)module.exports=e();else if("function"==typeof define&&define.amd)define(e);else{var f;"undefined"!=typeof window?f=window:"undefined"!=typeof global?f=global:"undefined"!=typeof self&&(f=self),f.Mopidy=e()}}(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);throw new Error("Cannot find module '"+o+"'")}var f=n[o]={exports:{}};t[o][0].call(f.exports,function(e){var n=t[o][1][e];return s(n?n:e)},f,f.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o 0) { for (i = 0; i < l; ++i) { errbacks[i](event, error); } return; } setTimeout(function () { error.message = event + " listener threw error: " + error.message; throw error; }, 0); } function assertFunction(fn) { if (typeof fn !== "function") { throw new TypeError("Listener is not function"); } return fn; } function supervisors(object) { if (!object.supervisors) { object.supervisors = []; } return object.supervisors; } function listeners(object, event) { if (!object.listeners) { object.listeners = {}; } if (event && !object.listeners[event]) { object.listeners[event] = []; } return event ? object.listeners[event] : object.listeners; } function errbacks(object) { if (!object.errbacks) { object.errbacks = []; } return object.errbacks; } /** * @signature var emitter = bane.createEmitter([object]); * * Create a new event emitter. If an object is passed, it will be modified * by adding the event emitter methods (see below). */ function createEventEmitter(object) { object = object || {}; function notifyListener(event, listener, args) { try { listener.listener.apply(listener.thisp || object, args); } catch (e) { handleError(event, e, errbacks(object)); } } object.on = function (event, listener, thisp) { if (typeof event === "function") { return supervisors(this).push({ listener: event, thisp: listener }); } listeners(this, event).push({ listener: assertFunction(listener), thisp: thisp }); }; object.off = function (event, listener) { var fns, events, i, l; if (!event) { fns = supervisors(this); fns.splice(0, fns.length); events = listeners(this); for (i in events) { if (events.hasOwnProperty(i)) { fns = listeners(this, i); fns.splice(0, fns.length); } } fns = errbacks(this); fns.splice(0, fns.length); return; } if (typeof event === "function") { fns = supervisors(this); listener = event; } else { fns = listeners(this, event); } if (!listener) { fns.splice(0, fns.length); return; } for (i = 0, l = fns.length; i < l; ++i) { if (fns[i].listener === listener) { fns.splice(i, 1); return; } } }; object.once = function (event, listener, thisp) { var wrapper = function () { object.off(event, wrapper); listener.apply(this, arguments); }; object.on(event, wrapper, thisp); }; object.bind = function (object, events) { var prop, i, l; if (!events) { for (prop in object) { if (typeof object[prop] === "function") { this.on(prop, object[prop], object); } } } else { for (i = 0, l = events.length; i < l; ++i) { if (typeof object[events[i]] === "function") { this.on(events[i], object[events[i]], object); } else { throw new Error("No such method " + events[i]); } } } return object; }; object.emit = function (event) { var toNotify = supervisors(this); var args = slice.call(arguments), i, l; for (i = 0, l = toNotify.length; i < l; ++i) { notifyListener(event, toNotify[i], args); } toNotify = listeners(this, event).slice(); args = slice.call(arguments, 1); for (i = 0, l = toNotify.length; i < l; ++i) { notifyListener(event, toNotify[i], args); } }; object.errback = function (listener) { if (!this.errbacks) { this.errbacks = []; } this.errbacks.push(assertFunction(listener)); }; return object; } return { createEventEmitter: createEventEmitter, aggregate: function (emitters) { var aggregate = createEventEmitter(); emitters.forEach(function (emitter) { emitter.on(function (event, data) { aggregate.emit(event, data); }); }); return aggregate; } }; }); },{}],3:[function(_dereq_,module,exports){ // shim for using process in browser var process = module.exports = {}; process.nextTick = (function () { var canSetImmediate = typeof window !== 'undefined' && window.setImmediate; var canPost = typeof window !== 'undefined' && window.postMessage && window.addEventListener ; if (canSetImmediate) { return function (f) { return window.setImmediate(f) }; } if (canPost) { var queue = []; window.addEventListener('message', function (ev) { var source = ev.source; if ((source === window || source === null) && ev.data === 'process-tick') { ev.stopPropagation(); if (queue.length > 0) { var fn = queue.shift(); fn(); } } }, true); return function nextTick(fn) { queue.push(fn); window.postMessage('process-tick', '*'); }; } return function nextTick(fn) { setTimeout(fn, 0); }; })(); process.title = 'browser'; process.browser = true; process.env = {}; process.argv = []; function noop() {} process.on = noop; process.addListener = noop; process.once = noop; process.off = noop; process.removeListener = noop; process.removeAllListeners = noop; process.emit = noop; process.binding = function (name) { throw new Error('process.binding is not supported'); } // TODO(shtylman) process.cwd = function () { return '/' }; process.chdir = function (dir) { throw new Error('process.chdir is not supported'); }; },{}],4:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ (function(define) { 'use strict'; define(function (_dereq_) { var makePromise = _dereq_('./makePromise'); var Scheduler = _dereq_('./Scheduler'); var async = _dereq_('./env').asap; return makePromise({ scheduler: new Scheduler(async) }); }); })(typeof define === 'function' && define.amd ? define : function (factory) { module.exports = factory(_dereq_); }); },{"./Scheduler":5,"./env":17,"./makePromise":19}],5:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ (function(define) { 'use strict'; define(function() { // Credit to Twisol (https://github.com/Twisol) for suggesting // this type of extensible queue + trampoline approach for next-tick conflation. /** * Async task scheduler * @param {function} async function to schedule a single async function * @constructor */ function Scheduler(async) { this._async = async; this._running = false; this._queue = this; this._queueLen = 0; this._afterQueue = {}; this._afterQueueLen = 0; var self = this; this.drain = function() { self._drain(); }; } /** * Enqueue a task * @param {{ run:function }} task */ Scheduler.prototype.enqueue = function(task) { this._queue[this._queueLen++] = task; this.run(); }; /** * Enqueue a task to run after the main task queue * @param {{ run:function }} task */ Scheduler.prototype.afterQueue = function(task) { this._afterQueue[this._afterQueueLen++] = task; this.run(); }; Scheduler.prototype.run = function() { if (!this._running) { this._running = true; this._async(this.drain); } }; /** * Drain the handler queue entirely, and then the after queue */ Scheduler.prototype._drain = function() { var i = 0; for (; i < this._queueLen; ++i) { this._queue[i].run(); this._queue[i] = void 0; } this._queueLen = 0; this._running = false; for (i = 0; i < this._afterQueueLen; ++i) { this._afterQueue[i].run(); this._afterQueue[i] = void 0; } this._afterQueueLen = 0; }; return Scheduler; }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); },{}],6:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ (function(define) { 'use strict'; define(function() { /** * Custom error type for promises rejected by promise.timeout * @param {string} message * @constructor */ function TimeoutError (message) { Error.call(this); this.message = message; this.name = TimeoutError.name; if (typeof Error.captureStackTrace === 'function') { Error.captureStackTrace(this, TimeoutError); } } TimeoutError.prototype = Object.create(Error.prototype); TimeoutError.prototype.constructor = TimeoutError; return TimeoutError; }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); },{}],7:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ (function(define) { 'use strict'; define(function() { makeApply.tryCatchResolve = tryCatchResolve; return makeApply; function makeApply(Promise, call) { if(arguments.length < 2) { call = tryCatchResolve; } return apply; function apply(f, thisArg, args) { var p = Promise._defer(); var l = args.length; var params = new Array(l); callAndResolve({ f:f, thisArg:thisArg, args:args, params:params, i:l-1, call:call }, p._handler); return p; } function callAndResolve(c, h) { if(c.i < 0) { return call(c.f, c.thisArg, c.params, h); } var handler = Promise._handler(c.args[c.i]); handler.fold(callAndResolveNext, c, void 0, h); } function callAndResolveNext(c, x, h) { c.params[c.i] = x; c.i -= 1; callAndResolve(c, h); } } function tryCatchResolve(f, thisArg, args, resolver) { try { resolver.resolve(f.apply(thisArg, args)); } catch(e) { resolver.reject(e); } } }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); },{}],8:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ (function(define) { 'use strict'; define(function(_dereq_) { var state = _dereq_('../state'); var applier = _dereq_('../apply'); return function array(Promise) { var applyFold = applier(Promise); var toPromise = Promise.resolve; var all = Promise.all; var ar = Array.prototype.reduce; var arr = Array.prototype.reduceRight; var slice = Array.prototype.slice; // Additional array combinators Promise.any = any; Promise.some = some; Promise.settle = settle; Promise.map = map; Promise.filter = filter; Promise.reduce = reduce; Promise.reduceRight = reduceRight; /** * When this promise fulfills with an array, do * onFulfilled.apply(void 0, array) * @param {function} onFulfilled function to apply * @returns {Promise} promise for the result of applying onFulfilled */ Promise.prototype.spread = function(onFulfilled) { return this.then(all).then(function(array) { return onFulfilled.apply(this, array); }); }; return Promise; /** * One-winner competitive race. * Return a promise that will fulfill when one of the promises * in the input array fulfills, or will reject when all promises * have rejected. * @param {array} promises * @returns {Promise} promise for the first fulfilled value */ function any(promises) { var p = Promise._defer(); var resolver = p._handler; var l = promises.length>>>0; var pending = l; var errors = []; for (var h, x, i = 0; i < l; ++i) { x = promises[i]; if(x === void 0 && !(i in promises)) { --pending; continue; } h = Promise._handler(x); if(h.state() > 0) { resolver.become(h); Promise._visitRemaining(promises, i, h); break; } else { h.visit(resolver, handleFulfill, handleReject); } } if(pending === 0) { resolver.reject(new RangeError('any(): array must not be empty')); } return p; function handleFulfill(x) { /*jshint validthis:true*/ errors = null; this.resolve(x); // this === resolver } function handleReject(e) { /*jshint validthis:true*/ if(this.resolved) { // this === resolver return; } errors.push(e); if(--pending === 0) { this.reject(errors); } } } /** * N-winner competitive race * Return a promise that will fulfill when n input promises have * fulfilled, or will reject when it becomes impossible for n * input promises to fulfill (ie when promises.length - n + 1 * have rejected) * @param {array} promises * @param {number} n * @returns {Promise} promise for the earliest n fulfillment values * * @deprecated */ function some(promises, n) { /*jshint maxcomplexity:7*/ var p = Promise._defer(); var resolver = p._handler; var results = []; var errors = []; var l = promises.length>>>0; var nFulfill = 0; var nReject; var x, i; // reused in both for() loops // First pass: count actual array items for(i=0; i nFulfill) { resolver.reject(new RangeError('some(): array must contain at least ' + n + ' item(s), but had ' + nFulfill)); } else if(nFulfill === 0) { resolver.resolve(results); } // Second pass: observe each array item, make progress toward goals for(i=0; i 2 ? ar.call(promises, liftCombine(f), arguments[2]) : ar.call(promises, liftCombine(f)); } /** * Traditional reduce function, similar to `Array.prototype.reduceRight()`, but * input may contain promises and/or values, and reduceFunc * may return either a value or a promise, *and* initialValue may * be a promise for the starting value. * @param {Array|Promise} promises array or promise for an array of anything, * may contain a mix of promises and values. * @param {function(accumulated:*, x:*, index:Number):*} f reduce function * @returns {Promise} that will resolve to the final reduced value */ function reduceRight(promises, f /*, initialValue */) { return arguments.length > 2 ? arr.call(promises, liftCombine(f), arguments[2]) : arr.call(promises, liftCombine(f)); } function liftCombine(f) { return function(z, x, i) { return applyFold(f, void 0, [z,x,i]); }; } }; }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); },{"../apply":7,"../state":20}],9:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ (function(define) { 'use strict'; define(function() { return function flow(Promise) { var resolve = Promise.resolve; var reject = Promise.reject; var origCatch = Promise.prototype['catch']; /** * Handle the ultimate fulfillment value or rejection reason, and assume * responsibility for all errors. If an error propagates out of result * or handleFatalError, it will be rethrown to the host, resulting in a * loud stack track on most platforms and a crash on some. * @param {function?} onResult * @param {function?} onError * @returns {undefined} */ Promise.prototype.done = function(onResult, onError) { this._handler.visit(this._handler.receiver, onResult, onError); }; /** * Add Error-type and predicate matching to catch. Examples: * promise.catch(TypeError, handleTypeError) * .catch(predicate, handleMatchedErrors) * .catch(handleRemainingErrors) * @param onRejected * @returns {*} */ Promise.prototype['catch'] = Promise.prototype.otherwise = function(onRejected) { if (arguments.length < 2) { return origCatch.call(this, onRejected); } if(typeof onRejected !== 'function') { return this.ensure(rejectInvalidPredicate); } return origCatch.call(this, createCatchFilter(arguments[1], onRejected)); }; /** * Wraps the provided catch handler, so that it will only be called * if the predicate evaluates truthy * @param {?function} handler * @param {function} predicate * @returns {function} conditional catch handler */ function createCatchFilter(handler, predicate) { return function(e) { return evaluatePredicate(e, predicate) ? handler.call(this, e) : reject(e); }; } /** * Ensures that onFulfilledOrRejected will be called regardless of whether * this promise is fulfilled or rejected. onFulfilledOrRejected WILL NOT * receive the promises' value or reason. Any returned value will be disregarded. * onFulfilledOrRejected may throw or return a rejected promise to signal * an additional error. * @param {function} handler handler to be called regardless of * fulfillment or rejection * @returns {Promise} */ Promise.prototype['finally'] = Promise.prototype.ensure = function(handler) { if(typeof handler !== 'function') { return this; } return this.then(function(x) { return runSideEffect(handler, this, identity, x); }, function(e) { return runSideEffect(handler, this, reject, e); }); }; function runSideEffect (handler, thisArg, propagate, value) { var result = handler.call(thisArg); return maybeThenable(result) ? propagateValue(result, propagate, value) : propagate(value); } function propagateValue (result, propagate, x) { return resolve(result).then(function () { return propagate(x); }); } /** * Recover from a failure by returning a defaultValue. If defaultValue * is a promise, it's fulfillment value will be used. If defaultValue is * a promise that rejects, the returned promise will reject with the * same reason. * @param {*} defaultValue * @returns {Promise} new promise */ Promise.prototype['else'] = Promise.prototype.orElse = function(defaultValue) { return this.then(void 0, function() { return defaultValue; }); }; /** * Shortcut for .then(function() { return value; }) * @param {*} value * @return {Promise} a promise that: * - is fulfilled if value is not a promise, or * - if value is a promise, will fulfill with its value, or reject * with its reason. */ Promise.prototype['yield'] = function(value) { return this.then(function() { return value; }); }; /** * Runs a side effect when this promise fulfills, without changing the * fulfillment value. * @param {function} onFulfilledSideEffect * @returns {Promise} */ Promise.prototype.tap = function(onFulfilledSideEffect) { return this.then(onFulfilledSideEffect)['yield'](this); }; return Promise; }; function rejectInvalidPredicate() { throw new TypeError('catch predicate must be a function'); } function evaluatePredicate(e, predicate) { return isError(predicate) ? e instanceof predicate : predicate(e); } function isError(predicate) { return predicate === Error || (predicate != null && predicate.prototype instanceof Error); } function maybeThenable(x) { return (typeof x === 'object' || typeof x === 'function') && x !== null; } function identity(x) { return x; } }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); },{}],10:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ /** @author Jeff Escalante */ (function(define) { 'use strict'; define(function() { return function fold(Promise) { Promise.prototype.fold = function(f, z) { var promise = this._beget(); this._handler.fold(function(z, x, to) { Promise._handler(z).fold(function(x, z, to) { to.resolve(f.call(this, z, x)); }, x, this, to); }, z, promise._handler.receiver, promise._handler); return promise; }; return Promise; }; }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); },{}],11:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ (function(define) { 'use strict'; define(function(_dereq_) { var inspect = _dereq_('../state').inspect; return function inspection(Promise) { Promise.prototype.inspect = function() { return inspect(Promise._handler(this)); }; return Promise; }; }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); },{"../state":20}],12:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ (function(define) { 'use strict'; define(function() { return function generate(Promise) { var resolve = Promise.resolve; Promise.iterate = iterate; Promise.unfold = unfold; return Promise; /** * @deprecated Use github.com/cujojs/most streams and most.iterate * Generate a (potentially infinite) stream of promised values: * x, f(x), f(f(x)), etc. until condition(x) returns true * @param {function} f function to generate a new x from the previous x * @param {function} condition function that, given the current x, returns * truthy when the iterate should stop * @param {function} handler function to handle the value produced by f * @param {*|Promise} x starting value, may be a promise * @return {Promise} the result of the last call to f before * condition returns true */ function iterate(f, condition, handler, x) { return unfold(function(x) { return [x, f(x)]; }, condition, handler, x); } /** * @deprecated Use github.com/cujojs/most streams and most.unfold * Generate a (potentially infinite) stream of promised values * by applying handler(generator(seed)) iteratively until * condition(seed) returns true. * @param {function} unspool function that generates a [value, newSeed] * given a seed. * @param {function} condition function that, given the current seed, returns * truthy when the unfold should stop * @param {function} handler function to handle the value produced by unspool * @param x {*|Promise} starting value, may be a promise * @return {Promise} the result of the last value produced by unspool before * condition returns true */ function unfold(unspool, condition, handler, x) { return resolve(x).then(function(seed) { return resolve(condition(seed)).then(function(done) { return done ? seed : resolve(unspool(seed)).spread(next); }); }); function next(item, newSeed) { return resolve(handler(item)).then(function() { return unfold(unspool, condition, handler, newSeed); }); } } }; }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); },{}],13:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ (function(define) { 'use strict'; define(function() { return function progress(Promise) { /** * @deprecated * Register a progress handler for this promise * @param {function} onProgress * @returns {Promise} */ Promise.prototype.progress = function(onProgress) { return this.then(void 0, void 0, onProgress); }; return Promise; }; }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); },{}],14:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ (function(define) { 'use strict'; define(function(_dereq_) { var env = _dereq_('../env'); var TimeoutError = _dereq_('../TimeoutError'); function setTimeout(f, ms, x, y) { return env.setTimer(function() { f(x, y, ms); }, ms); } return function timed(Promise) { /** * Return a new promise whose fulfillment value is revealed only * after ms milliseconds * @param {number} ms milliseconds * @returns {Promise} */ Promise.prototype.delay = function(ms) { var p = this._beget(); this._handler.fold(handleDelay, ms, void 0, p._handler); return p; }; function handleDelay(ms, x, h) { setTimeout(resolveDelay, ms, x, h); } function resolveDelay(x, h) { h.resolve(x); } /** * Return a new promise that rejects after ms milliseconds unless * this promise fulfills earlier, in which case the returned promise * fulfills with the same value. * @param {number} ms milliseconds * @param {Error|*=} reason optional rejection reason to use, defaults * to a TimeoutError if not provided * @returns {Promise} */ Promise.prototype.timeout = function(ms, reason) { var p = this._beget(); var h = p._handler; var t = setTimeout(onTimeout, ms, reason, p._handler); this._handler.visit(h, function onFulfill(x) { env.clearTimer(t); this.resolve(x); // this = h }, function onReject(x) { env.clearTimer(t); this.reject(x); // this = h }, h.notify); return p; }; function onTimeout(reason, h, ms) { var e = typeof reason === 'undefined' ? new TimeoutError('timed out after ' + ms + 'ms') : reason; h.reject(e); } return Promise; }; }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); },{"../TimeoutError":6,"../env":17}],15:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ (function(define) { 'use strict'; define(function(_dereq_) { var setTimer = _dereq_('../env').setTimer; var format = _dereq_('../format'); return function unhandledRejection(Promise) { var logError = noop; var logInfo = noop; var localConsole; if(typeof console !== 'undefined') { // Alias console to prevent things like uglify's drop_console option from // removing console.log/error. Unhandled rejections fall into the same // category as uncaught exceptions, and build tools shouldn't silence them. localConsole = console; logError = typeof localConsole.error !== 'undefined' ? function (e) { localConsole.error(e); } : function (e) { localConsole.log(e); }; logInfo = typeof localConsole.info !== 'undefined' ? function (e) { localConsole.info(e); } : function (e) { localConsole.log(e); }; } Promise.onPotentiallyUnhandledRejection = function(rejection) { enqueue(report, rejection); }; Promise.onPotentiallyUnhandledRejectionHandled = function(rejection) { enqueue(unreport, rejection); }; Promise.onFatalRejection = function(rejection) { enqueue(throwit, rejection.value); }; var tasks = []; var reported = []; var running = null; function report(r) { if(!r.handled) { reported.push(r); logError('Potentially unhandled rejection [' + r.id + '] ' + format.formatError(r.value)); } } function unreport(r) { var i = reported.indexOf(r); if(i >= 0) { reported.splice(i, 1); logInfo('Handled previous rejection [' + r.id + '] ' + format.formatObject(r.value)); } } function enqueue(f, x) { tasks.push(f, x); if(running === null) { running = setTimer(flush, 0); } } function flush() { running = null; while(tasks.length > 0) { tasks.shift()(tasks.shift()); } } return Promise; }; function throwit(e) { throw e; } function noop() {} }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); },{"../env":17,"../format":18}],16:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ (function(define) { 'use strict'; define(function() { return function addWith(Promise) { /** * Returns a promise whose handlers will be called with `this` set to * the supplied receiver. Subsequent promises derived from the * returned promise will also have their handlers called with receiver * as `this`. Calling `with` with undefined or no arguments will return * a promise whose handlers will again be called in the usual Promises/A+ * way (no `this`) thus safely undoing any previous `with` in the * promise chain. * * WARNING: Promises returned from `with`/`withThis` are NOT Promises/A+ * compliant, specifically violating 2.2.5 (http://promisesaplus.com/#point-41) * * @param {object} receiver `this` value for all handlers attached to * the returned promise. * @returns {Promise} */ Promise.prototype['with'] = Promise.prototype.withThis = function(receiver) { var p = this._beget(); var child = p._handler; child.receiver = receiver; this._handler.chain(child, receiver); return p; }; return Promise; }; }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); },{}],17:[function(_dereq_,module,exports){ (function (process){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ /*global process,document,setTimeout,clearTimeout,MutationObserver,WebKitMutationObserver*/ (function(define) { 'use strict'; define(function(_dereq_) { /*jshint maxcomplexity:6*/ // Sniff "best" async scheduling option // Prefer process.nextTick or MutationObserver, then check for // setTimeout, and finally vertx, since its the only env that doesn't // have setTimeout var MutationObs; var capturedSetTimeout = typeof setTimeout !== 'undefined' && setTimeout; // Default env var setTimer = function(f, ms) { return setTimeout(f, ms); }; var clearTimer = function(t) { return clearTimeout(t); }; var asap = function (f) { return capturedSetTimeout(f, 0); }; // Detect specific env if (isNode()) { // Node asap = function (f) { return process.nextTick(f); }; } else if (MutationObs = hasMutationObserver()) { // Modern browser asap = initMutationObserver(MutationObs); } else if (!capturedSetTimeout) { // vert.x var vertxRequire = _dereq_; var vertx = vertxRequire('vertx'); setTimer = function (f, ms) { return vertx.setTimer(ms, f); }; clearTimer = vertx.cancelTimer; asap = vertx.runOnLoop || vertx.runOnContext; } return { setTimer: setTimer, clearTimer: clearTimer, asap: asap }; function isNode () { return typeof process !== 'undefined' && process !== null && typeof process.nextTick === 'function'; } function hasMutationObserver () { return (typeof MutationObserver === 'function' && MutationObserver) || (typeof WebKitMutationObserver === 'function' && WebKitMutationObserver); } function initMutationObserver(MutationObserver) { var scheduled; var node = document.createTextNode(''); var o = new MutationObserver(run); o.observe(node, { characterData: true }); function run() { var f = scheduled; scheduled = void 0; f(); } var i = 0; return function (f) { scheduled = f; node.data = (i ^= 1); }; } }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); }).call(this,_dereq_("FWaASH")) },{"FWaASH":3}],18:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ (function(define) { 'use strict'; define(function() { return { formatError: formatError, formatObject: formatObject, tryStringify: tryStringify }; /** * Format an error into a string. If e is an Error and has a stack property, * it's returned. Otherwise, e is formatted using formatObject, with a * warning added about e not being a proper Error. * @param {*} e * @returns {String} formatted string, suitable for output to developers */ function formatError(e) { var s = typeof e === 'object' && e !== null && e.stack ? e.stack : formatObject(e); return e instanceof Error ? s : s + ' (WARNING: non-Error used)'; } /** * Format an object, detecting "plain" objects and running them through * JSON.stringify if possible. * @param {Object} o * @returns {string} */ function formatObject(o) { var s = String(o); if(s === '[object Object]' && typeof JSON !== 'undefined') { s = tryStringify(o, s); } return s; } /** * Try to return the result of JSON.stringify(x). If that fails, return * defaultValue * @param {*} x * @param {*} defaultValue * @returns {String|*} JSON.stringify(x) or defaultValue */ function tryStringify(x, defaultValue) { try { return JSON.stringify(x); } catch(e) { return defaultValue; } } }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); },{}],19:[function(_dereq_,module,exports){ (function (process){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ (function(define) { 'use strict'; define(function() { return function makePromise(environment) { var tasks = environment.scheduler; var emitRejection = initEmitRejection(); var objectCreate = Object.create || function(proto) { function Child() {} Child.prototype = proto; return new Child(); }; /** * Create a promise whose fate is determined by resolver * @constructor * @returns {Promise} promise * @name Promise */ function Promise(resolver, handler) { this._handler = resolver === Handler ? handler : init(resolver); } /** * Run the supplied resolver * @param resolver * @returns {Pending} */ function init(resolver) { var handler = new Pending(); try { resolver(promiseResolve, promiseReject, promiseNotify); } catch (e) { promiseReject(e); } return handler; /** * Transition from pre-resolution state to post-resolution state, notifying * all listeners of the ultimate fulfillment or rejection * @param {*} x resolution value */ function promiseResolve (x) { handler.resolve(x); } /** * Reject this promise with reason, which will be used verbatim * @param {Error|*} reason rejection reason, strongly suggested * to be an Error type */ function promiseReject (reason) { handler.reject(reason); } /** * @deprecated * Issue a progress event, notifying all progress listeners * @param {*} x progress event payload to pass to all listeners */ function promiseNotify (x) { handler.notify(x); } } // Creation Promise.resolve = resolve; Promise.reject = reject; Promise.never = never; Promise._defer = defer; Promise._handler = getHandler; /** * Returns a trusted promise. If x is already a trusted promise, it is * returned, otherwise returns a new trusted Promise which follows x. * @param {*} x * @return {Promise} promise */ function resolve(x) { return isPromise(x) ? x : new Promise(Handler, new Async(getHandler(x))); } /** * Return a reject promise with x as its reason (x is used verbatim) * @param {*} x * @returns {Promise} rejected promise */ function reject(x) { return new Promise(Handler, new Async(new Rejected(x))); } /** * Return a promise that remains pending forever * @returns {Promise} forever-pending promise. */ function never() { return foreverPendingPromise; // Should be frozen } /** * Creates an internal {promise, resolver} pair * @private * @returns {Promise} */ function defer() { return new Promise(Handler, new Pending()); } // Transformation and flow control /** * Transform this promise's fulfillment value, returning a new Promise * for the transformed result. If the promise cannot be fulfilled, onRejected * is called with the reason. onProgress *may* be called with updates toward * this promise's fulfillment. * @param {function=} onFulfilled fulfillment handler * @param {function=} onRejected rejection handler * @param {function=} onProgress @deprecated progress handler * @return {Promise} new promise */ Promise.prototype.then = function(onFulfilled, onRejected, onProgress) { var parent = this._handler; var state = parent.join().state(); if ((typeof onFulfilled !== 'function' && state > 0) || (typeof onRejected !== 'function' && state < 0)) { // Short circuit: value will not change, simply share handler return new this.constructor(Handler, parent); } var p = this._beget(); var child = p._handler; parent.chain(child, parent.receiver, onFulfilled, onRejected, onProgress); return p; }; /** * If this promise cannot be fulfilled due to an error, call onRejected to * handle the error. Shortcut for .then(undefined, onRejected) * @param {function?} onRejected * @return {Promise} */ Promise.prototype['catch'] = function(onRejected) { return this.then(void 0, onRejected); }; /** * Creates a new, pending promise of the same type as this promise * @private * @returns {Promise} */ Promise.prototype._beget = function() { return begetFrom(this._handler, this.constructor); }; function begetFrom(parent, Promise) { var child = new Pending(parent.receiver, parent.join().context); return new Promise(Handler, child); } // Array combinators Promise.all = all; Promise.race = race; Promise._traverse = traverse; /** * Return a promise that will fulfill when all promises in the * input array have fulfilled, or will reject when one of the * promises rejects. * @param {array} promises array of promises * @returns {Promise} promise for array of fulfillment values */ function all(promises) { return traverseWith(snd, null, promises); } /** * Array> -> Promise> * @private * @param {function} f function to apply to each promise's value * @param {Array} promises array of promises * @returns {Promise} promise for transformed values */ function traverse(f, promises) { return traverseWith(tryCatch2, f, promises); } function traverseWith(tryMap, f, promises) { var handler = typeof f === 'function' ? mapAt : settleAt; var resolver = new Pending(); var pending = promises.length >>> 0; var results = new Array(pending); for (var i = 0, x; i < promises.length && !resolver.resolved; ++i) { x = promises[i]; if (x === void 0 && !(i in promises)) { --pending; continue; } traverseAt(promises, handler, i, x, resolver); } if(pending === 0) { resolver.become(new Fulfilled(results)); } return new Promise(Handler, resolver); function mapAt(i, x, resolver) { if(!resolver.resolved) { traverseAt(promises, settleAt, i, tryMap(f, x, i), resolver); } } function settleAt(i, x, resolver) { results[i] = x; if(--pending === 0) { resolver.become(new Fulfilled(results)); } } } function traverseAt(promises, handler, i, x, resolver) { if (maybeThenable(x)) { var h = getHandlerMaybeThenable(x); var s = h.state(); if (s === 0) { h.fold(handler, i, void 0, resolver); } else if (s > 0) { handler(i, h.value, resolver); } else { resolver.become(h); visitRemaining(promises, i+1, h); } } else { handler(i, x, resolver); } } Promise._visitRemaining = visitRemaining; function visitRemaining(promises, start, handler) { for(var i=start; i 0 ? toFulfilledState(handler.value) : toRejectedState(handler.value); } }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); },{}],21:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** * Promises/A+ and when() implementation * when is part of the cujoJS family of libraries (http://cujojs.com/) * @author Brian Cavalier * @author John Hann * @version 3.7.2 */ (function(define) { 'use strict'; define(function (_dereq_) { var timed = _dereq_('./lib/decorators/timed'); var array = _dereq_('./lib/decorators/array'); var flow = _dereq_('./lib/decorators/flow'); var fold = _dereq_('./lib/decorators/fold'); var inspect = _dereq_('./lib/decorators/inspect'); var generate = _dereq_('./lib/decorators/iterate'); var progress = _dereq_('./lib/decorators/progress'); var withThis = _dereq_('./lib/decorators/with'); var unhandledRejection = _dereq_('./lib/decorators/unhandledRejection'); var TimeoutError = _dereq_('./lib/TimeoutError'); var Promise = [array, flow, fold, generate, progress, inspect, withThis, timed, unhandledRejection] .reduce(function(Promise, feature) { return feature(Promise); }, _dereq_('./lib/Promise')); var apply = _dereq_('./lib/apply')(Promise); // Public API when.promise = promise; // Create a pending promise when.resolve = Promise.resolve; // Create a resolved promise when.reject = Promise.reject; // Create a rejected promise when.lift = lift; // lift a function to return promises when['try'] = attempt; // call a function and return a promise when.attempt = attempt; // alias for when.try when.iterate = Promise.iterate; // DEPRECATED (use cujojs/most streams) Generate a stream of promises when.unfold = Promise.unfold; // DEPRECATED (use cujojs/most streams) Generate a stream of promises when.join = join; // Join 2 or more promises when.all = all; // Resolve a list of promises when.settle = settle; // Settle a list of promises when.any = lift(Promise.any); // One-winner race when.some = lift(Promise.some); // Multi-winner race when.race = lift(Promise.race); // First-to-settle race when.map = map; // Array.map() for promises when.filter = filter; // Array.filter() for promises when.reduce = lift(Promise.reduce); // Array.reduce() for promises when.reduceRight = lift(Promise.reduceRight); // Array.reduceRight() for promises when.isPromiseLike = isPromiseLike; // Is something promise-like, aka thenable when.Promise = Promise; // Promise constructor when.defer = defer; // Create a {promise, resolve, reject} tuple // Error types when.TimeoutError = TimeoutError; /** * Get a trusted promise for x, or by transforming x with onFulfilled * * @param {*} x * @param {function?} onFulfilled callback to be called when x is * successfully fulfilled. If promiseOrValue is an immediate value, callback * will be invoked immediately. * @param {function?} onRejected callback to be called when x is * rejected. * @param {function?} onProgress callback to be called when progress updates * are issued for x. @deprecated * @returns {Promise} a new promise that will fulfill with the return * value of callback or errback or the completion value of promiseOrValue if * callback and/or errback is not supplied. */ function when(x, onFulfilled, onRejected, onProgress) { var p = Promise.resolve(x); if (arguments.length < 2) { return p; } return p.then(onFulfilled, onRejected, onProgress); } /** * Creates a new promise whose fate is determined by resolver. * @param {function} resolver function(resolve, reject, notify) * @returns {Promise} promise whose fate is determine by resolver */ function promise(resolver) { return new Promise(resolver); } /** * Lift the supplied function, creating a version of f that returns * promises, and accepts promises as arguments. * @param {function} f * @returns {Function} version of f that returns promises */ function lift(f) { return function() { for(var i=0, l=arguments.length, a=new Array(l); i this._settings.backoffDelayMax) { this._backoffDelay = this._settings.backoffDelayMax; } }; Mopidy.prototype._resetBackoffDelay = function () { this._backoffDelay = this._settings.backoffDelayMin; }; Mopidy.prototype.close = function () { this.off("state:offline", this._reconnect); this._webSocket.close(); }; Mopidy.prototype._handleWebSocketError = function (error) { this._console.warn("WebSocket error:", error.stack || error); }; Mopidy.prototype._send = function (message) { switch (this._webSocket.readyState) { case Mopidy.WebSocket.CONNECTING: return when.reject( new Mopidy.ConnectionError("WebSocket is still connecting")); case Mopidy.WebSocket.CLOSING: return when.reject( new Mopidy.ConnectionError("WebSocket is closing")); case Mopidy.WebSocket.CLOSED: return when.reject( new Mopidy.ConnectionError("WebSocket is closed")); default: var deferred = when.defer(); message.jsonrpc = "2.0"; message.id = this._nextRequestId(); this._pendingRequests[message.id] = deferred.resolver; this._webSocket.send(JSON.stringify(message)); this.emit("websocket:outgoingMessage", message); return deferred.promise; } }; Mopidy.prototype._nextRequestId = (function () { var lastUsed = -1; return function () { lastUsed += 1; return lastUsed; }; }()); Mopidy.prototype._handleMessage = function (message) { try { var data = JSON.parse(message.data); if (data.hasOwnProperty("id")) { this._handleResponse(data); } else if (data.hasOwnProperty("event")) { this._handleEvent(data); } else { this._console.warn( "Unknown message type received. Message was: " + message.data); } } catch (error) { if (error instanceof SyntaxError) { this._console.warn( "WebSocket message parsing failed. Message was: " + message.data); } else { throw error; } } }; Mopidy.prototype._handleResponse = function (responseMessage) { if (!this._pendingRequests.hasOwnProperty(responseMessage.id)) { this._console.warn( "Unexpected response received. Message was:", responseMessage); return; } var error; var resolver = this._pendingRequests[responseMessage.id]; delete this._pendingRequests[responseMessage.id]; if (responseMessage.hasOwnProperty("result")) { resolver.resolve(responseMessage.result); } else if (responseMessage.hasOwnProperty("error")) { error = new Mopidy.ServerError(responseMessage.error.message); error.code = responseMessage.error.code; error.data = responseMessage.error.data; resolver.reject(error); this._console.warn("Server returned error:", responseMessage.error); } else { error = new Error("Response without 'result' or 'error' received"); error.data = {response: responseMessage}; resolver.reject(error); this._console.warn( "Response without 'result' or 'error' received. Message was:", responseMessage); } }; Mopidy.prototype._handleEvent = function (eventMessage) { var type = eventMessage.event; var data = eventMessage; delete data.event; this.emit("event:" + this._snakeToCamel(type), data); }; Mopidy.prototype._getApiSpec = function () { return this._send({method: "core.describe"}) .then(this._createApi.bind(this)) .catch(this._handleWebSocketError); }; Mopidy.prototype._createApi = function (methods) { var byPositionOrByName = ( this._settings.callingConvention === "by-position-or-by-name"); var caller = function (method) { return function () { var message = {method: method}; if (arguments.length === 0) { return this._send(message); } if (!byPositionOrByName) { message.params = Array.prototype.slice.call(arguments); return this._send(message); } if (arguments.length > 1) { return when.reject(new Error( "Expected zero arguments, a single array, " + "or a single object.")); } if (!Array.isArray(arguments[0]) && arguments[0] !== Object(arguments[0])) { return when.reject(new TypeError( "Expected an array or an object.")); } message.params = arguments[0]; return this._send(message); }.bind(this); }.bind(this); var getPath = function (fullName) { var path = fullName.split("."); if (path.length >= 1 && path[0] === "core") { path = path.slice(1); } return path; }; var createObjects = function (objPath) { var parentObj = this; objPath.forEach(function (objName) { objName = this._snakeToCamel(objName); parentObj[objName] = parentObj[objName] || {}; parentObj = parentObj[objName]; }.bind(this)); return parentObj; }.bind(this); var createMethod = function (fullMethodName) { var methodPath = getPath(fullMethodName); var methodName = this._snakeToCamel(methodPath.slice(-1)[0]); var object = createObjects(methodPath.slice(0, -1)); object[methodName] = caller(fullMethodName); object[methodName].description = methods[fullMethodName].description; object[methodName].params = methods[fullMethodName].params; }.bind(this); Object.keys(methods).forEach(createMethod); this.emit("state:online"); }; Mopidy.prototype._snakeToCamel = function (name) { return name.replace(/(_[a-z])/g, function (match) { return match.toUpperCase().replace("_", ""); }); }; module.exports = Mopidy; },{"../lib/websocket/":1,"bane":2,"when":21}]},{},[22]) (22) });Mopidy-2.0.0/mopidy/http/data/mopidy.css0000664000175000017500000000114212441116635020365 0ustar jodaljodal00000000000000html { background: #f8f8f8; color: #555; font-family: Geneva, Tahoma, Verdana, sans-serif; line-height: 1.4em; } body { max-width: 600px; margin: 0 auto; } h1, h2 { font-weight: 500; line-height: 1.1em; } a { color: #555; text-decoration: none; border-bottom: 1px dotted; } img { border: 0; } .box { background: white; box-shadow: 0px 5px 5px #f0f0f0; margin: 1em; padding: 1em; } .box.focus { background: #465158; color: #e8ecef; } .box a { color: #465158; } .box a:hover { opacity: 0.8; } .box.focus a { color: #e8ecef; } Mopidy-2.0.0/mopidy/http/data/clients.html0000664000175000017500000000144512441116635020707 0ustar jodaljodal00000000000000 Mopidy

Mopidy

This web server is a part of the Mopidy music server. To learn more about Mopidy, please visit www.mopidy.com.

Web clients

Web clients which are installed as Mopidy extensions will automatically appear here.

Mopidy-2.0.0/mopidy/softwaremixer/0000775000175000017500000000000012660436443017370 5ustar jodaljodal00000000000000Mopidy-2.0.0/mopidy/softwaremixer/__init__.py0000664000175000017500000000116712505224626021502 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import os import mopidy from mopidy import config, ext class Extension(ext.Extension): dist_name = 'Mopidy-SoftwareMixer' ext_name = 'softwaremixer' version = mopidy.__version__ def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') return config.read(conf_file) def get_config_schema(self): schema = super(Extension, self).get_config_schema() return schema def setup(self, registry): from .mixer import SoftwareMixer registry.add('mixer', SoftwareMixer) Mopidy-2.0.0/mopidy/softwaremixer/ext.conf0000664000175000017500000000003712441116635021032 0ustar jodaljodal00000000000000[softwaremixer] enabled = true Mopidy-2.0.0/mopidy/softwaremixer/mixer.py0000664000175000017500000000321512505224626021063 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging import pykka from mopidy import mixer logger = logging.getLogger(__name__) class SoftwareMixer(pykka.ThreadingActor, mixer.Mixer): name = 'software' def __init__(self, config): super(SoftwareMixer, self).__init__(config) self._audio_mixer = None self._initial_volume = None self._initial_mute = None def setup(self, mixer_ref): self._audio_mixer = mixer_ref # The Mopidy startup procedure will set the initial volume of a # mixer, but this happens before the audio actor's mixer is injected # into the software mixer actor and has no effect. Thus, we need to set # the initial volume again. if self._initial_volume is not None: self.set_volume(self._initial_volume) if self._initial_mute is not None: self.set_mute(self._initial_mute) def teardown(self): self._audio_mixer = None def get_volume(self): if self._audio_mixer is None: return None return self._audio_mixer.get_volume().get() def set_volume(self, volume): if self._audio_mixer is None: self._initial_volume = volume return False self._audio_mixer.set_volume(volume) return True def get_mute(self): if self._audio_mixer is None: return None return self._audio_mixer.get_mute().get() def set_mute(self, mute): if self._audio_mixer is None: self._initial_mute = mute return False self._audio_mixer.set_mute(mute) return True Mopidy-2.0.0/mopidy/ext.py0000664000175000017500000002347112660436420015645 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import collections import logging import os import pkg_resources from mopidy import config as config_lib, exceptions from mopidy.internal import path logger = logging.getLogger(__name__) _extension_data_fields = ['extension', 'entry_point', 'config_schema', 'config_defaults', 'command'] ExtensionData = collections.namedtuple('ExtensionData', _extension_data_fields) class Extension(object): """Base class for Mopidy extensions""" dist_name = None """The extension's distribution name, as registered on PyPI Example: ``Mopidy-Soundspot`` """ ext_name = None """The extension's short name, as used in setup.py and as config section name Example: ``soundspot`` """ version = None """The extension's version Should match the :attr:`__version__` attribute on the extension's main Python module and the version registered on PyPI. """ def get_default_config(self): """The extension's default config as a bytestring :returns: bytes or unicode """ raise NotImplementedError( 'Add at least a config section with "enabled = true"') def get_config_schema(self): """The extension's config validation schema :returns: :class:`~mopidy.config.schemas.ConfigSchema` """ schema = config_lib.ConfigSchema(self.ext_name) schema['enabled'] = config_lib.Boolean() return schema @classmethod def get_cache_dir(cls, config): """Get or create cache directory for the extension. Use this directory to cache data that can safely be thrown away. :param config: the Mopidy config object :return: string """ assert cls.ext_name is not None cache_dir_path = bytes(os.path.join(config['core']['cache_dir'], cls.ext_name)) path.get_or_create_dir(cache_dir_path) return cache_dir_path @classmethod def get_config_dir(cls, config): """Get or create configuration directory for the extension. :param config: the Mopidy config object :return: string """ assert cls.ext_name is not None config_dir_path = bytes(os.path.join(config['core']['config_dir'], cls.ext_name)) path.get_or_create_dir(config_dir_path) return config_dir_path @classmethod def get_data_dir(cls, config): """Get or create data directory for the extension. Use this directory to store data that should be persistent. :param config: the Mopidy config object :returns: string """ assert cls.ext_name is not None data_dir_path = bytes(os.path.join(config['core']['data_dir'], cls.ext_name)) path.get_or_create_dir(data_dir_path) return data_dir_path def get_command(self): """Command to expose to command line users running ``mopidy``. :returns: Instance of a :class:`~mopidy.commands.Command` class. """ pass def validate_environment(self): """Checks if the extension can run in the current environment. Dependencies described by :file:`setup.py` are checked by Mopidy, so you should not check their presence here. If a problem is found, raise :exc:`~mopidy.exceptions.ExtensionError` with a message explaining the issue. :raises: :exc:`~mopidy.exceptions.ExtensionError` :returns: :class:`None` """ pass def setup(self, registry): """ Register the extension's components in the extension :class:`Registry`. For example, to register a backend:: def setup(self, registry): from .backend import SoundspotBackend registry.add('backend', SoundspotBackend) See :class:`Registry` for a list of registry keys with a special meaning. Mopidy will instantiate and start any classes registered under the ``frontend`` and ``backend`` registry keys. This method can also be used for other setup tasks not involving the extension registry. :param registry: the extension registry :type registry: :class:`Registry` """ raise NotImplementedError class Registry(collections.Mapping): """Registry of components provided by Mopidy extensions. Passed to the :meth:`~Extension.setup` method of all extensions. The registry can be used like a dict of string keys and lists. Some keys have a special meaning, including, but not limited to: - ``backend`` is used for Mopidy backend classes. - ``frontend`` is used for Mopidy frontend classes. - ``local:library`` is used for Mopidy-Local libraries. Extensions can use the registry for allow other to extend the extension itself. For example the ``Mopidy-Local`` use the ``local:library`` key to allow other extensions to register library providers for ``Mopidy-Local`` to use. Extensions should namespace custom keys with the extension's :attr:`~Extension.ext_name`, e.g. ``local:foo`` or ``http:bar``. """ def __init__(self): self._registry = {} def add(self, name, cls): """Add a component to the registry. Multiple classes can be registered to the same name. """ self._registry.setdefault(name, []).append(cls) def __getitem__(self, name): return self._registry.setdefault(name, []) def __iter__(self): return iter(self._registry) def __len__(self): return len(self._registry) def load_extensions(): """Find all installed extensions. :returns: list of installed extensions """ installed_extensions = [] for entry_point in pkg_resources.iter_entry_points('mopidy.ext'): logger.debug('Loading entry point: %s', entry_point) try: extension_class = entry_point.load(require=False) except Exception as e: logger.exception("Failed to load extension %s: %s" % ( entry_point.name, e)) continue try: if not issubclass(extension_class, Extension): raise TypeError # issubclass raises TypeError on non-class except TypeError: logger.error('Entry point %s did not contain a valid extension' 'class: %r', entry_point.name, extension_class) continue try: extension = extension_class() config_schema = extension.get_config_schema() default_config = extension.get_default_config() command = extension.get_command() except Exception: logger.exception('Setup of extension from entry point %s failed, ' 'ignoring extension.', entry_point.name) continue installed_extensions.append(ExtensionData( extension, entry_point, config_schema, default_config, command)) logger.debug( 'Loaded extension: %s %s', extension.dist_name, extension.version) names = (ed.extension.ext_name for ed in installed_extensions) logger.debug('Discovered extensions: %s', ', '.join(names)) return installed_extensions def validate_extension_data(data): """Verify extension's dependencies and environment. :param extensions: an extension to check :returns: if extension should be run """ logger.debug('Validating extension: %s', data.extension.ext_name) if data.extension.ext_name != data.entry_point.name: logger.warning( 'Disabled extension %(ep)s: entry point name (%(ep)s) ' 'does not match extension name (%(ext)s)', {'ep': data.entry_point.name, 'ext': data.extension.ext_name}) return False try: data.entry_point.require() except pkg_resources.DistributionNotFound as ex: logger.info( 'Disabled extension %s: Dependency %s not found', data.extension.ext_name, ex) return False except pkg_resources.VersionConflict as ex: if len(ex.args) == 2: found, required = ex.args logger.info( 'Disabled extension %s: %s required, but found %s at %s', data.extension.ext_name, required, found, found.location) else: logger.info( 'Disabled extension %s: %s', data.extension.ext_name, ex) return False try: data.extension.validate_environment() except exceptions.ExtensionError as ex: logger.info( 'Disabled extension %s: %s', data.extension.ext_name, ex.message) return False except Exception: logger.exception('Validating extension %s failed with an exception.', data.extension.ext_name) return False if not data.config_schema: logger.error('Extension %s does not have a config schema, disabling.', data.extension.ext_name) return False elif not isinstance(data.config_schema.get('enabled'), config_lib.Boolean): logger.error('Extension %s does not have the required "enabled" config' ' option, disabling.', data.extension.ext_name) return False for key, value in data.config_schema.items(): if not isinstance(value, config_lib.ConfigValue): logger.error('Extension %s config schema contains an invalid value' ' for the option "%s", disabling.', data.extension.ext_name, key) return False if not data.config_defaults: logger.error('Extension %s does not have a default config, disabling.', data.extension.ext_name) return False return True Mopidy-2.0.0/mopidy/core/0000775000175000017500000000000012660436443015421 5ustar jodaljodal00000000000000Mopidy-2.0.0/mopidy/core/playback.py0000664000175000017500000004623212660436420017563 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging from mopidy import models from mopidy.audio import PlaybackState from mopidy.compat import urllib from mopidy.core import listener from mopidy.internal import deprecation, validation logger = logging.getLogger(__name__) class PlaybackController(object): pykka_traversable = True def __init__(self, audio, backends, core): # TODO: these should be internal self.backends = backends self.core = core self._audio = audio self._stream_title = None self._state = PlaybackState.STOPPED self._current_tl_track = None self._pending_tl_track = None self._pending_position = None self._last_position = None self._previous = False if self._audio: self._audio.set_about_to_finish_callback( self._on_about_to_finish_callback) def _get_backend(self, tl_track): if tl_track is None: return None uri_scheme = urllib.parse.urlparse(tl_track.track.uri).scheme return self.backends.with_playback.get(uri_scheme, None) # Properties def get_current_tl_track(self): """Get the currently playing or selected track. Returns a :class:`mopidy.models.TlTrack` or :class:`None`. """ return self._current_tl_track def _set_current_tl_track(self, value): """Set the currently playing or selected track. *Internal:* This is only for use by Mopidy's test suite. """ self._current_tl_track = value current_tl_track = deprecation.deprecated_property(get_current_tl_track) """ .. deprecated:: 1.0 Use :meth:`get_current_tl_track` instead. """ def get_current_track(self): """ Get the currently playing or selected track. Extracted from :meth:`get_current_tl_track` for convenience. Returns a :class:`mopidy.models.Track` or :class:`None`. """ return getattr(self.get_current_tl_track(), 'track', None) current_track = deprecation.deprecated_property(get_current_track) """ .. deprecated:: 1.0 Use :meth:`get_current_track` instead. """ def get_current_tlid(self): """ Get the currently playing or selected TLID. Extracted from :meth:`get_current_tl_track` for convenience. Returns a :class:`int` or :class:`None`. .. versionadded:: 1.1 """ return getattr(self.get_current_tl_track(), 'tlid', None) def get_stream_title(self): """Get the current stream title or :class:`None`.""" return self._stream_title def get_state(self): """Get The playback state.""" return self._state def set_state(self, new_state): """Set the playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or :attr:`STOPPED`. Possible states and transitions: .. digraph:: state_transitions "STOPPED" -> "PLAYING" [ label="play" ] "STOPPED" -> "PAUSED" [ label="pause" ] "PLAYING" -> "STOPPED" [ label="stop" ] "PLAYING" -> "PAUSED" [ label="pause" ] "PLAYING" -> "PLAYING" [ label="play" ] "PAUSED" -> "PLAYING" [ label="resume" ] "PAUSED" -> "STOPPED" [ label="stop" ] """ validation.check_choice(new_state, validation.PLAYBACK_STATES) (old_state, self._state) = (self.get_state(), new_state) logger.debug('Changing state: %s -> %s', old_state, new_state) self._trigger_playback_state_changed(old_state, new_state) state = deprecation.deprecated_property(get_state, set_state) """ .. deprecated:: 1.0 Use :meth:`get_state` and :meth:`set_state` instead. """ def get_time_position(self): """Get time position in milliseconds.""" if self._pending_position is not None: return self._pending_position backend = self._get_backend(self.get_current_tl_track()) if backend: # TODO: Wrap backend call in error handling. return backend.playback.get_time_position().get() else: return 0 time_position = deprecation.deprecated_property(get_time_position) """ .. deprecated:: 1.0 Use :meth:`get_time_position` instead. """ def get_volume(self): """ .. deprecated:: 1.0 Use :meth:`core.mixer.get_volume() ` instead. """ deprecation.warn('core.playback.get_volume') return self.core.mixer.get_volume() def set_volume(self, volume): """ .. deprecated:: 1.0 Use :meth:`core.mixer.set_volume() ` instead. """ deprecation.warn('core.playback.set_volume') return self.core.mixer.set_volume(volume) volume = deprecation.deprecated_property(get_volume, set_volume) """ .. deprecated:: 1.0 Use :meth:`core.mixer.get_volume() ` and :meth:`core.mixer.set_volume() ` instead. """ def get_mute(self): """ .. deprecated:: 1.0 Use :meth:`core.mixer.get_mute() ` instead. """ deprecation.warn('core.playback.get_mute') return self.core.mixer.get_mute() def set_mute(self, mute): """ .. deprecated:: 1.0 Use :meth:`core.mixer.set_mute() ` instead. """ deprecation.warn('core.playback.set_mute') return self.core.mixer.set_mute(mute) mute = deprecation.deprecated_property(get_mute, set_mute) """ .. deprecated:: 1.0 Use :meth:`core.mixer.get_mute() ` and :meth:`core.mixer.set_mute() ` instead. """ # Methods def _on_end_of_stream(self): self.set_state(PlaybackState.STOPPED) if self._current_tl_track: self._trigger_track_playback_ended(self.get_time_position()) self._set_current_tl_track(None) def _on_stream_changed(self, uri): if self._last_position is None: position = self.get_time_position() else: # This code path handles the stop() case, uri should be none. position, self._last_position = self._last_position, None if self._pending_position is None: self._trigger_track_playback_ended(position) self._stream_title = None if self._pending_tl_track: self._set_current_tl_track(self._pending_tl_track) self._pending_tl_track = None if self._pending_position is None: self.set_state(PlaybackState.PLAYING) self._trigger_track_playback_started() else: self._seek(self._pending_position) def _on_position_changed(self, position): if self._pending_position == position: self._trigger_seeked(position) self._pending_position = None def _on_about_to_finish_callback(self): """Callback that performs a blocking actor call to the real callback. This is passed to audio, which is allowed to call this code from the audio thread. We pass execution into the core actor to ensure that there is no unsafe access of state in core. This must block until we get a response. """ self.core.actor_ref.ask({ 'command': 'pykka_call', 'args': tuple(), 'kwargs': {}, 'attr_path': ('playback', '_on_about_to_finish'), }) def _on_about_to_finish(self): if self._state == PlaybackState.STOPPED: return pending = self.core.tracklist.eot_track(self._current_tl_track) while pending: # TODO: Avoid infinite loops if all tracks are unplayable. backend = self._get_backend(pending) if not backend: continue try: if backend.playback.change_track(pending.track).get(): self._pending_tl_track = pending break except Exception: logger.exception('%s backend caused an exception.', backend.actor_ref.actor_class.__name__) self.core.tracklist._mark_unplayable(pending) pending = self.core.tracklist.eot_track(pending) def _on_tracklist_change(self): """ Tell the playback controller that the current playlist has changed. Used by :class:`mopidy.core.TracklistController`. """ if not self.core.tracklist.tl_tracks: self.stop() self._set_current_tl_track(None) elif self.get_current_tl_track() not in self.core.tracklist.tl_tracks: self._set_current_tl_track(None) def next(self): """ Change to the next track. The current playback state will be kept. If it was playing, playing will continue. If it was paused, it will still be paused, etc. """ state = self.get_state() current = self._pending_tl_track or self._current_tl_track while current: pending = self.core.tracklist.next_track(current) if self._change(pending, state): break else: self.core.tracklist._mark_unplayable(pending) # TODO: this could be needed to prevent a loop in rare cases # if current == pending: # break current = pending # TODO return result? def pause(self): """Pause playback.""" backend = self._get_backend(self.get_current_tl_track()) # TODO: Wrap backend call in error handling. if not backend or backend.playback.pause().get(): # TODO: switch to: # backend.track(pause) # wait for state change? self.set_state(PlaybackState.PAUSED) self._trigger_track_playback_paused() def play(self, tl_track=None, tlid=None): """ Play the given track, or if the given tl_track and tlid is :class:`None`, play the currently active track. Note that the track **must** already be in the tracklist. :param tl_track: track to play :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :param tlid: TLID of the track to play :type tlid: :class:`int` or :class:`None` """ if sum(o is not None for o in [tl_track, tlid]) > 1: raise ValueError('At most one of "tl_track" and "tlid" may be set') tl_track is None or validation.check_instance(tl_track, models.TlTrack) tlid is None or validation.check_integer(tlid, min=1) if tl_track: deprecation.warn('core.playback.play:tl_track_kwarg', pending=True) if tl_track is None and tlid is not None: for tl_track in self.core.tracklist.get_tl_tracks(): if tl_track.tlid == tlid: break else: tl_track = None if tl_track is not None: # TODO: allow from outside tracklist, would make sense given refs? assert tl_track in self.core.tracklist.get_tl_tracks() elif tl_track is None and self.get_state() == PlaybackState.PAUSED: self.resume() return current = self._pending_tl_track or self._current_tl_track pending = tl_track or current or self.core.tracklist.next_track(None) while pending: if self._change(pending, PlaybackState.PLAYING): break else: self.core.tracklist._mark_unplayable(pending) current = pending pending = self.core.tracklist.next_track(current) # TODO return result? def _change(self, pending_tl_track, state): self._pending_tl_track = pending_tl_track if not pending_tl_track: self.stop() self._on_end_of_stream() # pretend an EOS happened for cleanup return True backend = self._get_backend(pending_tl_track) if not backend: return False # TODO: Wrap backend call in error handling. backend.playback.prepare_change() try: if not backend.playback.change_track(pending_tl_track.track).get(): return False except Exception: logger.exception('%s backend caused an exception.', backend.actor_ref.actor_class.__name__) return False # TODO: Wrap backend calls in error handling. if state == PlaybackState.PLAYING: try: return backend.playback.play().get() except TypeError: # TODO: check by binding against underlying play method using # inspect and otherwise re-raise? logger.error('%s needs to be updated to work with this ' 'version of Mopidy.', backend) return False elif state == PlaybackState.PAUSED: return backend.playback.pause().get() elif state == PlaybackState.STOPPED: # TODO: emit some event now? self._current_tl_track = self._pending_tl_track self._pending_tl_track = None return True raise Exception('Unknown state: %s' % state) def previous(self): """ Change to the previous track. The current playback state will be kept. If it was playing, playing will continue. If it was paused, it will still be paused, etc. """ self._previous = True state = self.get_state() current = self._pending_tl_track or self._current_tl_track while current: pending = self.core.tracklist.previous_track(current) if self._change(pending, state): break else: self.core.tracklist._mark_unplayable(pending) # TODO: this could be needed to prevent a loop in rare cases # if current == pending: # break current = pending # TODO: no return value? def resume(self): """If paused, resume playing the current track.""" if self.get_state() != PlaybackState.PAUSED: return backend = self._get_backend(self.get_current_tl_track()) # TODO: Wrap backend call in error handling. if backend and backend.playback.resume().get(): self.set_state(PlaybackState.PLAYING) # TODO: trigger via gst messages self._trigger_track_playback_resumed() # TODO: switch to: # backend.resume() # wait for state change? def seek(self, time_position): """ Seeks to time position given in milliseconds. :param time_position: time position in milliseconds :type time_position: int :rtype: :class:`True` if successful, else :class:`False` """ # TODO: seek needs to take pending tracks into account :( validation.check_integer(time_position) if time_position < 0: logger.debug( 'Client seeked to negative position. Seeking to zero.') time_position = 0 if not self.core.tracklist.tracks: return False if self.get_state() == PlaybackState.STOPPED: self.play() # We need to prefer the still playing track, but if nothing is playing # we fall back to the pending one. tl_track = self._current_tl_track or self._pending_tl_track if tl_track and tl_track.track.length is None: return False if time_position < 0: time_position = 0 elif time_position > tl_track.track.length: # TODO: GStreamer will trigger a about-to-finish for us, use that? self.next() return True # Store our target position. self._pending_position = time_position # Make sure we switch back to previous track if we get a seek while we # have a pending track. if self._current_tl_track and self._pending_tl_track: self._change(self._current_tl_track, self.get_state()) else: return self._seek(time_position) def _seek(self, time_position): backend = self._get_backend(self.get_current_tl_track()) if not backend: return False # TODO: Wrap backend call in error handling. return backend.playback.seek(time_position).get() def stop(self): """Stop playing.""" if self.get_state() != PlaybackState.STOPPED: self._last_position = self.get_time_position() backend = self._get_backend(self.get_current_tl_track()) # TODO: Wrap backend call in error handling. if not backend or backend.playback.stop().get(): self.set_state(PlaybackState.STOPPED) def _trigger_track_playback_paused(self): logger.debug('Triggering track playback paused event') if self.current_track is None: return listener.CoreListener.send( 'track_playback_paused', tl_track=self.get_current_tl_track(), time_position=self.get_time_position()) def _trigger_track_playback_resumed(self): logger.debug('Triggering track playback resumed event') if self.current_track is None: return listener.CoreListener.send( 'track_playback_resumed', tl_track=self.get_current_tl_track(), time_position=self.get_time_position()) def _trigger_track_playback_started(self): if self.get_current_tl_track() is None: return logger.debug('Triggering track playback started event') tl_track = self.get_current_tl_track() self.core.tracklist._mark_playing(tl_track) self.core.history._add_track(tl_track.track) listener.CoreListener.send('track_playback_started', tl_track=tl_track) def _trigger_track_playback_ended(self, time_position_before_stop): tl_track = self.get_current_tl_track() if tl_track is None: return logger.debug('Triggering track playback ended event') if not self._previous: self.core.tracklist._mark_played(self._current_tl_track) self._previous = False # TODO: Use the lowest of track duration and position. listener.CoreListener.send( 'track_playback_ended', tl_track=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') listener.CoreListener.send( 'playback_state_changed', old_state=old_state, new_state=new_state) def _trigger_seeked(self, time_position): # TODO: Trigger this from audio events? logger.debug('Triggering seeked event') listener.CoreListener.send('seeked', time_position=time_position) Mopidy-2.0.0/mopidy/core/history.py0000664000175000017500000000277412517507762017512 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import copy import logging import time from mopidy import models logger = logging.getLogger(__name__) class HistoryController(object): pykka_traversable = True def __init__(self): self._history = [] def _add_track(self, track): """Add track to the playback history. Internal method for :class:`mopidy.core.PlaybackController`. :param track: track to add :type track: :class:`mopidy.models.Track` """ if not isinstance(track, models.Track): raise TypeError('Only Track objects can be added to the history') timestamp = int(time.time() * 1000) name_parts = [] if track.artists: name_parts.append( ', '.join([artist.name for artist in track.artists])) if track.name is not None: name_parts.append(track.name) name = ' - '.join(name_parts) ref = models.Ref.track(uri=track.uri, name=name) self._history.insert(0, (timestamp, ref)) def get_length(self): """Get the number of tracks in the history. :returns: the history length :rtype: int """ return len(self._history) def get_history(self): """Get the track history. The timestamps are milliseconds since epoch. :returns: the track history :rtype: list of (timestamp, :class:`mopidy.models.Ref`) tuples """ return copy.copy(self._history) Mopidy-2.0.0/mopidy/core/tracklist.py0000664000175000017500000005224312660436420017774 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging import random from mopidy import exceptions from mopidy.core import listener from mopidy.internal import deprecation, validation from mopidy.models import TlTrack, Track logger = logging.getLogger(__name__) class TracklistController(object): pykka_traversable = True def __init__(self, core): self.core = core self._next_tlid = 1 self._tl_tracks = [] self._version = 0 self._shuffled = [] # Properties def get_tl_tracks(self): """Get tracklist as list of :class:`mopidy.models.TlTrack`.""" return self._tl_tracks[:] tl_tracks = deprecation.deprecated_property(get_tl_tracks) """ .. deprecated:: 1.0 Use :meth:`get_tl_tracks` instead. """ def get_tracks(self): """Get tracklist as list of :class:`mopidy.models.Track`.""" return [tl_track.track for tl_track in self._tl_tracks] tracks = deprecation.deprecated_property(get_tracks) """ .. deprecated:: 1.0 Use :meth:`get_tracks` instead. """ def get_length(self): """Get length of the tracklist.""" return len(self._tl_tracks) length = deprecation.deprecated_property(get_length) """ .. deprecated:: 1.0 Use :meth:`get_length` instead. """ def get_version(self): """ Get the tracklist version. Integer which is increased every time the tracklist is changed. Is not reset before Mopidy is restarted. """ return self._version def _increase_version(self): self._version += 1 self.core.playback._on_tracklist_change() self._trigger_tracklist_changed() version = deprecation.deprecated_property(get_version) """ .. deprecated:: 1.0 Use :meth:`get_version` instead. """ def get_consume(self): """Get consume mode. :class:`True` Tracks are removed from the tracklist when they have been played. :class:`False` Tracks are not removed from the tracklist. """ return getattr(self, '_consume', False) def set_consume(self, value): """Set consume mode. :class:`True` Tracks are removed from the tracklist when they have been played. :class:`False` Tracks are not removed from the tracklist. """ validation.check_boolean(value) if self.get_consume() != value: self._trigger_options_changed() return setattr(self, '_consume', value) consume = deprecation.deprecated_property(get_consume, set_consume) """ .. deprecated:: 1.0 Use :meth:`get_consume` and :meth:`set_consume` instead. """ def get_random(self): """Get random mode. :class:`True` Tracks are selected at random from the tracklist. :class:`False` Tracks are played in the order of the tracklist. """ return getattr(self, '_random', False) def set_random(self, value): """Set random mode. :class:`True` Tracks are selected at random from the tracklist. :class:`False` Tracks are played in the order of the tracklist. """ validation.check_boolean(value) if self.get_random() != value: self._trigger_options_changed() if value: self._shuffled = self.get_tl_tracks() random.shuffle(self._shuffled) return setattr(self, '_random', value) random = deprecation.deprecated_property(get_random, set_random) """ .. deprecated:: 1.0 Use :meth:`get_random` and :meth:`set_random` instead. """ def get_repeat(self): """ Get repeat mode. :class:`True` The tracklist is played repeatedly. :class:`False` The tracklist is played once. """ return getattr(self, '_repeat', False) def set_repeat(self, value): """ Set repeat mode. To repeat a single track, set both ``repeat`` and ``single``. :class:`True` The tracklist is played repeatedly. :class:`False` The tracklist is played once. """ validation.check_boolean(value) if self.get_repeat() != value: self._trigger_options_changed() return setattr(self, '_repeat', value) repeat = deprecation.deprecated_property(get_repeat, set_repeat) """ .. deprecated:: 1.0 Use :meth:`get_repeat` and :meth:`set_repeat` instead. """ def get_single(self): """ Get single mode. :class:`True` Playback is stopped after current song, unless in ``repeat`` mode. :class:`False` Playback continues after current song. """ return getattr(self, '_single', False) def set_single(self, value): """ Set single mode. :class:`True` Playback is stopped after current song, unless in ``repeat`` mode. :class:`False` Playback continues after current song. """ validation.check_boolean(value) if self.get_single() != value: self._trigger_options_changed() return setattr(self, '_single', value) single = deprecation.deprecated_property(get_single, set_single) """ .. deprecated:: 1.0 Use :meth:`get_single` and :meth:`set_single` instead. """ # Methods def index(self, tl_track=None, tlid=None): """ The position of the given track in the tracklist. If neither *tl_track* or *tlid* is given we return the index of the currently playing track. :param tl_track: the track to find the index of :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :param tlid: TLID of the track to find the index of :type tlid: :class:`int` or :class:`None` :rtype: :class:`int` or :class:`None` .. versionadded:: 1.1 The *tlid* parameter """ tl_track is None or validation.check_instance(tl_track, TlTrack) tlid is None or validation.check_integer(tlid, min=1) if tl_track is None and tlid is None: tl_track = self.core.playback.get_current_tl_track() if tl_track is not None: try: return self._tl_tracks.index(tl_track) except ValueError: pass elif tlid is not None: for i, tl_track in enumerate(self._tl_tracks): if tl_track.tlid == tlid: return i return None def get_eot_tlid(self): """ The TLID of the track that will be played after the current track. Not necessarily the same TLID as returned by :meth:`get_next_tlid`. :rtype: :class:`int` or :class:`None` .. versionadded:: 1.1 """ current_tl_track = self.core.playback.get_current_tl_track() return getattr(self.eot_track(current_tl_track), 'tlid', None) def eot_track(self, tl_track): """ The track that will be played after the given track. Not necessarily the same track as :meth:`next_track`. :param tl_track: the reference track :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ deprecation.warn('core.tracklist.eot_track', pending=True) tl_track is None or validation.check_instance(tl_track, TlTrack) if self.get_single() and self.get_repeat(): return tl_track elif self.get_single(): return None # Current difference between next and EOT handling is that EOT needs to # handle "single", with that out of the way the rest of the logic is # shared. return self.next_track(tl_track) def get_next_tlid(self): """ The tlid of the track that will be played if calling :meth:`mopidy.core.PlaybackController.next()`. For normal playback this is the next track in the tracklist. If repeat is enabled the next track can loop around the tracklist. When random is enabled this should be a random track, all tracks should be played once before the tracklist repeats. :rtype: :class:`int` or :class:`None` .. versionadded:: 1.1 """ current_tl_track = self.core.playback.get_current_tl_track() return getattr(self.next_track(current_tl_track), 'tlid', None) def next_track(self, tl_track): """ The track that will be played if calling :meth:`mopidy.core.PlaybackController.next()`. For normal playback this is the next track in the tracklist. If repeat is enabled the next track can loop around the tracklist. When random is enabled this should be a random track, all tracks should be played once before the tracklist repeats. :param tl_track: the reference track :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ deprecation.warn('core.tracklist.next_track', pending=True) tl_track is None or validation.check_instance(tl_track, TlTrack) if not self._tl_tracks: return None if self.get_random() and not self._shuffled: if self.get_repeat() or not tl_track: logger.debug('Shuffling tracks') self._shuffled = self._tl_tracks[:] random.shuffle(self._shuffled) if self.get_random(): if self._shuffled: return self._shuffled[0] return None next_index = self.index(tl_track) if next_index is None: next_index = 0 else: next_index += 1 if self.get_repeat(): next_index %= len(self._tl_tracks) elif next_index >= len(self._tl_tracks): return None return self._tl_tracks[next_index] def get_previous_tlid(self): """ Returns the TLID of the track that will be played if calling :meth:`mopidy.core.PlaybackController.previous()`. For normal playback this is the previous track in the tracklist. If random and/or consume is enabled it should return the current track instead. :rtype: :class:`int` or :class:`None` .. versionadded:: 1.1 """ current_tl_track = self.core.playback.get_current_tl_track() return getattr(self.previous_track(current_tl_track), 'tlid', None) def previous_track(self, tl_track): """ Returns the track that will be played if calling :meth:`mopidy.core.PlaybackController.previous()`. For normal playback this is the previous track in the tracklist. If random and/or consume is enabled it should return the current track instead. :param tl_track: the reference track :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ deprecation.warn('core.tracklist.previous_track', pending=True) tl_track is None or validation.check_instance(tl_track, TlTrack) if self.get_repeat() or self.get_consume() or self.get_random(): return tl_track position = self.index(tl_track) if position in (None, 0): return None # Since we know we are not at zero we have to be somewhere in the range # 1 - len(tracks) Thus 'position - 1' will always be within the list. return self._tl_tracks[position - 1] def add(self, tracks=None, at_position=None, uri=None, uris=None): """ Add tracks to the tracklist. If ``uri`` is given instead of ``tracks``, the URI is looked up in the library and the resulting tracks are added to the tracklist. If ``uris`` is given instead of ``uri`` or ``tracks``, the URIs are looked up in the library and the resulting tracks are added to the tracklist. If ``at_position`` is given, the tracks are inserted at the given position in the tracklist. If ``at_position`` is not given, the tracks are appended to the end of the tracklist. Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event. :param tracks: tracks to add :type tracks: list of :class:`mopidy.models.Track` or :class:`None` :param at_position: position in tracklist to add tracks :type at_position: int or :class:`None` :param uri: URI for tracks to add :type uri: string or :class:`None` :param uris: list of URIs for tracks to add :type uris: list of string or :class:`None` :rtype: list of :class:`mopidy.models.TlTrack` .. versionadded:: 1.0 The ``uris`` argument. .. deprecated:: 1.0 The ``tracks`` and ``uri`` arguments. Use ``uris``. """ if sum(o is not None for o in [tracks, uri, uris]) != 1: raise ValueError( 'Exactly one of "tracks", "uri" or "uris" must be set') tracks is None or validation.check_instances(tracks, Track) uri is None or validation.check_uri(uri) uris is None or validation.check_uris(uris) validation.check_integer(at_position or 0) if tracks: deprecation.warn('core.tracklist.add:tracks_arg') if uri: deprecation.warn('core.tracklist.add:uri_arg') if tracks is None: if uri is not None: uris = [uri] tracks = [] track_map = self.core.library.lookup(uris=uris) for uri in uris: tracks.extend(track_map[uri]) tl_tracks = [] max_length = self.core._config['core']['max_tracklist_length'] for track in tracks: if self.get_length() >= max_length: raise exceptions.TracklistFull( 'Tracklist may contain at most %d tracks.' % max_length) tl_track = TlTrack(self._next_tlid, track) self._next_tlid += 1 if at_position is not None: self._tl_tracks.insert(at_position, tl_track) at_position += 1 else: self._tl_tracks.append(tl_track) tl_tracks.append(tl_track) if tl_tracks: self._increase_version() return tl_tracks def clear(self): """ Clear the tracklist. Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event. """ self._tl_tracks = [] self._increase_version() def filter(self, criteria=None, **kwargs): """ Filter the tracklist by the given criterias. A criteria consists of a model field to check and a list of values to compare it against. If the model field matches one of the values, it may be returned. Only tracks that matches all the given criterias are returned. Examples:: # Returns tracks with TLIDs 1, 2, 3, or 4 (tracklist ID) filter({'tlid': [1, 2, 3, 4]}) # Returns track with URIs 'xyz' or 'abc' filter({'uri': ['xyz', 'abc']}) # Returns track with a matching TLIDs (1, 3 or 6) and a # matching URI ('xyz' or 'abc') filter({'tlid': [1, 3, 6], 'uri': ['xyz', 'abc']}) :param criteria: on or more criteria to match by :type criteria: dict, of (string, list) pairs :rtype: list of :class:`mopidy.models.TlTrack` .. deprecated:: 1.1 Providing the criteria via ``kwargs``. """ if kwargs: deprecation.warn('core.tracklist.filter:kwargs_criteria') criteria = criteria or kwargs tlids = criteria.pop('tlid', []) validation.check_query(criteria, validation.TRACKLIST_FIELDS) validation.check_instances(tlids, int) matches = self._tl_tracks for (key, values) in criteria.items(): matches = [ ct for ct in matches if getattr(ct.track, key) in values] if tlids: matches = [ct for ct in matches if ct.tlid in tlids] return matches def move(self, start, end, to_position): """ Move the tracks in the slice ``[start:end]`` to ``to_position``. Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event. :param start: position of first track to move :type start: int :param end: position after last track to move :type end: int :param to_position: new position for the tracks :type to_position: int """ if start == end: end += 1 tl_tracks = self._tl_tracks # TODO: use validation helpers? assert start < end, 'start must be smaller than end' assert start >= 0, 'start must be at least zero' assert end <= len(tl_tracks), \ 'end can not be larger than tracklist length' assert to_position >= 0, 'to_position must be at least zero' assert to_position <= len(tl_tracks), \ 'to_position can not be larger than tracklist length' new_tl_tracks = tl_tracks[:start] + tl_tracks[end:] for tl_track in tl_tracks[start:end]: new_tl_tracks.insert(to_position, tl_track) to_position += 1 self._tl_tracks = new_tl_tracks self._increase_version() def remove(self, criteria=None, **kwargs): """ Remove the matching tracks from the tracklist. Uses :meth:`filter()` to lookup the tracks to remove. Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event. :param criteria: on or more criteria to match by :type criteria: dict :rtype: list of :class:`mopidy.models.TlTrack` that was removed .. deprecated:: 1.1 Providing the criteria via ``kwargs``. """ if kwargs: deprecation.warn('core.tracklist.remove:kwargs_criteria') tl_tracks = self.filter(criteria or kwargs) for tl_track in tl_tracks: position = self._tl_tracks.index(tl_track) del self._tl_tracks[position] self._increase_version() return tl_tracks def shuffle(self, start=None, end=None): """ Shuffles the entire tracklist. If ``start`` and ``end`` is given only shuffles the slice ``[start:end]``. Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event. :param start: position of first track to shuffle :type start: int or :class:`None` :param end: position after last track to shuffle :type end: int or :class:`None` """ tl_tracks = self._tl_tracks # TOOD: use validation helpers? if start is not None and end is not None: assert start < end, 'start must be smaller than end' if start is not None: assert start >= 0, 'start must be at least zero' if end is not None: assert end <= len(tl_tracks), 'end can not be larger than ' + \ 'tracklist length' before = tl_tracks[:start or 0] shuffled = tl_tracks[start:end] after = tl_tracks[end or len(tl_tracks):] random.shuffle(shuffled) self._tl_tracks = before + shuffled + after self._increase_version() def slice(self, start, end): """ Returns a slice of the tracklist, limited by the given start and end positions. :param start: position of first track to include in slice :type start: int :param end: position after last track to include in slice :type end: int :rtype: :class:`mopidy.models.TlTrack` """ # TODO: validate slice? return self._tl_tracks[start:end] def _mark_playing(self, tl_track): """Internal method for :class:`mopidy.core.PlaybackController`.""" if self.get_random() and tl_track in self._shuffled: self._shuffled.remove(tl_track) def _mark_unplayable(self, tl_track): """Internal method for :class:`mopidy.core.PlaybackController`.""" logger.warning('Track is not playable: %s', tl_track.track.uri) if self.get_consume() and tl_track is not None: self.remove({'tlid': [tl_track.tlid]}) if self.get_random() and tl_track in self._shuffled: self._shuffled.remove(tl_track) def _mark_played(self, tl_track): """Internal method for :class:`mopidy.core.PlaybackController`.""" if self.get_consume() and tl_track is not None: self.remove({'tlid': [tl_track.tlid]}) return True return False def _trigger_tracklist_changed(self): if self.get_random(): self._shuffled = self._tl_tracks[:] random.shuffle(self._shuffled) else: self._shuffled = [] logger.debug('Triggering event: tracklist_changed()') listener.CoreListener.send('tracklist_changed') def _trigger_options_changed(self): logger.debug('Triggering options changed event') listener.CoreListener.send('options_changed') Mopidy-2.0.0/mopidy/core/__init__.py0000664000175000017500000000060312505224626017525 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals # flake8: noqa from .actor import Core from .history import HistoryController from .library import LibraryController from .listener import CoreListener from .mixer import MixerController from .playback import PlaybackController, PlaybackState from .playlists import PlaylistsController from .tracklist import TracklistController Mopidy-2.0.0/mopidy/core/library.py0000664000175000017500000003357612660436420017450 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import collections import contextlib import logging import operator from mopidy import compat, exceptions, models from mopidy.compat import urllib from mopidy.internal import deprecation, validation logger = logging.getLogger(__name__) @contextlib.contextmanager def _backend_error_handling(backend, reraise=None): try: yield except exceptions.ValidationError as e: logger.error('%s backend returned bad data: %s', backend.actor_ref.actor_class.__name__, e) except Exception as e: if reraise and isinstance(e, reraise): raise logger.exception('%s backend caused an exception.', backend.actor_ref.actor_class.__name__) class LibraryController(object): pykka_traversable = True def __init__(self, backends, core): self.backends = backends self.core = core def _get_backend(self, uri): uri_scheme = urllib.parse.urlparse(uri).scheme return self.backends.with_library.get(uri_scheme, None) def _get_backends_to_uris(self, uris): if uris: backends_to_uris = collections.defaultdict(list) for uri in uris: backend = self._get_backend(uri) if backend is not None: backends_to_uris[backend].append(uri) else: backends_to_uris = dict([ (b, None) for b in self.backends.with_library.values()]) return backends_to_uris def browse(self, uri): """ Browse directories and tracks at the given ``uri``. ``uri`` is a string which represents some directory belonging to a backend. To get the intial root directories for backends pass :class:`None` as the URI. Returns a list of :class:`mopidy.models.Ref` objects for the directories and tracks at the given ``uri``. The :class:`~mopidy.models.Ref` objects representing tracks keep the track's original URI. A matching pair of objects can look like this:: Track(uri='dummy:/foo.mp3', name='foo', artists=..., album=...) Ref.track(uri='dummy:/foo.mp3', name='foo') The :class:`~mopidy.models.Ref` objects representing directories have backend specific URIs. These are opaque values, so no one but the backend that created them should try and derive any meaning from them. The only valid exception to this is checking the scheme, as it is used to route browse requests to the correct backend. For example, the dummy library's ``/bar`` directory could be returned like this:: Ref.directory(uri='dummy:directory:/bar', name='bar') :param string uri: URI to browse :rtype: list of :class:`mopidy.models.Ref` .. versionadded:: 0.18 """ if uri is None: return self._roots() elif not uri.strip(): return [] validation.check_uri(uri) return self._browse(uri) def _roots(self): directories = set() backends = self.backends.with_library_browse.values() futures = {b: b.library.root_directory for b in backends} for backend, future in futures.items(): with _backend_error_handling(backend): root = future.get() validation.check_instance(root, models.Ref) directories.add(root) return sorted(directories, key=operator.attrgetter('name')) def _browse(self, uri): scheme = urllib.parse.urlparse(uri).scheme backend = self.backends.with_library_browse.get(scheme) if not backend: return [] with _backend_error_handling(backend): result = backend.library.browse(uri).get() validation.check_instances(result, models.Ref) return result return [] def get_distinct(self, field, query=None): """ List distinct values for a given field from the library. This has mainly been added to support the list commands the MPD protocol supports in a more sane fashion. Other frontends are not recommended to use this method. :param string field: One of ``track``, ``artist``, ``albumartist``, ``album``, ``composer``, ``performer``, ``date`` or ``genre``. :param dict query: Query to use for limiting results, see :meth:`search` for details about the query format. :rtype: set of values corresponding to the requested field type. .. versionadded:: 1.0 """ validation.check_choice(field, validation.DISTINCT_FIELDS) query is None or validation.check_query(query) # TODO: normalize? result = set() futures = {b: b.library.get_distinct(field, query) for b in self.backends.with_library.values()} for backend, future in futures.items(): with _backend_error_handling(backend): values = future.get() if values is not None: validation.check_instances(values, compat.text_type) result.update(values) return result def get_images(self, uris): """Lookup the images for the given URIs Backends can use this to return image URIs for any URI they know about be it tracks, albums, playlists. The lookup result is a dictionary mapping the provided URIs to lists of images. Unknown URIs or URIs the corresponding backend couldn't find anything for will simply return an empty list for that URI. :param uris: list of URIs to find images for :type uris: list of string :rtype: {uri: tuple of :class:`mopidy.models.Image`} .. versionadded:: 1.0 """ validation.check_uris(uris) futures = { backend: backend.library.get_images(backend_uris) for (backend, backend_uris) in self._get_backends_to_uris(uris).items() if backend_uris} results = {uri: tuple() for uri in uris} for backend, future in futures.items(): with _backend_error_handling(backend): if future.get() is None: continue validation.check_instance(future.get(), collections.Mapping) for uri, images in future.get().items(): if uri not in uris: raise exceptions.ValidationError( 'Got unknown image URI: %s' % uri) validation.check_instances(images, models.Image) results[uri] += tuple(images) return results def find_exact(self, query=None, uris=None, **kwargs): """Search the library for tracks where ``field`` is ``values``. .. deprecated:: 1.0 Use :meth:`search` with ``exact`` set. """ deprecation.warn('core.library.find_exact') return self.search(query=query, uris=uris, exact=True, **kwargs) def lookup(self, uri=None, uris=None): """ Lookup the given URIs. If the URI expands to multiple tracks, the returned list will contain them all. :param uri: track URI :type uri: string or :class:`None` :param uris: track URIs :type uris: list of string or :class:`None` :rtype: list of :class:`mopidy.models.Track` if uri was set or {uri: list of :class:`mopidy.models.Track`} if uris was set. .. versionadded:: 1.0 The ``uris`` argument. .. deprecated:: 1.0 The ``uri`` argument. Use ``uris`` instead. """ if sum(o is not None for o in [uri, uris]) != 1: raise ValueError('Exactly one of "uri" or "uris" must be set') uris is None or validation.check_uris(uris) uri is None or validation.check_uri(uri) if uri: deprecation.warn('core.library.lookup:uri_arg') if uri is not None: uris = [uri] futures = {} results = {u: [] for u in uris} # TODO: lookup(uris) to backend APIs for backend, backend_uris in self._get_backends_to_uris(uris).items(): for u in backend_uris: futures[(backend, u)] = backend.library.lookup(u) for (backend, u), future in futures.items(): with _backend_error_handling(backend): result = future.get() if result is not None: validation.check_instances(result, models.Track) # TODO Consider making Track.uri field mandatory, and # then remove this filtering of tracks without URIs. results[u] = [r for r in result if r.uri] if uri: return results[uri] return results def refresh(self, uri=None): """ Refresh library. Limit to URI and below if an URI is given. :param uri: directory or track URI :type uri: string """ uri is None or validation.check_uri(uri) futures = {} backends = {} uri_scheme = urllib.parse.urlparse(uri).scheme if uri else None for backend_scheme, backend in self.backends.with_library.items(): backends.setdefault(backend, set()).add(backend_scheme) for backend, backend_schemes in backends.items(): if uri_scheme is None or uri_scheme in backend_schemes: futures[backend] = backend.library.refresh(uri) for backend, future in futures.items(): with _backend_error_handling(backend): future.get() def search(self, query=None, uris=None, exact=False, **kwargs): """ Search the library for tracks where ``field`` contains ``values``. ``field`` can be one of ``uri``, ``track_name``, ``album``, ``artist``, ``albumartist``, ``composer``, ``performer``, ``track_no``, ``genre``, ``date``, ``comment`` or ``any``. If ``uris`` is given, the search is limited to results from within the URI roots. For example passing ``uris=['file:']`` will limit the search to the local backend. Examples:: # Returns results matching 'a' in any backend search({'any': ['a']}) # Returns results matching artist 'xyz' in any backend search({'artist': ['xyz']}) # Returns results matching 'a' and 'b' and artist 'xyz' in any # backend search({'any': ['a', 'b'], 'artist': ['xyz']}) # Returns results matching 'a' if within the given URI roots # "file:///media/music" and "spotify:" search({'any': ['a']}, uris=['file:///media/music', 'spotify:']) # Returns results matching artist 'xyz' and 'abc' in any backend search({'artist': ['xyz', 'abc']}) :param query: one or more queries to search for :type query: dict :param uris: zero or more URI roots to limit the search to :type uris: list of string or :class:`None` :param exact: if the search should use exact matching :type exact: :class:`bool` :rtype: list of :class:`mopidy.models.SearchResult` .. versionadded:: 1.0 The ``exact`` keyword argument, which replaces :meth:`find_exact`. .. deprecated:: 1.0 Previously, if the query was empty, and the backend could support it, all available tracks were returned. This has not changed, but it is strongly discouraged. No new code should rely on this behavior. .. deprecated:: 1.1 Providing the search query via ``kwargs`` is no longer supported. """ query = _normalize_query(query or kwargs) uris is None or validation.check_uris(uris) query is None or validation.check_query(query) validation.check_boolean(exact) if kwargs: deprecation.warn('core.library.search:kwargs_query') if not query: deprecation.warn('core.library.search:empty_query') futures = {} for backend, backend_uris in self._get_backends_to_uris(uris).items(): futures[backend] = backend.library.search( query=query, uris=backend_uris, exact=exact) # Some of our tests check for LookupError to catch bad queries. This is # silly and should be replaced with query validation before passing it # to the backends. reraise = (TypeError, LookupError) results = [] for backend, future in futures.items(): try: with _backend_error_handling(backend, reraise=reraise): result = future.get() if result is not None: validation.check_instance(result, models.SearchResult) results.append(result) except TypeError: backend_name = backend.actor_ref.actor_class.__name__ logger.warning( '%s does not implement library.search() with "exact" ' 'support. Please upgrade it.', backend_name) return results def _normalize_query(query): broken_client = False # TODO: this breaks if query is not a dictionary like object... for (field, values) in query.items(): if isinstance(values, compat.string_types): broken_client = True query[field] = [values] if broken_client: logger.warning( 'A client or frontend made a broken library search. Values in ' 'queries must be lists of strings, not a string. Please check what' ' sent this query and file a bug. Query: %s', query) if not query: logger.warning( 'A client or frontend made a library search with an empty query. ' 'This is strongly discouraged. Please check what sent this query ' 'and file a bug.') return query Mopidy-2.0.0/mopidy/core/listener.py0000664000175000017500000001224312660436420017615 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mopidy import listener class CoreListener(listener.Listener): """ Marker interface for recipients of events sent by the core actor. Any Pykka actor that mixes in this class will receive calls to the methods defined here when the corresponding events happen in the core actor. This interface is used both for looking up what actors to notify of the events, and for providing default implementations for those listeners that are not interested in all events. """ @staticmethod def send(event, **kwargs): """Helper to allow calling of core listener events""" listener.send(CoreListener, event, **kwargs) def on_event(self, event, **kwargs): """ Called on all events. *MAY* be implemented by actor. By default, this method forwards the event to the specific event methods. :param event: the event name :type event: string :param kwargs: any other arguments to the specific event handlers """ # Just delegate to parent, entry mostly for docs. super(CoreListener, self).on_event(event, **kwargs) def track_playback_paused(self, tl_track, time_position): """ Called whenever track playback is paused. *MAY* be implemented by actor. :param tl_track: the track that was playing when playback paused :type tl_track: :class:`mopidy.models.TlTrack` :param time_position: the time position in milliseconds :type time_position: int """ pass def track_playback_resumed(self, tl_track, time_position): """ Called whenever track playback is resumed. *MAY* be implemented by actor. :param tl_track: the track that was playing when playback resumed :type tl_track: :class:`mopidy.models.TlTrack` :param time_position: the time position in milliseconds :type time_position: int """ pass def track_playback_started(self, tl_track): """ Called whenever a new track starts playing. *MAY* be implemented by actor. :param tl_track: the track that just started playing :type tl_track: :class:`mopidy.models.TlTrack` """ pass def track_playback_ended(self, tl_track, time_position): """ Called whenever playback of a track ends. *MAY* be implemented by actor. :param tl_track: the track that was played before playback stopped :type tl_track: :class:`mopidy.models.TlTrack` :param time_position: the time position in milliseconds :type time_position: int """ pass def playback_state_changed(self, old_state, new_state): """ Called whenever playback state is changed. *MAY* be implemented by actor. :param old_state: the state before the change :type old_state: string from :class:`mopidy.core.PlaybackState` field :param new_state: the state after the change :type new_state: string from :class:`mopidy.core.PlaybackState` field """ pass def tracklist_changed(self): """ Called whenever the tracklist is changed. *MAY* be implemented by actor. """ pass def playlists_loaded(self): """ Called when playlists are loaded or refreshed. *MAY* be implemented by actor. """ pass def playlist_changed(self, playlist): """ Called whenever a playlist is changed. *MAY* be implemented by actor. :param playlist: the changed playlist :type playlist: :class:`mopidy.models.Playlist` """ pass def playlist_deleted(self, uri): """ Called whenever a playlist is deleted. *MAY* be implemented by actor. :param uri: the URI of the deleted playlist :type uri: string """ pass def options_changed(self): """ Called whenever an option is changed. *MAY* be implemented by actor. """ pass def volume_changed(self, volume): """ Called whenever the volume is changed. *MAY* be implemented by actor. :param volume: the new volume in the range [0..100] :type volume: int """ pass def mute_changed(self, mute): """ Called whenever the mute state is changed. *MAY* be implemented by actor. :param mute: the new mute state :type mute: boolean """ pass def seeked(self, time_position): """ Called whenever the time position changes by an unexpected amount, e.g. at seek to a new time position. *MAY* be implemented by actor. :param time_position: the position that was seeked to in milliseconds :type time_position: int """ pass def stream_title_changed(self, title): """ Called whenever the currently playing stream title changes. *MAY* be implemented by actor. :param title: the new stream title :type title: string """ pass Mopidy-2.0.0/mopidy/core/playlists.py0000664000175000017500000002634612660436420020025 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import contextlib import logging from mopidy import exceptions from mopidy.compat import urllib from mopidy.core import listener from mopidy.internal import deprecation, validation from mopidy.models import Playlist, Ref logger = logging.getLogger(__name__) @contextlib.contextmanager def _backend_error_handling(backend, reraise=None): try: yield except exceptions.ValidationError as e: logger.error('%s backend returned bad data: %s', backend.actor_ref.actor_class.__name__, e) except Exception as e: if reraise and isinstance(e, reraise): raise logger.exception('%s backend caused an exception.', backend.actor_ref.actor_class.__name__) class PlaylistsController(object): pykka_traversable = True def __init__(self, backends, core): self.backends = backends self.core = core def get_uri_schemes(self): """ Get the list of URI schemes that support playlists. :rtype: list of string .. versionadded:: 2.0 """ return list(sorted(self.backends.with_playlists.keys())) def as_list(self): """ Get a list of the currently available playlists. Returns a list of :class:`~mopidy.models.Ref` objects referring to the playlists. In other words, no information about the playlists' content is given. :rtype: list of :class:`mopidy.models.Ref` .. versionadded:: 1.0 """ futures = { backend: backend.playlists.as_list() for backend in set(self.backends.with_playlists.values())} results = [] for b, future in futures.items(): try: with _backend_error_handling(b, reraise=NotImplementedError): playlists = future.get() if playlists is not None: validation.check_instances(playlists, Ref) results.extend(playlists) except NotImplementedError: backend_name = b.actor_ref.actor_class.__name__ logger.warning( '%s does not implement playlists.as_list(). ' 'Please upgrade it.', backend_name) return results def get_items(self, uri): """ Get the items in a playlist specified by ``uri``. Returns a list of :class:`~mopidy.models.Ref` objects referring to the playlist's items. If a playlist with the given ``uri`` doesn't exist, it returns :class:`None`. :rtype: list of :class:`mopidy.models.Ref`, or :class:`None` .. versionadded:: 1.0 """ validation.check_uri(uri) uri_scheme = urllib.parse.urlparse(uri).scheme backend = self.backends.with_playlists.get(uri_scheme, None) if not backend: return None with _backend_error_handling(backend): items = backend.playlists.get_items(uri).get() items is None or validation.check_instances(items, Ref) return items return None def get_playlists(self, include_tracks=True): """ Get the available playlists. :rtype: list of :class:`mopidy.models.Playlist` .. versionchanged:: 1.0 If you call the method with ``include_tracks=False``, the :attr:`~mopidy.models.Playlist.last_modified` field of the returned playlists is no longer set. .. deprecated:: 1.0 Use :meth:`as_list` and :meth:`get_items` instead. """ deprecation.warn('core.playlists.get_playlists') playlist_refs = self.as_list() if include_tracks: playlists = {r.uri: self.lookup(r.uri) for r in playlist_refs} # Use the playlist name from as_list() because it knows about any # playlist folder hierarchy, which lookup() does not. return [ playlists[r.uri].replace(name=r.name) for r in playlist_refs if playlists[r.uri] is not None] else: return [ Playlist(uri=r.uri, name=r.name) for r in playlist_refs] playlists = deprecation.deprecated_property(get_playlists) """ .. deprecated:: 1.0 Use :meth:`as_list` and :meth:`get_items` instead. """ def create(self, name, uri_scheme=None): """ Create a new playlist. If ``uri_scheme`` matches an URI scheme handled by a current backend, that backend is asked to create the playlist. If ``uri_scheme`` is :class:`None` or doesn't match a current backend, the first backend is asked to create the playlist. All new playlists must be created by calling this method, and **not** by creating new instances of :class:`mopidy.models.Playlist`. :param name: name of the new playlist :type name: string :param uri_scheme: use the backend matching the URI scheme :type uri_scheme: string :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ if uri_scheme in self.backends.with_playlists: backends = [self.backends.with_playlists[uri_scheme]] else: backends = self.backends.with_playlists.values() for backend in backends: with _backend_error_handling(backend): result = backend.playlists.create(name).get() if result is None: continue validation.check_instance(result, Playlist) listener.CoreListener.send('playlist_changed', playlist=result) return result return None def delete(self, uri): """ Delete playlist identified by the URI. If the URI doesn't match the URI schemes handled by the current backends, nothing happens. :param uri: URI of the playlist to delete :type uri: string """ validation.check_uri(uri) uri_scheme = urllib.parse.urlparse(uri).scheme backend = self.backends.with_playlists.get(uri_scheme, None) if not backend: return None # TODO: error reporting to user with _backend_error_handling(backend): backend.playlists.delete(uri).get() # TODO: error detection and reporting to user listener.CoreListener.send('playlist_deleted', uri=uri) # TODO: return value? def filter(self, criteria=None, **kwargs): """ Filter playlists by the given criterias. Examples:: # Returns track with name 'a' filter({'name': 'a'}) # Returns track with URI 'xyz' filter({'uri': 'xyz'}) # Returns track with name 'a' and URI 'xyz' filter({'name': 'a', 'uri': 'xyz'}) :param criteria: one or more criteria to match by :type criteria: dict :rtype: list of :class:`mopidy.models.Playlist` .. deprecated:: 1.0 Use :meth:`as_list` and filter yourself. """ deprecation.warn('core.playlists.filter') criteria = criteria or kwargs validation.check_query( criteria, validation.PLAYLIST_FIELDS, list_values=False) matches = self.playlists # TODO: stop using self playlists for (key, value) in criteria.iteritems(): matches = filter(lambda p: getattr(p, key) == value, matches) return matches def lookup(self, uri): """ Lookup playlist with given URI in both the set of playlists and in any other playlist sources. Returns :class:`None` if not found. :param uri: playlist URI :type uri: string :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ uri_scheme = urllib.parse.urlparse(uri).scheme backend = self.backends.with_playlists.get(uri_scheme, None) if not backend: return None with _backend_error_handling(backend): playlist = backend.playlists.lookup(uri).get() playlist is None or validation.check_instance(playlist, Playlist) return playlist return None # TODO: there is an inconsistency between library.refresh(uri) and this # call, not sure how to sort this out. def refresh(self, uri_scheme=None): """ Refresh the playlists in :attr:`playlists`. If ``uri_scheme`` is :class:`None`, all backends are asked to refresh. If ``uri_scheme`` is an URI scheme handled by a backend, only that backend is asked to refresh. If ``uri_scheme`` doesn't match any current backend, nothing happens. :param uri_scheme: limit to the backend matching the URI scheme :type uri_scheme: string """ # TODO: check: uri_scheme is None or uri_scheme? futures = {} backends = {} playlists_loaded = False for backend_scheme, backend in self.backends.with_playlists.items(): backends.setdefault(backend, set()).add(backend_scheme) for backend, backend_schemes in backends.items(): if uri_scheme is None or uri_scheme in backend_schemes: futures[backend] = backend.playlists.refresh() for backend, future in futures.items(): with _backend_error_handling(backend): future.get() playlists_loaded = True if playlists_loaded: listener.CoreListener.send('playlists_loaded') def save(self, playlist): """ Save the playlist. For a playlist to be saveable, it must have the ``uri`` attribute set. You must not set the ``uri`` atribute yourself, but use playlist objects returned by :meth:`create` or retrieved from :attr:`playlists`, which will always give you saveable playlists. The method returns the saved playlist. The return playlist may differ from the saved playlist. E.g. if the playlist name was changed, the returned playlist may have a different URI. The caller of this method must throw away the playlist sent to this method, and use the returned playlist instead. If the playlist's URI isn't set or doesn't match the URI scheme of a current backend, nothing is done and :class:`None` is returned. :param playlist: the playlist :type playlist: :class:`mopidy.models.Playlist` :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ validation.check_instance(playlist, Playlist) if playlist.uri is None: return # TODO: log this problem? uri_scheme = urllib.parse.urlparse(playlist.uri).scheme backend = self.backends.with_playlists.get(uri_scheme, None) if not backend: return None # TODO: we let AssertionError error through due to legacy tests :/ with _backend_error_handling(backend, reraise=AssertionError): playlist = backend.playlists.save(playlist).get() playlist is None or validation.check_instance(playlist, Playlist) if playlist: listener.CoreListener.send( 'playlist_changed', playlist=playlist) return playlist return None Mopidy-2.0.0/mopidy/core/mixer.py0000664000175000017500000000524512575004517017123 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import contextlib import logging from mopidy import exceptions from mopidy.internal import validation logger = logging.getLogger(__name__) @contextlib.contextmanager def _mixer_error_handling(mixer): try: yield except exceptions.ValidationError as e: logger.error('%s mixer returned bad data: %s', mixer.actor_ref.actor_class.__name__, e) except Exception: logger.exception('%s mixer caused an exception.', mixer.actor_ref.actor_class.__name__) class MixerController(object): pykka_traversable = True def __init__(self, mixer): self._mixer = mixer def get_volume(self): """Get the volume. Integer in range [0..100] or :class:`None` if unknown. The volume scale is linear. """ if self._mixer is None: return None with _mixer_error_handling(self._mixer): volume = self._mixer.get_volume().get() volume is None or validation.check_integer(volume, min=0, max=100) return volume return None def set_volume(self, volume): """Set the volume. The volume is defined as an integer in range [0..100]. The volume scale is linear. Returns :class:`True` if call is successful, otherwise :class:`False`. """ validation.check_integer(volume, min=0, max=100) if self._mixer is None: return False # TODO: 2.0 return None with _mixer_error_handling(self._mixer): result = self._mixer.set_volume(volume).get() validation.check_instance(result, bool) return result return False def get_mute(self): """Get mute state. :class:`True` if muted, :class:`False` unmuted, :class:`None` if unknown. """ if self._mixer is None: return None with _mixer_error_handling(self._mixer): mute = self._mixer.get_mute().get() mute is None or validation.check_instance(mute, bool) return mute return None def set_mute(self, mute): """Set mute state. :class:`True` to mute, :class:`False` to unmute. Returns :class:`True` if call is successful, otherwise :class:`False`. """ validation.check_boolean(mute) if self._mixer is None: return False # TODO: 2.0 return None with _mixer_error_handling(self._mixer): result = self._mixer.set_mute(bool(mute)).get() validation.check_instance(result, bool) return result return False Mopidy-2.0.0/mopidy/core/actor.py0000664000175000017500000001442712660436420017106 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import collections import itertools import logging import pykka from mopidy import audio, backend, mixer from mopidy.audio import PlaybackState from mopidy.core.history import HistoryController from mopidy.core.library import LibraryController from mopidy.core.listener import CoreListener from mopidy.core.mixer import MixerController from mopidy.core.playback import PlaybackController from mopidy.core.playlists import PlaylistsController from mopidy.core.tracklist import TracklistController from mopidy.internal import versioning from mopidy.internal.deprecation import deprecated_property logger = logging.getLogger(__name__) class Core( pykka.ThreadingActor, audio.AudioListener, backend.BackendListener, mixer.MixerListener): library = None """An instance of :class:`~mopidy.core.LibraryController`""" history = None """An instance of :class:`~mopidy.core.HistoryController`""" mixer = None """An instance of :class:`~mopidy.core.MixerController`""" playback = None """An instance of :class:`~mopidy.core.PlaybackController`""" playlists = None """An instance of :class:`~mopidy.core.PlaylistsController`""" tracklist = None """An instance of :class:`~mopidy.core.TracklistController`""" def __init__(self, config=None, mixer=None, backends=None, audio=None): super(Core, self).__init__() self._config = config self.backends = Backends(backends) self.library = LibraryController(backends=self.backends, core=self) self.history = HistoryController() self.mixer = MixerController(mixer=mixer) self.playback = PlaybackController( audio=audio, backends=self.backends, core=self) self.playlists = PlaylistsController(backends=self.backends, core=self) self.tracklist = TracklistController(core=self) self.audio = audio def get_uri_schemes(self): """Get list of URI schemes we can handle""" futures = [b.uri_schemes for b in self.backends] results = pykka.get_all(futures) uri_schemes = itertools.chain(*results) return sorted(uri_schemes) uri_schemes = deprecated_property(get_uri_schemes) """ .. deprecated:: 1.0 Use :meth:`get_uri_schemes` instead. """ def get_version(self): """Get version of the Mopidy core API""" return versioning.get_version() version = deprecated_property(get_version) """ .. deprecated:: 1.0 Use :meth:`get_version` instead. """ def reached_end_of_stream(self): self.playback._on_end_of_stream() def stream_changed(self, uri): self.playback._on_stream_changed(uri) def position_changed(self, position): self.playback._on_position_changed(position) def state_changed(self, old_state, new_state, target_state): # XXX: This is a temporary fix for issue #232 while we wait for a more # permanent solution with the implementation of issue #234. When the # Spotify play token is lost, the Spotify backend pauses audio # playback, but mopidy.core doesn't know this, so we need to update # mopidy.core's state to match the actual state in mopidy.audio. If we # don't do this, clients will think that we're still playing. # We ignore cases when target state is set as this is buffering # updates (at least for now) and we need to get #234 fixed... if (new_state == PlaybackState.PAUSED and not target_state and self.playback.state != PlaybackState.PAUSED): self.playback.state = new_state self.playback._trigger_track_playback_paused() def playlists_loaded(self): # Forward event from backend to frontends CoreListener.send('playlists_loaded') def volume_changed(self, volume): # Forward event from mixer to frontends CoreListener.send('volume_changed', volume=volume) def mute_changed(self, mute): # Forward event from mixer to frontends CoreListener.send('mute_changed', mute=mute) def tags_changed(self, tags): if not self.audio or 'title' not in tags: return tags = self.audio.get_current_tags().get() if not tags: return # TODO: this limits us to only streams that set organization, this is # a hack to make sure we don't emit stream title changes for plain # tracks. We need a better way to decide if something is a stream. if 'title' in tags and tags['title'] and 'organization' in tags: title = tags['title'][0] self.playback._stream_title = title CoreListener.send('stream_title_changed', title=title) class Backends(list): def __init__(self, backends): super(Backends, self).__init__(backends) self.with_library = collections.OrderedDict() self.with_library_browse = collections.OrderedDict() self.with_playback = collections.OrderedDict() self.with_playlists = collections.OrderedDict() backends_by_scheme = {} def name(b): return b.actor_ref.actor_class.__name__ for b in backends: try: has_library = b.has_library().get() has_library_browse = b.has_library_browse().get() has_playback = b.has_playback().get() has_playlists = b.has_playlists().get() except Exception: self.remove(b) logger.exception('Fetching backend info for %s failed', b.actor_ref.actor_class.__name__) for scheme in b.uri_schemes.get(): assert scheme not in backends_by_scheme, ( 'Cannot add URI scheme "%s" for %s, ' 'it is already handled by %s' ) % (scheme, name(b), name(backends_by_scheme[scheme])) backends_by_scheme[scheme] = b if has_library: self.with_library[scheme] = b if has_library_browse: self.with_library_browse[scheme] = b if has_playback: self.with_playback[scheme] = b if has_playlists: self.with_playlists[scheme] = b Mopidy-2.0.0/mopidy/internal/0000775000175000017500000000000012660436443016305 5ustar jodaljodal00000000000000Mopidy-2.0.0/mopidy/internal/timer.py0000664000175000017500000000053112660436420017771 0ustar jodaljodal00000000000000from __future__ import unicode_literals import contextlib import logging import time from mopidy.internal import log logger = logging.getLogger(__name__) @contextlib.contextmanager def time_logger(name, level=log.TRACE_LOG_LEVEL): start = time.time() yield logger.log(level, '%s took %dms', name, (time.time() - start) * 1000) Mopidy-2.0.0/mopidy/internal/formatting.py0000664000175000017500000000162412575004517021032 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import re import unicodedata def indent(string, places=4, linebreak='\n', singles=False): lines = string.split(linebreak) if not singles and len(lines) == 1: return string for i, line in enumerate(lines): lines[i] = ' ' * places + line result = linebreak.join(lines) if not singles: result = linebreak + result return result def slugify(value): """ Converts to lowercase, removes non-word characters (alphanumerics and underscores) and converts spaces to hyphens. Also strips leading and trailing whitespace. This function is based on Django's slugify implementation. """ value = unicodedata.normalize('NFKD', value) value = value.encode('ascii', 'ignore').decode('ascii') value = re.sub(r'[^\w\s-]', '', value).strip().lower() return re.sub(r'[-\s]+', '-', value) Mopidy-2.0.0/mopidy/internal/process.py0000664000175000017500000000313412660436420020331 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging import threading import pykka from mopidy.compat import thread logger = logging.getLogger(__name__) def exit_process(): logger.debug('Interrupting main...') thread.interrupt_main() logger.debug('Interrupted main') def sigterm_handler(signum, frame): """A :mod:`signal` handler which will exit the program on signal. This function is not called when the process' main thread is running a GLib mainloop. In that case, the GLib mainloop must listen for SIGTERM signals and quit itself. For Mopidy subcommands that does not run the GLib mainloop, this handler ensures a proper shutdown of the process on SIGTERM. """ logger.info('Got SIGTERM signal. Exiting...') exit_process() def stop_actors_by_class(klass): actors = pykka.ActorRegistry.get_by_class(klass) logger.debug('Stopping %d instance(s) of %s', len(actors), klass.__name__) for actor in actors: actor.stop() def stop_remaining_actors(): num_actors = len(pykka.ActorRegistry.get_all()) while num_actors: logger.error( 'There are actor threads still running, this is probably a bug') logger.debug( 'Seeing %d actor and %d non-actor thread(s): %s', num_actors, threading.active_count() - num_actors, ', '.join([t.name for t in threading.enumerate()])) logger.debug('Stopping %d actor(s)...', num_actors) pykka.ActorRegistry.stop_all() num_actors = len(pykka.ActorRegistry.get_all()) logger.debug('All actors stopped.') Mopidy-2.0.0/mopidy/internal/http.py0000664000175000017500000000317712647257461017654 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging import time import requests from mopidy import httpclient logger = logging.getLogger(__name__) def get_requests_session(proxy_config, user_agent): proxy = httpclient.format_proxy(proxy_config) full_user_agent = httpclient.format_user_agent(user_agent) session = requests.Session() session.proxies.update({'http': proxy, 'https': proxy}) session.headers.update({'user-agent': full_user_agent}) return session def download(session, uri, timeout=1.0, chunk_size=4096): try: response = session.get(uri, stream=True, timeout=timeout) except requests.exceptions.Timeout: logger.warning('Download of %r failed due to connection timeout after ' '%.3fs', uri, timeout) return None except requests.exceptions.InvalidSchema: logger.warning('Download of %r failed due to unsupported schema', uri) return None except requests.exceptions.RequestException as exc: logger.warning('Download of %r failed: %s', uri, exc) logger.debug('Download exception details', exc_info=True) return None content = [] deadline = time.time() + timeout for chunk in response.iter_content(chunk_size): content.append(chunk) if time.time() > deadline: logger.warning('Download of %r failed due to download taking more ' 'than %.3fs', uri, timeout) return None if not response.ok: logger.warning('Problem downloading %r: %s', uri, response.reason) return None return b''.join(content) Mopidy-2.0.0/mopidy/internal/gi.py0000664000175000017500000000207312660436420017253 0ustar jodaljodal00000000000000from __future__ import absolute_import, print_function, unicode_literals import sys import textwrap try: import gi gi.require_version('Gst', '1.0') from gi.repository import GLib, GObject, Gst except ImportError: print(textwrap.dedent(""" ERROR: A GObject Python package was not found. Mopidy requires GStreamer to work. GStreamer is a C library with a number of dependencies itself, and cannot be installed with the regular Python tools like pip. Please see http://docs.mopidy.com/en/latest/installation/ for instructions on how to install the required dependencies. """)) raise else: Gst.init([]) gi.require_version('GstPbutils', '1.0') from gi.repository import GstPbutils REQUIRED_GST_VERSION = (1, 2, 3) if Gst.version() < REQUIRED_GST_VERSION: sys.exit( 'ERROR: Mopidy requires GStreamer >= %s, but found %s.' % ( '.'.join(map(str, REQUIRED_GST_VERSION)), Gst.version_string())) __all__ = [ 'GLib', 'GObject', 'Gst', 'GstPbutils', 'gi', ] Mopidy-2.0.0/mopidy/internal/versioning.py0000664000175000017500000000130212660436420021031 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import os import subprocess import mopidy def get_version(): try: return get_git_version() except EnvironmentError: return mopidy.__version__ def get_git_version(): project_dir = os.path.abspath( os.path.join(os.path.dirname(mopidy.__file__), '..')) process = subprocess.Popen( ['git', 'describe'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=project_dir) if process.wait() != 0: raise EnvironmentError('Execution of "git describe" failed') version = process.stdout.read().strip() if version.startswith(b'v'): version = version[1:] return version Mopidy-2.0.0/mopidy/internal/deps.py0000664000175000017500000001124212660436420017605 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import functools import os import platform import sys import pkg_resources from mopidy.internal import formatting from mopidy.internal.gi import Gst, gi def format_dependency_list(adapters=None): if adapters is None: dist_names = set([ ep.dist.project_name for ep in pkg_resources.iter_entry_points('mopidy.ext') if ep.dist.project_name != 'Mopidy']) dist_infos = [ functools.partial(pkg_info, dist_name) for dist_name in dist_names] adapters = [ executable_info, platform_info, python_info, functools.partial(pkg_info, 'Mopidy', True) ] + dist_infos + [ gstreamer_info, ] return '\n'.join([_format_dependency(a()) for a in adapters]) def _format_dependency(dep_info): lines = [] if 'version' not in dep_info: lines.append('%s: not found' % dep_info['name']) else: if 'path' in dep_info: source = ' from %s' % dep_info['path'] else: source = '' lines.append('%s: %s%s' % ( dep_info['name'], dep_info['version'], source, )) if 'other' in dep_info: lines.append(' Detailed information: %s' % ( formatting.indent(dep_info['other'], places=4)),) if dep_info.get('dependencies', []): for sub_dep_info in dep_info['dependencies']: sub_dep_lines = _format_dependency(sub_dep_info) lines.append( formatting.indent(sub_dep_lines, places=2, singles=True)) return '\n'.join(lines) def executable_info(): return { 'name': 'Executable', 'version': sys.argv[0], } def platform_info(): return { 'name': 'Platform', 'version': platform.platform(), } def python_info(): return { 'name': 'Python', 'version': '%s %s' % ( platform.python_implementation(), platform.python_version()), 'path': os.path.dirname(platform.__file__), } def pkg_info(project_name=None, include_extras=False): if project_name is None: project_name = 'Mopidy' try: distribution = pkg_resources.get_distribution(project_name) extras = include_extras and distribution.extras or [] dependencies = [ pkg_info(d) for d in distribution.requires(extras)] return { 'name': project_name, 'version': distribution.version, 'path': distribution.location, 'dependencies': dependencies, } except pkg_resources.ResolutionError: return { 'name': project_name, } def gstreamer_info(): other = [] other.append('Python wrapper: python-gi %s' % gi.__version__) found_elements = [] missing_elements = [] for name, status in _gstreamer_check_elements(): if status: found_elements.append(name) else: missing_elements.append(name) other.append('Relevant elements:') other.append(' Found:') for element in found_elements: other.append(' %s' % element) if not found_elements: other.append(' none') other.append(' Not found:') for element in missing_elements: other.append(' %s' % element) if not missing_elements: other.append(' none') return { 'name': 'GStreamer', 'version': '.'.join(map(str, Gst.version())), 'path': os.path.dirname(gi.__file__), 'other': '\n'.join(other), } def _gstreamer_check_elements(): elements_to_check = [ # Core playback 'uridecodebin', # External HTTP streams 'souphttpsrc', # Spotify 'appsrc', # Audio sinks 'alsasink', 'osssink', 'oss4sink', 'pulsesink', # MP3 encoding and decoding # # One of flump3dec, mad, and mpg123audiodec is required for MP3 # playback. 'flump3dec', 'id3demux', 'id3v2mux', 'lamemp3enc', 'mad', 'mpegaudioparse', 'mpg123audiodec', # Ogg Vorbis encoding and decoding 'vorbisdec', 'vorbisenc', 'vorbisparse', 'oggdemux', 'oggmux', 'oggparse', # Flac decoding 'flacdec', 'flacparse', # Shoutcast output 'shout2send', ] known_elements = [ factory.get_name() for factory in Gst.Registry.get().get_feature_list(Gst.ElementFactory)] return [ (element, element in known_elements) for element in elements_to_check] Mopidy-2.0.0/mopidy/internal/deprecation.py0000664000175000017500000000740112660436420021151 0ustar jodaljodal00000000000000from __future__ import unicode_literals import contextlib import re import warnings from mopidy import compat # Messages used in deprecation warnings are collected here so we can target # them easily when ignoring warnings. _MESSAGES = { # Deprecated features mpd: 'mpd.protocol.playback.pause:state_arg': 'The use of pause command w/o the PAUSE argument is deprecated.', 'mpd.protocol.current_playlist.playlist': 'Do not use this, instead use playlistinfo', # Deprecated features in audio: 'audio.emit_end_of_stream': 'audio.emit_end_of_stream() is deprecated', # Deprecated features in core libary: 'core.library.find_exact': 'library.find_exact() is deprecated', 'core.library.lookup:uri_arg': 'library.lookup() "uri" argument is deprecated', 'core.library.search:kwargs_query': 'library.search() with "kwargs" as query is deprecated', 'core.library.search:empty_query': 'library.search() with empty "query" argument deprecated', # Deprecated features in core playback: 'core.playback.get_mute': 'playback.get_mute() is deprecated', 'core.playback.set_mute': 'playback.set_mute() is deprecated', 'core.playback.get_volume': 'playback.get_volume() is deprecated', 'core.playback.set_volume': 'playback.set_volume() is deprecated', 'core.playback.play:tl_track_kwargs': 'playback.play() with "tl_track" argument is pending deprecation use ' '"tlid" instead', # Deprecated features in core playlists: 'core.playlists.filter': 'playlists.filter() is deprecated', 'core.playlists.get_playlists': 'playlists.get_playlists() is deprecated', # Deprecated features in core tracklist: 'core.tracklist.add:tracks_arg': 'tracklist.add() "tracks" argument is deprecated', 'core.tracklist.add:uri_arg': 'tracklist.add() "uri" argument is deprecated', 'core.tracklist.filter:kwargs_criteria': 'tracklist.filter() with "kwargs" as criteria is deprecated', 'core.tracklist.remove:kwargs_criteria': 'tracklist.remove() with "kwargs" as criteria is deprecated', 'core.tracklist.eot_track': 'tracklist.eot_track() is pending deprecation, use ' 'tracklist.get_eot_tlid()', 'core.tracklist.next_track': 'tracklist.next_track() is pending deprecation, use ' 'tracklist.get_next_tlid()', 'core.tracklist.previous_track': 'tracklist.previous_track() is pending deprecation, use ' 'tracklist.get_previous_tlid()', 'models.immutable.copy': 'ImmutableObject.copy() is deprecated, use ImmutableObject.replace()', } def warn(msg_id, pending=False): if pending: category = PendingDeprecationWarning else: category = DeprecationWarning warnings.warn(_MESSAGES.get(msg_id, msg_id), category) @contextlib.contextmanager def ignore(ids=None): with warnings.catch_warnings(): if isinstance(ids, compat.string_types): ids = [ids] if ids: for msg_id in ids: msg = re.escape(_MESSAGES.get(msg_id, msg_id)) warnings.filterwarnings('ignore', msg, DeprecationWarning) else: warnings.filterwarnings('ignore', category=DeprecationWarning) yield def deprecated_property( getter=None, setter=None, message='Property is deprecated'): # During development, this is a convenient place to add logging, emit # warnings, or ``assert False`` to ensure you are not using any of the # deprecated properties. # # Using inspect to find the call sites to emit proper warnings makes # parallel execution of our test suite slower than serial execution. Thus, # we don't want to add any extra overhead here by default. return property(getter, setter) Mopidy-2.0.0/mopidy/internal/validation.py0000664000175000017500000000763412660436420021016 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import collections from mopidy import compat, exceptions from mopidy.compat import urllib PLAYBACK_STATES = {'paused', 'stopped', 'playing'} SEARCH_FIELDS = { 'uri', 'track_name', 'album', 'artist', 'albumartist', 'composer', 'performer', 'track_no', 'genre', 'date', 'comment', 'any'} PLAYLIST_FIELDS = {'uri', 'name'} # TODO: add length and last_modified? TRACKLIST_FIELDS = { # TODO: add bitrate, length, disc_no, track_no, modified? 'uri', 'name', 'genre', 'date', 'comment', 'musicbrainz_id'} DISTINCT_FIELDS = { 'track', 'artist', 'albumartist', 'album', 'composer', 'performer', 'date', 'genre'} # TODO: _check_iterable(check, msg, **kwargs) + [check(a) for a in arg]? def _check_iterable(arg, msg, **kwargs): """Ensure we have an iterable which is not a string or an iterator""" if isinstance(arg, compat.string_types): raise exceptions.ValidationError(msg.format(arg=arg, **kwargs)) elif not isinstance(arg, collections.Iterable): raise exceptions.ValidationError(msg.format(arg=arg, **kwargs)) elif iter(arg) is iter(arg): raise exceptions.ValidationError(msg.format(arg=arg, **kwargs)) def check_choice(arg, choices, msg='Expected one of {choices}, not {arg!r}'): if arg not in choices: raise exceptions.ValidationError(msg.format( arg=arg, choices=tuple(choices))) def check_boolean(arg, msg='Expected a boolean, not {arg!r}'): check_instance(arg, bool, msg=msg) def check_instance(arg, cls, msg='Expected a {name} instance, not {arg!r}'): if not isinstance(arg, cls): raise exceptions.ValidationError( msg.format(arg=arg, name=cls.__name__)) def check_instances(arg, cls, msg='Expected a list of {name}, not {arg!r}'): _check_iterable(arg, msg, name=cls.__name__) if not all(isinstance(instance, cls) for instance in arg): raise exceptions.ValidationError( msg.format(arg=arg, name=cls.__name__)) def check_integer(arg, min=None, max=None): if not isinstance(arg, compat.integer_types): raise exceptions.ValidationError('Expected an integer, not %r' % arg) elif min is not None and arg < min: raise exceptions.ValidationError( 'Expected number larger or equal to %d, not %r' % (min, arg)) elif max is not None and arg > max: raise exceptions.ValidationError( 'Expected number smaller or equal to %d, not %r' % (max, arg)) def check_query(arg, fields=SEARCH_FIELDS, list_values=True): # TODO: normalize name -> track_name # TODO: normalize value -> [value] # TODO: normalize blank -> [] or just remove field? # TODO: remove list_values? if not isinstance(arg, collections.Mapping): raise exceptions.ValidationError( 'Expected a query dictionary, not {arg!r}'.format(arg=arg)) for key, value in arg.items(): check_choice(key, fields, msg='Expected query field to be one of ' '{choices}, not {arg!r}') if list_values: msg = 'Expected "{key}" to be list of strings, not {arg!r}' _check_iterable(value, msg, key=key) [_check_query_value(key, v, msg) for v in value] else: _check_query_value( key, value, 'Expected "{key}" to be a string, not {arg!r}') def _check_query_value(key, arg, msg): if not isinstance(arg, compat.string_types) or not arg.strip(): raise exceptions.ValidationError(msg.format(arg=arg, key=key)) def check_uri(arg, msg='Expected a valid URI, not {arg!r}'): if not isinstance(arg, compat.string_types): raise exceptions.ValidationError(msg.format(arg=arg)) elif urllib.parse.urlparse(arg).scheme == '': raise exceptions.ValidationError(msg.format(arg=arg)) def check_uris(arg, msg='Expected a list of URIs, not {arg!r}'): _check_iterable(arg, msg) [check_uri(a, msg) for a in arg] Mopidy-2.0.0/mopidy/internal/network.py0000664000175000017500000003106112660436420020344 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import errno import logging import re import socket import sys import threading import pykka from mopidy.internal import encoding from mopidy.internal.gi import GObject logger = logging.getLogger(__name__) class ShouldRetrySocketCall(Exception): """Indicate that attempted socket call should be retried""" def try_ipv6_socket(): """Determine if system really supports IPv6""" if not socket.has_ipv6: return False try: socket.socket(socket.AF_INET6).close() return True except IOError as error: logger.debug( 'Platform supports IPv6, but socket creation failed, ' 'disabling: %s', encoding.locale_decode(error)) return False #: Boolean value that indicates if creating an IPv6 socket will succeed. has_ipv6 = try_ipv6_socket() def create_socket(): """Create a TCP socket with or without IPv6 depending on system support""" if has_ipv6: sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) # Explicitly configure socket to work for both IPv4 and IPv6 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) return sock def format_hostname(hostname): """Format hostname for display.""" if (has_ipv6 and re.match(r'\d+.\d+.\d+.\d+', hostname) is not None): hostname = '::ffff:%s' % hostname return hostname class Server(object): """Setup listener and register it with GObject's event loop.""" def __init__(self, host, port, protocol, protocol_kwargs=None, max_connections=5, timeout=30): self.protocol = protocol self.protocol_kwargs = protocol_kwargs or {} self.max_connections = max_connections self.timeout = timeout self.server_socket = self.create_server_socket(host, port) self.register_server_socket(self.server_socket.fileno()) def create_server_socket(self, host, port): sock = create_socket() sock.setblocking(False) sock.bind((host, port)) sock.listen(1) return sock def register_server_socket(self, fileno): GObject.io_add_watch(fileno, GObject.IO_IN, self.handle_connection) def handle_connection(self, fd, flags): try: sock, addr = self.accept_connection() except ShouldRetrySocketCall: return True if self.maximum_connections_exceeded(): self.reject_connection(sock, addr) else: self.init_connection(sock, addr) return True def accept_connection(self): try: return self.server_socket.accept() except socket.error as e: if e.errno in (errno.EAGAIN, errno.EINTR): raise ShouldRetrySocketCall raise def maximum_connections_exceeded(self): return (self.max_connections is not None and self.number_of_connections() >= self.max_connections) def number_of_connections(self): return len(pykka.ActorRegistry.get_by_class(self.protocol)) def reject_connection(self, sock, addr): # FIXME provide more context in logging? logger.warning('Rejected connection from [%s]:%s', addr[0], addr[1]) try: sock.close() except socket.error: pass def init_connection(self, sock, addr): Connection( self.protocol, self.protocol_kwargs, sock, addr, self.timeout) class Connection(object): # NOTE: the callback code is _not_ run in the actor's thread, but in the # same one as the event loop. If code in the callbacks blocks, the rest of # GObject code will likely be blocked as well... # # Also note that source_remove() return values are ignored on purpose, a # false return value would only tell us that what we thought was registered # is already gone, there is really nothing more we can do. def __init__(self, protocol, protocol_kwargs, sock, addr, timeout): sock.setblocking(False) self.host, self.port = addr[:2] # IPv6 has larger addr self.sock = sock self.protocol = protocol self.protocol_kwargs = protocol_kwargs self.timeout = timeout self.send_lock = threading.Lock() self.send_buffer = b'' self.stopping = False self.recv_id = None self.send_id = None self.timeout_id = None self.actor_ref = self.protocol.start(self, **self.protocol_kwargs) self.enable_recv() self.enable_timeout() def stop(self, reason, level=logging.DEBUG): if self.stopping: logger.log(level, 'Already stopping: %s' % reason) return else: self.stopping = True logger.log(level, reason) try: self.actor_ref.stop(block=False) except pykka.ActorDeadError: pass self.disable_timeout() self.disable_recv() self.disable_send() try: self.sock.close() except socket.error: pass def queue_send(self, data): """Try to send data to client exactly as is and queue rest.""" self.send_lock.acquire(True) self.send_buffer = self.send(self.send_buffer + data) self.send_lock.release() if self.send_buffer: self.enable_send() def send(self, data): """Send data to client, return any unsent data.""" try: sent = self.sock.send(data) return data[sent:] except socket.error as e: if e.errno in (errno.EWOULDBLOCK, errno.EINTR): return data self.stop( 'Unexpected client error: %s' % encoding.locale_decode(e)) return b'' def enable_timeout(self): """Reactivate timeout mechanism.""" if self.timeout <= 0: return self.disable_timeout() self.timeout_id = GObject.timeout_add_seconds( self.timeout, self.timeout_callback) def disable_timeout(self): """Deactivate timeout mechanism.""" if self.timeout_id is None: return GObject.source_remove(self.timeout_id) self.timeout_id = None def enable_recv(self): if self.recv_id is not None: return try: self.recv_id = GObject.io_add_watch( self.sock.fileno(), GObject.IO_IN | GObject.IO_ERR | GObject.IO_HUP, self.recv_callback) except socket.error as e: self.stop('Problem with connection: %s' % e) def disable_recv(self): if self.recv_id is None: return GObject.source_remove(self.recv_id) self.recv_id = None def enable_send(self): if self.send_id is not None: return try: self.send_id = GObject.io_add_watch( self.sock.fileno(), GObject.IO_OUT | GObject.IO_ERR | GObject.IO_HUP, self.send_callback) except socket.error as e: self.stop('Problem with connection: %s' % e) def disable_send(self): if self.send_id is None: return GObject.source_remove(self.send_id) self.send_id = None def recv_callback(self, fd, flags): if flags & (GObject.IO_ERR | GObject.IO_HUP): self.stop('Bad client flags: %s' % flags) return True try: data = self.sock.recv(4096) except socket.error as e: if e.errno not in (errno.EWOULDBLOCK, errno.EINTR): self.stop('Unexpected client error: %s' % e) return True if not data: self.disable_recv() self.actor_ref.tell({'close': True}) return True try: self.actor_ref.tell({'received': data}) except pykka.ActorDeadError: self.stop('Actor is dead.') return True def send_callback(self, fd, flags): if flags & (GObject.IO_ERR | GObject.IO_HUP): self.stop('Bad client flags: %s' % flags) return True # If with can't get the lock, simply try again next time socket is # ready for sending. if not self.send_lock.acquire(False): return True try: self.send_buffer = self.send(self.send_buffer) if not self.send_buffer: self.disable_send() finally: self.send_lock.release() return True def timeout_callback(self): self.stop('Client inactive for %ds; closing connection' % self.timeout) return False class LineProtocol(pykka.ThreadingActor): """ Base class for handling line based protocols. Takes care of receiving new data from server's client code, decoding and then splitting data along line boundaries. """ #: Line terminator to use for outputed lines. terminator = '\n' #: Regex to use for spliting lines, will be set compiled version of its #: own value, or to ``terminator``s value if it is not set itself. delimiter = None #: What encoding to expect incomming data to be in, can be :class:`None`. encoding = 'utf-8' def __init__(self, connection): super(LineProtocol, self).__init__() self.connection = connection self.prevent_timeout = False self.recv_buffer = b'' if self.delimiter: self.delimiter = re.compile(self.delimiter) else: self.delimiter = re.compile(self.terminator) @property def host(self): return self.connection.host @property def port(self): return self.connection.port def on_line_received(self, line): """ Called whenever a new line is found. Should be implemented by subclasses. """ raise NotImplementedError def on_receive(self, message): """Handle messages with new data from server.""" if 'close' in message: self.connection.stop('Client most likely disconnected.') return if 'received' not in message: return self.connection.disable_timeout() self.recv_buffer += message['received'] for line in self.parse_lines(): line = self.decode(line) if line is not None: self.on_line_received(line) if not self.prevent_timeout: self.connection.enable_timeout() def on_stop(self): """Ensure that cleanup when actor stops.""" self.connection.stop('Actor is shutting down.') def parse_lines(self): """Consume new data and yield any lines found.""" while re.search(self.terminator, self.recv_buffer): line, self.recv_buffer = self.delimiter.split( self.recv_buffer, 1) yield line def encode(self, line): """ Handle encoding of line. Can be overridden by subclasses to change encoding behaviour. """ try: return line.encode(self.encoding) except UnicodeError: logger.warning( 'Stopping actor due to encode problem, data ' 'supplied by client was not valid %s', self.encoding) self.stop() def decode(self, line): """ Handle decoding of line. Can be overridden by subclasses to change decoding behaviour. """ try: return line.decode(self.encoding) except UnicodeError: logger.warning( 'Stopping actor due to decode problem, data ' 'supplied by client was not valid %s', self.encoding) self.stop() def join_lines(self, lines): if not lines: return '' return self.terminator.join(lines) + self.terminator def send_lines(self, lines): """ Send array of lines to client via connection. Join lines using the terminator that is set for this class, encode it and send it to the client. """ if not lines: return data = self.join_lines(lines) self.connection.queue_send(self.encode(data)) Mopidy-2.0.0/mopidy/internal/__init__.py0000664000175000017500000000007112575004517020412 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals Mopidy-2.0.0/mopidy/internal/jsonrpc.py0000664000175000017500000003024312614502604020327 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import inspect import json import traceback import pykka from mopidy import compat class JsonRpcWrapper(object): """ Wrap objects and make them accessible through JSON-RPC 2.0 messaging. This class takes responsibility of communicating with the objects and processing of JSON-RPC 2.0 messages. The transport of the messages over HTTP, WebSocket, TCP, or whatever is of no concern to this class. The wrapper supports exporting the methods of one or more objects. Either way, the objects must be exported with method name prefixes, called "mounts". To expose objects, add them all to the objects mapping. The key in the mapping is used as the object's mounting point in the exposed API:: jrw = JsonRpcWrapper(objects={ 'foo': foo, 'hello': lambda: 'Hello, world!', }) This will export the Python callables on the left as the JSON-RPC 2.0 method names on the right:: foo.bar() -> foo.bar foo.baz() -> foo.baz lambda -> hello Only the public methods of the mounted objects, or functions/methods included directly in the mapping, will be exposed. If a method returns a :class:`pykka.Future`, the future will be completed and its value unwrapped before the JSON-RPC wrapper returns the response. For further details on the JSON-RPC 2.0 spec, see http://www.jsonrpc.org/specification :param objects: mapping between mounting points and exposed functions or class instances :type objects: dict :param decoders: object builders to be used by :func`json.loads` :type decoders: list of functions taking a dict and returning a dict :param encoders: object serializers to be used by :func:`json.dumps` :type encoders: list of :class:`json.JSONEncoder` subclasses with the method :meth:`default` implemented """ def __init__(self, objects, decoders=None, encoders=None): if '' in objects.keys(): raise AttributeError( 'The empty string is not allowed as an object mount') self.objects = objects self.decoder = get_combined_json_decoder(decoders or []) self.encoder = get_combined_json_encoder(encoders or []) def handle_json(self, request): """ Handles an incoming request encoded as a JSON string. Returns a response as a JSON string for commands, and :class:`None` for notifications. :param request: the serialized JSON-RPC request :type request: string :rtype: string or :class:`None` """ try: request = json.loads(request, object_hook=self.decoder) except ValueError: response = JsonRpcParseError().get_response() else: response = self.handle_data(request) if response is None: return None return json.dumps(response, cls=self.encoder) def handle_data(self, request): """ Handles an incoming request in the form of a Python data structure. Returns a Python data structure for commands, or a :class:`None` for notifications. :param request: the unserialized JSON-RPC request :type request: dict :rtype: dict, list, or :class:`None` """ if isinstance(request, list): return self._handle_batch(request) else: return self._handle_single_request(request) def _handle_batch(self, requests): if not requests: return JsonRpcInvalidRequestError( data='Batch list cannot be empty').get_response() responses = [] for request in requests: response = self._handle_single_request(request) if response: responses.append(response) return responses or None def _handle_single_request(self, request): try: self._validate_request(request) args, kwargs = self._get_params(request) except JsonRpcInvalidRequestError as error: return error.get_response() try: method = self._get_method(request['method']) try: result = method(*args, **kwargs) if self._is_notification(request): return None result = self._unwrap_result(result) return { 'jsonrpc': '2.0', 'id': request['id'], 'result': result, } except TypeError as error: raise JsonRpcInvalidParamsError(data={ 'type': error.__class__.__name__, 'message': compat.text_type(error), 'traceback': traceback.format_exc(), }) except Exception as error: raise JsonRpcApplicationError(data={ 'type': error.__class__.__name__, 'message': compat.text_type(error), 'traceback': traceback.format_exc(), }) except JsonRpcError as error: if self._is_notification(request): return None return error.get_response(request['id']) def _validate_request(self, request): if not isinstance(request, dict): raise JsonRpcInvalidRequestError( data='Request must be an object') if 'jsonrpc' not in request: raise JsonRpcInvalidRequestError( data='"jsonrpc" member must be included') if request['jsonrpc'] != '2.0': raise JsonRpcInvalidRequestError( data='"jsonrpc" value must be "2.0"') if 'method' not in request: raise JsonRpcInvalidRequestError( data='"method" member must be included') if not isinstance(request['method'], compat.text_type): raise JsonRpcInvalidRequestError( data='"method" must be a string') def _get_params(self, request): if 'params' not in request: return [], {} params = request['params'] if isinstance(params, list): return params, {} elif isinstance(params, dict): return [], params else: raise JsonRpcInvalidRequestError( data='"params", if given, must be an array or an object') def _get_method(self, method_path): if callable(self.objects.get(method_path, None)): # The mounted object is the callable return self.objects[method_path] # The mounted object contains the callable if '.' not in method_path: raise JsonRpcMethodNotFoundError( data='Could not find object mount in method name "%s"' % ( method_path)) mount, method_name = method_path.rsplit('.', 1) if method_name.startswith('_'): raise JsonRpcMethodNotFoundError( data='Private methods are not exported') try: obj = self.objects[mount] except KeyError: raise JsonRpcMethodNotFoundError( data='No object found at "%s"' % mount) try: return getattr(obj, method_name) except AttributeError: raise JsonRpcMethodNotFoundError( data='Object mounted at "%s" has no member "%s"' % ( mount, method_name)) def _is_notification(self, request): return 'id' not in request def _unwrap_result(self, result): if isinstance(result, pykka.Future): result = result.get() return result class JsonRpcError(Exception): code = -32000 message = 'Unspecified server error' def __init__(self, data=None): self.data = data def get_response(self, request_id=None): response = { 'jsonrpc': '2.0', 'id': request_id, 'error': { 'code': self.code, 'message': self.message, }, } if self.data: response['error']['data'] = self.data return response class JsonRpcParseError(JsonRpcError): code = -32700 message = 'Parse error' class JsonRpcInvalidRequestError(JsonRpcError): code = -32600 message = 'Invalid Request' class JsonRpcMethodNotFoundError(JsonRpcError): code = -32601 message = 'Method not found' class JsonRpcInvalidParamsError(JsonRpcError): code = -32602 message = 'Invalid params' class JsonRpcApplicationError(JsonRpcError): code = 0 message = 'Application error' def get_combined_json_decoder(decoders): def decode(dct): for decoder in decoders: dct = decoder(dct) return dct return decode def get_combined_json_encoder(encoders): class JsonRpcEncoder(json.JSONEncoder): def default(self, obj): for encoder in encoders: try: return encoder().default(obj) except TypeError: pass # Try next encoder return json.JSONEncoder.default(self, obj) return JsonRpcEncoder class JsonRpcInspector(object): """ Inspects a group of classes and functions to create a description of what methods they can expose over JSON-RPC 2.0. To inspect one or more classes, add them all to the objects mapping. The key in the mapping is used as the classes' mounting point in the exposed API:: jri = JsonRpcInspector(objects={ 'foo': Foo, 'hello': lambda: 'Hello, world!', }) Since the inspector is based on inspecting classes and not instances, it will not include methods added dynamically. The wrapper works with instances, and it will thus export dynamically added methods as well. :param objects: mapping between mounts and exposed functions or classes :type objects: dict """ def __init__(self, objects): if '' in objects.keys(): raise AttributeError( 'The empty string is not allowed as an object mount') self.objects = objects def describe(self): """ Inspects the object and returns a data structure which describes the available properties and methods. """ methods = {} for mount, obj in self.objects.items(): if inspect.isroutine(obj): methods[mount] = self._describe_method(obj) else: obj_methods = self._get_methods(obj) for name, description in obj_methods.items(): if mount: name = '%s.%s' % (mount, name) methods[name] = description return methods def _get_methods(self, obj): methods = {} for name, value in inspect.getmembers(obj): if name.startswith('_'): continue if not inspect.isroutine(value): continue method = self._describe_method(value) if method: methods[name] = method return methods def _describe_method(self, method): return { 'description': inspect.getdoc(method), 'params': self._describe_params(method), } def _describe_params(self, method): argspec = inspect.getargspec(method) defaults = argspec.defaults and list(argspec.defaults) or [] num_args_without_default = len(argspec.args) - len(defaults) no_defaults = [None] * num_args_without_default defaults = no_defaults + defaults params = [] for arg, default in zip(argspec.args, defaults): if arg == 'self': continue params.append({'name': arg}) if argspec.defaults: for i, default in enumerate(reversed(argspec.defaults)): params[len(params) - i - 1]['default'] = default if argspec.varargs: params.append({ 'name': argspec.varargs, 'varargs': True, }) if argspec.keywords: params.append({ 'name': argspec.keywords, 'kwargs': True, }) return params Mopidy-2.0.0/mopidy/internal/encoding.py0000664000175000017500000000042012614502604020431 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import locale from mopidy import compat def locale_decode(bytestr): try: return compat.text_type(bytestr) except UnicodeError: return bytes(bytestr).decode(locale.getpreferredencoding()) Mopidy-2.0.0/mopidy/internal/path.py0000664000175000017500000001602712660436420017614 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging import os import stat import string import threading from mopidy import compat, exceptions from mopidy.compat import queue, urllib from mopidy.internal import encoding, xdg logger = logging.getLogger(__name__) XDG_DIRS = xdg.get_dirs() def get_or_create_dir(dir_path): if not isinstance(dir_path, bytes): raise ValueError('Path is not a bytestring.') dir_path = expand_path(dir_path) if os.path.isfile(dir_path): raise OSError( 'A file with the same name as the desired dir, ' '"%s", already exists.' % dir_path) elif not os.path.isdir(dir_path): logger.info('Creating dir %s', dir_path) os.makedirs(dir_path, 0o755) return dir_path def get_or_create_file(file_path, mkdir=True, content=None): if not isinstance(file_path, bytes): raise ValueError('Path is not a bytestring.') file_path = expand_path(file_path) if isinstance(content, compat.text_type): content = content.encode('utf-8') if mkdir: get_or_create_dir(os.path.dirname(file_path)) if not os.path.isfile(file_path): logger.info('Creating file %s', file_path) with open(file_path, 'wb') as fh: if content is not None: fh.write(content) return file_path def path_to_uri(path): """ Convert OS specific path to file:// URI. Accepts either unicode strings or bytestrings. The encoding of any bytestring will be maintained so that :func:`uri_to_path` can return the same bytestring. Returns a file:// URI as an unicode string. """ if isinstance(path, compat.text_type): path = path.encode('utf-8') path = urllib.parse.quote(path) return urllib.parse.urlunsplit((b'file', b'', path, b'', b'')) def uri_to_path(uri): """ Convert an URI to a OS specific path. Returns a bytestring, since the file path can contain chars with other encoding than UTF-8. If we had returned these paths as unicode strings, you wouldn't be able to look up the matching dir or file on your file system because the exact path would be lost by ignoring its encoding. """ if isinstance(uri, compat.text_type): uri = uri.encode('utf-8') return urllib.parse.unquote(urllib.parse.urlsplit(uri).path) def split_path(path): parts = [] while True: path, part = os.path.split(path) if part: parts.insert(0, part) if not path or path == b'/': break return parts def expand_path(path): # TODO: document as we want people to use this. if not isinstance(path, bytes): raise ValueError('Path is not a bytestring.') try: path = string.Template(path).substitute(XDG_DIRS) except KeyError: return None path = os.path.expanduser(path) path = os.path.abspath(path) return path def _find_worker(relative, follow, done, work, results, errors): """Worker thread for collecting stat() results. :param str relative: directory to make results relative to :param bool follow: if symlinks should be followed :param threading.Event done: event indicating that all work has been done :param queue.Queue work: queue of paths to process :param dict results: shared dictionary for storing all the stat() results :param dict errors: shared dictionary for storing any per path errors """ while not done.is_set(): try: entry, parents = work.get(block=False) except queue.Empty: continue if relative: path = os.path.relpath(entry, relative) else: path = entry try: if follow: st = os.stat(entry) else: st = os.lstat(entry) if (st.st_dev, st.st_ino) in parents: errors[path] = exceptions.FindError('Sym/hardlink loop found.') continue parents = parents + [(st.st_dev, st.st_ino)] if stat.S_ISDIR(st.st_mode): for e in os.listdir(entry): work.put((os.path.join(entry, e), parents)) elif stat.S_ISREG(st.st_mode): results[path] = st elif stat.S_ISLNK(st.st_mode): errors[path] = exceptions.FindError('Not following symlinks.') else: errors[path] = exceptions.FindError('Not a file or directory.') except OSError as e: errors[path] = exceptions.FindError( encoding.locale_decode(e.strerror), e.errno) finally: work.task_done() def _find(root, thread_count=10, relative=False, follow=False): """Threaded find implementation that provides stat results for files. Tries to protect against sym/hardlink loops by keeping an eye on parent (st_dev, st_ino) pairs. :param str root: root directory to search from, may not be a file :param int thread_count: number of workers to use, mainly useful to mitigate network lag when scanning on NFS etc. :param bool relative: if results should be relative to root or absolute :param bool follow: if symlinks should be followed """ threads = [] results = {} errors = {} done = threading.Event() work = queue.Queue() work.put((os.path.abspath(root), [])) if not relative: root = None args = (root, follow, done, work, results, errors) for i in range(thread_count): t = threading.Thread(target=_find_worker, args=args) t.daemon = True t.start() threads.append(t) work.join() done.set() for t in threads: t.join() return results, errors def find_mtimes(root, follow=False): results, errors = _find(root, relative=False, follow=follow) # return the mtimes as integer milliseconds mtimes = {f: int(st.st_mtime * 1000) for f, st in results.items()} return mtimes, errors def is_path_inside_base_dir(path, base_path): if path.endswith(os.sep): raise ValueError('Path %s cannot end with a path separator' % path) # Expand symlinks real_base_path = os.path.realpath(base_path) real_path = os.path.realpath(path) if os.path.isfile(path): # Use dir of file for prefix comparision, so we don't accept # /tmp/foo.m3u as being inside /tmp/foo, simply because they have a # common prefix, /tmp/foo, which matches the base path, /tmp/foo. real_path = os.path.dirname(real_path) # Check if dir of file is the base path or a subdir common_prefix = os.path.commonprefix([real_base_path, real_path]) return common_prefix == real_base_path # FIXME replace with mock usage in tests. 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() Mopidy-2.0.0/mopidy/internal/log.py0000664000175000017500000001461012647257461017450 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging import logging.config import logging.handlers import platform LOG_LEVELS = { -1: dict(root=logging.ERROR, mopidy=logging.WARNING), 0: dict(root=logging.ERROR, mopidy=logging.INFO), 1: dict(root=logging.WARNING, mopidy=logging.DEBUG), 2: dict(root=logging.INFO, mopidy=logging.DEBUG), 3: dict(root=logging.DEBUG, mopidy=logging.DEBUG), 4: dict(root=logging.NOTSET, mopidy=logging.NOTSET), } # Custom log level which has even lower priority than DEBUG TRACE_LOG_LEVEL = 5 logging.addLevelName(TRACE_LOG_LEVEL, 'TRACE') logger = logging.getLogger(__name__) class DelayedHandler(logging.Handler): def __init__(self): logging.Handler.__init__(self) self._released = False self._buffer = [] def handle(self, record): if not self._released: self._buffer.append(record) def release(self): self._released = True root = logging.getLogger('') while self._buffer: root.handle(self._buffer.pop(0)) _delayed_handler = DelayedHandler() def bootstrap_delayed_logging(): root = logging.getLogger('') root.setLevel(logging.NOTSET) root.addHandler(_delayed_handler) def setup_logging(config, verbosity_level, save_debug_log): logging.captureWarnings(True) if config['logging']['config_file']: # Logging config from file must be read before other handlers are # added. If not, the other handlers will have no effect. try: path = config['logging']['config_file'] logging.config.fileConfig(path, disable_existing_loggers=False) except Exception as e: # Catch everything as logging does not specify what can go wrong. logger.error('Loading logging config %r failed. %s', path, e) setup_console_logging(config, verbosity_level) if save_debug_log: setup_debug_logging_to_file(config) _delayed_handler.release() def setup_console_logging(config, verbosity_level): if verbosity_level < min(LOG_LEVELS.keys()): verbosity_level = min(LOG_LEVELS.keys()) if verbosity_level > max(LOG_LEVELS.keys()): verbosity_level = max(LOG_LEVELS.keys()) loglevels = config.get('loglevels', {}) has_debug_loglevels = any([ level < logging.INFO for level in loglevels.values()]) 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'] formatter = logging.Formatter(log_format) if config['logging']['color']: handler = ColorizingStreamHandler(config.get('logcolors', {})) else: handler = logging.StreamHandler() handler.addFilter(verbosity_filter) handler.setFormatter(formatter) logging.getLogger('').addHandler(handler) def setup_debug_logging_to_file(config): formatter = logging.Formatter(config['logging']['debug_format']) handler = logging.handlers.RotatingFileHandler( config['logging']['debug_file'], maxBytes=10485760, backupCount=3) handler.setFormatter(formatter) logging.getLogger('').addHandler(handler) class VerbosityFilter(logging.Filter): 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: required_log_level = LOG_LEVELS[self.verbosity_level]['root'] return record.levelno >= required_log_level #: Available log colors. COLORS = [b'black', b'red', b'green', b'yellow', b'blue', b'magenta', b'cyan', b'white'] class ColorizingStreamHandler(logging.StreamHandler): """ Stream handler which colorizes the log using ANSI escape sequences. Does nothing on Windows, which doesn't support ANSI escape sequences. This implementation is based upon https://gist.github.com/vsajip/758430, which is: Copyright (C) 2010-2012 Vinay Sajip. All rights reserved. Licensed under the new BSD license. """ # Map logging levels to (background, foreground, bold/intense) level_map = { TRACE_LOG_LEVEL: (None, 'blue', False), logging.DEBUG: (None, 'blue', False), logging.INFO: (None, 'white', False), logging.WARNING: (None, 'yellow', False), logging.ERROR: (None, 'red', False), logging.CRITICAL: ('red', 'white', True), } # Map logger name to foreground colors logger_map = {} csi = '\x1b[' reset = '\x1b[0m' is_windows = platform.system() == 'Windows' def __init__(self, logger_colors): super(ColorizingStreamHandler, self).__init__() self.logger_map = logger_colors @property def is_tty(self): isatty = getattr(self.stream, 'isatty', None) return isatty and isatty() def emit(self, record): try: message = self.format(record) self.stream.write(message) self.stream.write(getattr(self, 'terminator', '\n')) self.flush() except Exception: self.handleError(record) def format(self, record): message = logging.StreamHandler.format(self, record) if not self.is_tty or self.is_windows: return message for name, color in self.logger_map.iteritems(): if record.name.startswith(name): return self.colorize(message, fg=color) if record.levelno in self.level_map: bg, fg, bold = self.level_map[record.levelno] return self.colorize(message, bg=bg, fg=fg, bold=bold) return message def colorize(self, message, bg=None, fg=None, bold=False): params = [] if bg in COLORS: params.append(str(COLORS.index(bg) + 40)) if fg in COLORS: params.append(str(COLORS.index(fg) + 30)) if bold: params.append('1') if params: message = ''.join(( self.csi, ';'.join(params), 'm', message, self.reset)) return message Mopidy-2.0.0/mopidy/internal/playlists.py0000664000175000017500000000650612660436420020705 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import io from mopidy.compat import configparser from mopidy.internal import validation try: import xml.etree.cElementTree as elementtree except ImportError: import xml.etree.ElementTree as elementtree def parse(data): handlers = { detect_extm3u_header: parse_extm3u, detect_pls_header: parse_pls, detect_asx_header: parse_asx, detect_xspf_header: parse_xspf, } for detector, parser in handlers.items(): if detector(data): return list(parser(data)) return parse_urilist(data) # Fallback def detect_extm3u_header(data): return data[0:7].upper() == b'#EXTM3U' def detect_pls_header(data): return data[0:10].lower() == b'[playlist]' def detect_xspf_header(data): data = data[0:150] if b'xspf' not in data.lower(): return False try: data = io.BytesIO(data) for event, element in elementtree.iterparse(data, events=(b'start',)): return element.tag.lower() == '{http://xspf.org/ns/0/}playlist' except elementtree.ParseError: pass return False def detect_asx_header(data): data = data[0:50] if b'asx' not in data.lower(): return False try: data = io.BytesIO(data) for event, element in elementtree.iterparse(data, events=(b'start',)): return element.tag.lower() == 'asx' except elementtree.ParseError: pass return False def parse_extm3u(data): # TODO: convert non URIs to file URIs. found_header = False for line in data.splitlines(): if found_header or line.startswith(b'#EXTM3U'): found_header = True else: continue if not line.startswith(b'#') and line.strip(): yield line.strip() def parse_pls(data): # TODO: convert non URIs to file URIs. try: cp = configparser.RawConfigParser() cp.readfp(io.BytesIO(data)) except configparser.Error: return for section in cp.sections(): if section.lower() != 'playlist': continue for i in range(cp.getint(section, 'numberofentries')): yield cp.get(section, 'file%d' % (i + 1)) def parse_xspf(data): try: # Last element will be root. for event, element in elementtree.iterparse(io.BytesIO(data)): element.tag = element.tag.lower() # normalize except elementtree.ParseError: return ns = 'http://xspf.org/ns/0/' for track in element.iterfind('{%s}tracklist/{%s}track' % (ns, ns)): yield track.findtext('{%s}location' % ns) def parse_asx(data): try: # Last element will be root. for event, element in elementtree.iterparse(io.BytesIO(data)): element.tag = element.tag.lower() # normalize except elementtree.ParseError: return for ref in element.findall('entry/ref[@href]'): yield ref.get('href', '').strip() for entry in element.findall('entry[@href]'): yield entry.get('href', '').strip() def parse_urilist(data): result = [] for line in data.splitlines(): if not line.strip() or line.startswith(b'#'): continue try: validation.check_uri(line) except ValueError: return [] result.append(line) return result Mopidy-2.0.0/mopidy/internal/xdg.py0000664000175000017500000000404312660436420017435 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import io import os from mopidy.compat import configparser def get_dirs(): """Returns a dict of all the known XDG Base Directories for the current user. The keys ``XDG_CACHE_DIR``, ``XDG_CONFIG_DIR``, and ``XDG_DATA_DIR`` is always available. Additional keys, like ``XDG_MUSIC_DIR``, may be available if the ``$XDG_CONFIG_DIR/user-dirs.dirs`` file exists and is parseable. See http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html for the XDG Base Directory specification. """ dirs = { 'XDG_CACHE_DIR': ( os.environ.get('XDG_CACHE_HOME') or os.path.expanduser(b'~/.cache')), 'XDG_CONFIG_DIR': ( os.environ.get('XDG_CONFIG_HOME') or os.path.expanduser(b'~/.config')), 'XDG_DATA_DIR': ( os.environ.get('XDG_DATA_HOME') or os.path.expanduser(b'~/.local/share')), } dirs.update(_get_user_dirs(dirs['XDG_CONFIG_DIR'])) return dirs def _get_user_dirs(xdg_config_dir): """Returns a dict of XDG dirs read from ``$XDG_CONFIG_HOME/user-dirs.dirs``. This is used at import time for most users of :mod:`mopidy`. By rolling our own implementation instead of using :meth:`glib.get_user_special_dir` we make it possible for many extensions to run their test suites, which are importing parts of :mod:`mopidy`, in a virtualenv with global site-packages disabled, and thus no :mod:`glib` available. """ dirs_file = os.path.join(xdg_config_dir, b'user-dirs.dirs') if not os.path.exists(dirs_file): return {} with open(dirs_file, 'rb') as fh: data = fh.read().decode('utf-8') data = '[XDG_USER_DIRS]\n' + data data = data.replace('$HOME', os.path.expanduser('~')) data = data.replace('"', '') config = configparser.RawConfigParser() config.readfp(io.StringIO(data)) return { k.upper(): os.path.abspath(v) for k, v in config.items('XDG_USER_DIRS') if v is not None} Mopidy-2.0.0/mopidy/file/0000775000175000017500000000000012660436443015410 5ustar jodaljodal00000000000000Mopidy-2.0.0/mopidy/file/backend.py0000664000175000017500000000103712575004517017350 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging import pykka from mopidy import backend from mopidy.file import library logger = logging.getLogger(__name__) class FileBackend(pykka.ThreadingActor, backend.Backend): uri_schemes = ['file'] def __init__(self, config, audio): super(FileBackend, self).__init__() self.library = library.FileLibraryProvider(backend=self, config=config) self.playback = backend.PlaybackProvider(audio=audio, backend=self) self.playlists = None Mopidy-2.0.0/mopidy/file/__init__.py0000664000175000017500000000163112575004517017520 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging import os import mopidy from mopidy import config, ext logger = logging.getLogger(__name__) class Extension(ext.Extension): dist_name = 'Mopidy-File' ext_name = 'file' version = mopidy.__version__ def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') return config.read(conf_file) def get_config_schema(self): schema = super(Extension, self).get_config_schema() schema['media_dirs'] = config.List(optional=True) schema['show_dotfiles'] = config.Boolean(optional=True) schema['follow_symlinks'] = config.Boolean(optional=True) schema['metadata_timeout'] = config.Integer(optional=True) return schema def setup(self, registry): from .backend import FileBackend registry.add('backend', FileBackend) Mopidy-2.0.0/mopidy/file/library.py0000664000175000017500000001126012660436420017421 0ustar jodaljodal00000000000000from __future__ import unicode_literals import logging import operator import os import sys import urllib2 from mopidy import backend, exceptions, models from mopidy.audio import scan, tags from mopidy.internal import path logger = logging.getLogger(__name__) FS_ENCODING = sys.getfilesystemencoding() class FileLibraryProvider(backend.LibraryProvider): """Library for browsing local files.""" # TODO: get_images that can pull from metadata and/or .folder.png etc? # TODO: handle playlists? @property def root_directory(self): if not self._media_dirs: return None elif len(self._media_dirs) == 1: uri = path.path_to_uri(self._media_dirs[0]['path']) else: uri = 'file:root' return models.Ref.directory(name='Files', uri=uri) def __init__(self, backend, config): super(FileLibraryProvider, self).__init__(backend) self._media_dirs = list(self._get_media_dirs(config)) self._follow_symlinks = config['file']['follow_symlinks'] self._show_dotfiles = config['file']['show_dotfiles'] self._scanner = scan.Scanner( timeout=config['file']['metadata_timeout']) def browse(self, uri): logger.debug('Browsing files at: %s', uri) result = [] local_path = path.uri_to_path(uri) if local_path == 'root': return list(self._get_media_dirs_refs()) if not self._is_in_basedir(os.path.realpath(local_path)): logger.warning( 'Rejected attempt to browse path (%s) outside dirs defined ' 'in file/media_dirs config.', uri) return [] for dir_entry in os.listdir(local_path): child_path = os.path.join(local_path, dir_entry) uri = path.path_to_uri(child_path) if not self._show_dotfiles and dir_entry.startswith(b'.'): continue if os.path.islink(child_path) and not self._follow_symlinks: logger.debug('Ignoring symlink: %s', uri) continue if not self._is_in_basedir(os.path.realpath(child_path)): logger.debug('Ignoring symlink to outside base dir: %s', uri) continue name = dir_entry.decode(FS_ENCODING, 'replace') if os.path.isdir(child_path): result.append(models.Ref.directory(name=name, uri=uri)) elif os.path.isfile(child_path): result.append(models.Ref.track(name=name, uri=uri)) result.sort(key=operator.attrgetter('name')) return result def lookup(self, uri): logger.debug('Looking up file URI: %s', uri) local_path = path.uri_to_path(uri) try: result = self._scanner.scan(uri) track = tags.convert_tags_to_track(result.tags).copy( uri=uri, length=result.duration) except exceptions.ScannerError as e: logger.warning('Failed looking up %s: %s', uri, e) track = models.Track(uri=uri) if not track.name: filename = os.path.basename(local_path) name = urllib2.unquote(filename).decode(FS_ENCODING, 'replace') track = track.copy(name=name) return [track] def _get_media_dirs(self, config): for entry in config['file']['media_dirs']: media_dir = {} media_dir_split = entry.split('|', 1) local_path = path.expand_path( media_dir_split[0].encode(FS_ENCODING)) if not local_path: logger.debug( 'Failed expanding path (%s) from file/media_dirs config ' 'value.', media_dir_split[0]) continue elif not os.path.isdir(local_path): logger.warning( '%s is not a directory. Please create the directory or ' 'update the file/media_dirs config value.', local_path) continue media_dir['path'] = local_path if len(media_dir_split) == 2: media_dir['name'] = media_dir_split[1] else: # TODO Mpd client should accept / in dir name media_dir['name'] = media_dir_split[0].replace(os.sep, '+') yield media_dir def _get_media_dirs_refs(self): for media_dir in self._media_dirs: yield models.Ref.directory( name=media_dir['name'], uri=path.path_to_uri(media_dir['path'])) def _is_in_basedir(self, local_path): return any( path.is_path_inside_base_dir(local_path, media_dir['path']) for media_dir in self._media_dirs) Mopidy-2.0.0/mopidy/file/ext.conf0000664000175000017500000000021612575004517017054 0ustar jodaljodal00000000000000[file] enabled = true media_dirs = $XDG_MUSIC_DIR|Music ~/|Home show_dotfiles = false follow_symlinks = false metadata_timeout = 1000 Mopidy-2.0.0/mopidy/listener.py0000664000175000017500000000317212660436420016666 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging import pykka logger = logging.getLogger(__name__) def send(cls, event, **kwargs): listeners = pykka.ActorRegistry.get_by_class(cls) logger.debug('Sending %s to %s: %s', event, cls.__name__, kwargs) for listener in listeners: # Save time by calling methods on Pykka actor without creating a # throwaway actor proxy. # # Because we use `.tell()` there is no return channel for any errors, # so Pykka logs them immediately. The alternative would be to use # `.ask()` and `.get()` the returned futures to block for the listeners # to react and return their exceptions to us. Since emitting events in # practise is making calls upwards in the stack, blocking here would # quickly deadlock. listener.tell({ 'command': 'pykka_call', 'attr_path': ('on_event',), 'args': (event,), 'kwargs': kwargs, }) class Listener(object): def on_event(self, event, **kwargs): """ Called on all events. *MAY* be implemented by actor. By default, this method forwards the event to the specific event methods. :param event: the event name :type event: string :param kwargs: any other arguments to the specific event handlers """ try: getattr(self, event)(**kwargs) except Exception: # Ensure we don't crash the actor due to "bad" events. logger.exception( 'Triggering event failed: %s(%s)', event, ', '.join(kwargs)) Mopidy-2.0.0/mopidy/exceptions.py0000664000175000017500000000244412575004517017226 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals class MopidyException(Exception): def __init__(self, message, *args, **kwargs): super(MopidyException, self).__init__(message, *args, **kwargs) self._message = message @property def message(self): """Reimplement message field that was deprecated in Python 2.6""" return self._message @message.setter # noqa def message(self, message): self._message = message class BackendError(MopidyException): pass class CoreError(MopidyException): def __init__(self, message, errno=None): super(CoreError, self).__init__(message, errno) self.errno = errno class ExtensionError(MopidyException): pass class FindError(MopidyException): def __init__(self, message, errno=None): super(FindError, self).__init__(message, errno) self.errno = errno class FrontendError(MopidyException): pass class MixerError(MopidyException): pass class ScannerError(MopidyException): pass class TracklistFull(CoreError): def __init__(self, message, errno=None): super(TracklistFull, self).__init__(message, errno) self.errno = errno class AudioException(MopidyException): pass class ValidationError(ValueError): pass Mopidy-2.0.0/mopidy/httpclient.py0000664000175000017500000000320512653464377017232 0ustar jodaljodal00000000000000from __future__ import unicode_literals import platform import mopidy "Helpers for configuring HTTP clients used in Mopidy extensions." def format_proxy(proxy_config, auth=True): """Convert a Mopidy proxy config to the commonly used proxy string format. Outputs ``scheme://host:port``, ``scheme://user:pass@host:port`` or :class:`None` depending on the proxy config provided. You can also opt out of getting the basic auth by setting ``auth`` to :class:`False`. .. versionadded:: 1.1 """ if not proxy_config.get('hostname'): return None port = proxy_config.get('port') if not port or port < 0: port = 80 if proxy_config.get('username') and proxy_config.get('password') and auth: template = '{scheme}://{username}:{password}@{hostname}:{port}' else: template = '{scheme}://{hostname}:{port}' return template.format(scheme=proxy_config.get('scheme') or 'http', username=proxy_config.get('username'), password=proxy_config.get('password'), hostname=proxy_config['hostname'], port=port) def format_user_agent(name=None): """Construct a User-Agent suitable for use in client code. This will identify use by the provided ``name`` (which should be on the format ``dist_name/version``), Mopidy version and Python version. .. versionadded:: 1.1 """ parts = ['Mopidy/%s' % (mopidy.__version__), '%s/%s' % (platform.python_implementation(), platform.python_version())] if name: parts.insert(0, name) return ' '.join(parts) Mopidy-2.0.0/mopidy/compat.py0000664000175000017500000000256712660436420016333 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import sys PY2 = sys.version_info[0] == 2 PY3 = sys.version_info[0] == 3 if PY2: import ConfigParser as configparser # noqa import Queue as queue # noqa import thread # noqa def fake_python3_urllib_module(): import types import urllib as py2_urllib import urlparse as py2_urlparse urllib = types.ModuleType(b'urllib') # noqa urllib.parse = types.ModuleType(b'urlib.parse') urllib.parse.quote = py2_urllib.quote urllib.parse.unquote = py2_urllib.unquote urllib.parse.urlparse = py2_urlparse.urlparse urllib.parse.urlsplit = py2_urlparse.urlsplit urllib.parse.urlunsplit = py2_urlparse.urlunsplit return urllib urllib = fake_python3_urllib_module() integer_types = (int, long) # noqa string_types = basestring # noqa text_type = unicode # noqa input = raw_input # noqa intern = intern # noqa def itervalues(dct, **kwargs): return iter(dct.itervalues(**kwargs)) else: import configparser # noqa import queue # noqa import _thread as thread # noqa import urllib # noqa integer_types = (int,) string_types = (str,) text_type = str input = input intern = sys.intern def itervalues(dct, **kwargs): return iter(dct.values(**kwargs)) Mopidy-2.0.0/mopidy/zeroconf.py0000664000175000017500000001032312660436420016662 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging import string logger = logging.getLogger(__name__) try: import dbus except ImportError: dbus = None _AVAHI_IF_UNSPEC = -1 _AVAHI_PROTO_UNSPEC = -1 _AVAHI_PUBLISHFLAGS_NONE = 0 def _is_loopback_address(host): return ( host.startswith('127.') or host.startswith('::ffff:127.') or host == '::1') def _convert_text_list_to_dbus_format(text_list): array = dbus.Array(signature='ay') for text in text_list: array.append([dbus.Byte(ord(c)) for c in text]) return array class Zeroconf(object): """Publish a network service with Zeroconf. Currently, this only works on Linux using Avahi via D-Bus. :param str name: human readable name of the service, e.g. 'MPD on neptune' :param str stype: service type, e.g. '_mpd._tcp' :param int port: TCP port of the service, e.g. 6600 :param str domain: local network domain name, defaults to '' :param str host: interface to advertise the service on, defaults to '' :param text: extra information depending on ``stype``, defaults to empty list :type text: list of str """ def __init__(self, name, stype, port, domain='', host='', text=None): self.stype = stype self.port = port self.domain = domain self.host = host self.text = text or [] self.bus = None self.server = None self.group = None self.display_hostname = None self.name = None if dbus: try: self.bus = dbus.SystemBus() self.server = dbus.Interface( self.bus.get_object('org.freedesktop.Avahi', '/'), 'org.freedesktop.Avahi.Server') self.display_hostname = '%s' % self.server.GetHostName() self.name = string.Template(name).safe_substitute( hostname=self.display_hostname, port=port) except dbus.exceptions.DBusException as e: logger.debug('%s: Server failed: %s', self, e) def __str__(self): return 'Zeroconf service "%s" (%s at [%s]:%d)' % ( self.name, self.stype, self.host, self.port) def publish(self): """Publish the service. Call when your service starts. """ if _is_loopback_address(self.host): logger.debug( '%s: Publish on loopback interface is not supported.', self) return False if not dbus: logger.debug('%s: dbus not installed; publish failed.', self) return False if not self.bus: logger.debug('%s: Bus not available; publish failed.', self) return False if not self.server: logger.debug('%s: Server not available; publish failed.', self) return False try: if not self.bus.name_has_owner('org.freedesktop.Avahi'): logger.debug( '%s: Avahi service not running; publish failed.', self) return False self.group = dbus.Interface( self.bus.get_object( 'org.freedesktop.Avahi', self.server.EntryGroupNew()), 'org.freedesktop.Avahi.EntryGroup') self.group.AddService( _AVAHI_IF_UNSPEC, _AVAHI_PROTO_UNSPEC, dbus.UInt32(_AVAHI_PUBLISHFLAGS_NONE), self.name, self.stype, self.domain, self.host, dbus.UInt16(self.port), _convert_text_list_to_dbus_format(self.text)) self.group.Commit() logger.debug('%s: Published', self) return True except dbus.exceptions.DBusException as e: logger.debug('%s: Publish failed: %s', self, e) return False def unpublish(self): """Unpublish the service. Call when your service shuts down. """ if self.group: try: self.group.Reset() logger.debug('%s: Unpublished', self) except dbus.exceptions.DBusException as e: logger.debug('%s: Unpublish failed: %s', self, e) finally: self.group = None Mopidy-2.0.0/mopidy/m3u/0000775000175000017500000000000012660436443015175 5ustar jodaljodal00000000000000Mopidy-2.0.0/mopidy/m3u/translator.py0000664000175000017500000000656112660436420017743 0ustar jodaljodal00000000000000from __future__ import absolute_import, print_function, unicode_literals import os from mopidy import models from . import Extension try: from urllib.parse import quote_from_bytes, unquote_to_bytes except ImportError: import urllib def quote_from_bytes(bytes, safe=b'/'): # Python 3 returns Unicode string return urllib.quote(bytes, safe).decode('utf-8') def unquote_to_bytes(string): if isinstance(string, bytes): return urllib.unquote(string) else: return urllib.unquote(string.encode('utf-8')) try: from urllib.parse import urlsplit, urlunsplit except ImportError: from urlparse import urlsplit, urlunsplit try: from os import fsencode, fsdecode except ImportError: import sys # no 'surrogateescape' in Python 2; 'replace' for backward compatibility def fsencode(filename, encoding=sys.getfilesystemencoding()): return filename.encode(encoding, 'replace') def fsdecode(filename, encoding=sys.getfilesystemencoding()): return filename.decode(encoding, 'replace') def path_to_uri(path, scheme=Extension.ext_name): """Convert file path to URI.""" assert isinstance(path, bytes), 'Mopidy paths should be bytes' uripath = quote_from_bytes(os.path.normpath(path)) return urlunsplit((scheme, None, uripath, None, None)) def uri_to_path(uri): """Convert URI to file path.""" # TODO: decide on Unicode vs. bytes for URIs return unquote_to_bytes(urlsplit(uri).path) def name_from_path(path): """Extract name from file path.""" name, _ = os.path.splitext(os.path.basename(path)) try: return fsdecode(name) except UnicodeError: return None def path_from_name(name, ext=None, sep='|'): """Convert name with optional extension to file path.""" if ext: return fsencode(name.replace(os.sep, sep) + ext) else: return fsencode(name.replace(os.sep, sep)) def path_to_ref(path): return models.Ref.playlist( uri=path_to_uri(path), name=name_from_path(path) ) def load_items(fp, basedir): refs = [] name = None for line in filter(None, (line.strip() for line in fp)): if line.startswith('#'): if line.startswith('#EXTINF:'): name = line.partition(',')[2] continue elif not urlsplit(line).scheme: path = os.path.join(basedir, fsencode(line)) if not name: name = name_from_path(path) uri = path_to_uri(path, scheme='file') else: uri = line # do *not* extract name from (stream?) URI path refs.append(models.Ref.track(uri=uri, name=name)) name = None return refs def dump_items(items, fp): if any(item.name for item in items): print('#EXTM3U', file=fp) for item in items: if item.name: print('#EXTINF:-1,%s' % item.name, file=fp) # TODO: convert file URIs to (relative) paths? if isinstance(item.uri, bytes): print(item.uri.decode('utf-8'), file=fp) else: print(item.uri, file=fp) def playlist(path, items=[], mtime=None): return models.Playlist( uri=path_to_uri(path), name=name_from_path(path), tracks=[models.Track(uri=item.uri, name=item.name) for item in items], last_modified=(int(mtime * 1000) if mtime else None) ) Mopidy-2.0.0/mopidy/m3u/backend.py0000664000175000017500000000055212660436420017133 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import pykka from mopidy import backend from . import playlists class M3UBackend(pykka.ThreadingActor, backend.Backend): uri_schemes = ['m3u'] def __init__(self, config, audio): super(M3UBackend, self).__init__() self.playlists = playlists.M3UPlaylistsProvider(self, config) Mopidy-2.0.0/mopidy/m3u/__init__.py0000664000175000017500000000161712660436420017306 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging import os import mopidy from mopidy import config, ext logger = logging.getLogger(__name__) class Extension(ext.Extension): dist_name = 'Mopidy-M3U' ext_name = 'm3u' version = mopidy.__version__ def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') return config.read(conf_file) def get_config_schema(self): schema = super(Extension, self).get_config_schema() schema['base_dir'] = config.Path(optional=True) schema['default_encoding'] = config.String() schema['default_extension'] = config.String(choices=['.m3u', '.m3u8']) schema['playlists_dir'] = config.Path(optional=True) return schema def setup(self, registry): from .backend import M3UBackend registry.add('backend', M3UBackend) Mopidy-2.0.0/mopidy/m3u/ext.conf0000664000175000017500000000016412660436420016640 0ustar jodaljodal00000000000000[m3u] enabled = true playlists_dir = base_dir = $XDG_MUSIC_DIR default_encoding = latin-1 default_extension = .m3u8 Mopidy-2.0.0/mopidy/m3u/playlists.py0000664000175000017500000001170012660436420017565 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import contextlib import io import locale import logging import operator import os import tempfile from mopidy import backend from . import Extension, translator logger = logging.getLogger(__name__) def log_environment_error(message, error): if isinstance(error.strerror, bytes): strerror = error.strerror.decode(locale.getpreferredencoding()) else: strerror = error.strerror logger.error('%s: %s', message, strerror) @contextlib.contextmanager def replace(path, mode='w+b', encoding=None, errors=None): try: (fd, tempname) = tempfile.mkstemp(dir=os.path.dirname(path)) except TypeError: # Python 3 requires dir to be of type str until v3.5 import sys path = path.decode(sys.getfilesystemencoding()) (fd, tempname) = tempfile.mkstemp(dir=os.path.dirname(path)) try: fp = io.open(fd, mode, encoding=encoding, errors=errors) except: os.remove(tempname) os.close(fd) raise try: yield fp fp.flush() os.fsync(fd) os.rename(tempname, path) except: os.remove(tempname) raise finally: fp.close() class M3UPlaylistsProvider(backend.PlaylistsProvider): def __init__(self, backend, config): super(M3UPlaylistsProvider, self).__init__(backend) ext_config = config[Extension.ext_name] if ext_config['playlists_dir'] is None: self._playlists_dir = Extension.get_data_dir(config) else: self._playlists_dir = ext_config['playlists_dir'] self._base_dir = ext_config['base_dir'] or self._playlists_dir self._default_encoding = ext_config['default_encoding'] self._default_extension = ext_config['default_extension'] def as_list(self): result = [] for entry in os.listdir(self._playlists_dir): if not entry.endswith((b'.m3u', b'.m3u8')): continue elif not os.path.isfile(self._abspath(entry)): continue else: result.append(translator.path_to_ref(entry)) result.sort(key=operator.attrgetter('name')) return result def create(self, name): path = translator.path_from_name(name.strip(), self._default_extension) try: with self._open(path, 'w'): pass mtime = os.path.getmtime(self._abspath(path)) except EnvironmentError as e: log_environment_error('Error creating playlist %s' % name, e) else: return translator.playlist(path, [], mtime) def delete(self, uri): path = translator.uri_to_path(uri) try: os.remove(self._abspath(path)) except EnvironmentError as e: log_environment_error('Error deleting playlist %s' % uri, e) def get_items(self, uri): path = translator.uri_to_path(uri) try: with self._open(path, 'r') as fp: items = translator.load_items(fp, self._base_dir) except EnvironmentError as e: log_environment_error('Error reading playlist %s' % uri, e) else: return items def lookup(self, uri): path = translator.uri_to_path(uri) try: with self._open(path, 'r') as fp: items = translator.load_items(fp, self._base_dir) mtime = os.path.getmtime(self._abspath(path)) except EnvironmentError as e: log_environment_error('Error reading playlist %s' % uri, e) else: return translator.playlist(path, items, mtime) def refresh(self): pass # nothing to do def save(self, playlist): path = translator.uri_to_path(playlist.uri) name = translator.name_from_path(path) try: with self._open(path, 'w') as fp: translator.dump_items(playlist.tracks, fp) if playlist.name and playlist.name != name: opath, ext = os.path.splitext(path) path = translator.path_from_name(playlist.name.strip()) + ext os.rename(self._abspath(opath + ext), self._abspath(path)) mtime = os.path.getmtime(self._abspath(path)) except EnvironmentError as e: log_environment_error('Error saving playlist %s' % playlist.uri, e) else: return translator.playlist(path, playlist.tracks, mtime) def _abspath(self, path): return os.path.join(self._playlists_dir, path) def _open(self, path, mode='r'): if path.endswith(b'.m3u8'): encoding = 'utf-8' else: encoding = self._default_encoding if not os.path.isabs(path): path = os.path.join(self._playlists_dir, path) if 'w' in mode: return replace(path, mode, encoding=encoding, errors='replace') else: return io.open(path, mode, encoding=encoding, errors='replace') Mopidy-2.0.0/mopidy/mixer.py0000664000175000017500000001017712660436420016170 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging from mopidy import listener logger = logging.getLogger(__name__) class Mixer(object): """ Audio mixer API If the mixer has problems during initialization it should raise :exc:`mopidy.exceptions.MixerError` with a descriptive error message. This will make Mopidy print the error message and exit so that the user can fix the issue. :param config: the entire Mopidy configuration :type config: dict """ name = None """ Name of the mixer. Used when configuring what mixer to use. Should match the :attr:`~mopidy.ext.Extension.ext_name` of the extension providing the mixer. """ def __init__(self, config): pass def get_volume(self): """ Get volume level of the mixer on a linear scale from 0 to 100. Example values: 0: Minimum volume, usually silent. 100: Maximum volume. :class:`None`: Volume is unknown. *MAY be implemented by subclass.* :rtype: int in range [0..100] or :class:`None` """ return None def set_volume(self, volume): """ Set volume level of the mixer. *MAY be implemented by subclass.* :param volume: Volume in the range [0..100] :type volume: int :rtype: :class:`True` if success, :class:`False` if failure """ return False def trigger_volume_changed(self, volume): """ Send ``volume_changed`` event to all mixer listeners. This method should be called by subclasses when the volume is changed, either because of a call to :meth:`set_volume` or because of any external entity changing the volume. """ logger.debug('Mixer event: volume_changed(volume=%d)', volume) MixerListener.send('volume_changed', volume=volume) def get_mute(self): """ Get mute state of the mixer. *MAY be implemented by subclass.* :rtype: :class:`True` if muted, :class:`False` if unmuted, :class:`None` if unknown. """ return None def set_mute(self, mute): """ Mute or unmute the mixer. *MAY be implemented by subclass.* :param mute: :class:`True` to mute, :class:`False` to unmute :type mute: bool :rtype: :class:`True` if success, :class:`False` if failure """ return False def trigger_mute_changed(self, mute): """ Send ``mute_changed`` event to all mixer listeners. This method should be called by subclasses when the mute state is changed, either because of a call to :meth:`set_mute` or because of any external entity changing the mute state. """ logger.debug('Mixer event: mute_changed(mute=%s)', mute) MixerListener.send('mute_changed', mute=mute) def ping(self): """Called to check if the actor is still alive.""" return True class MixerListener(listener.Listener): """ Marker interface for recipients of events sent by the mixer actor. Any Pykka actor that mixes in this class will receive calls to the methods defined here when the corresponding events happen in the mixer actor. This interface is used both for looking up what actors to notify of the events, and for providing default implementations for those listeners that are not interested in all events. """ @staticmethod def send(event, **kwargs): """Helper to allow calling of mixer listener events""" listener.send(MixerListener, event, **kwargs) def volume_changed(self, volume): """ Called after the volume has changed. *MAY* be implemented by actor. :param volume: the new volume :type volume: int in range [0..100] """ pass def mute_changed(self, mute): """ Called after the mute state has changed. *MAY* be implemented by actor. :param mute: :class:`True` if muted, :class:`False` if not muted :type mute: bool """ pass Mopidy-2.0.0/docs/0000775000175000017500000000000012660436443014120 5ustar jodaljodal00000000000000Mopidy-2.0.0/docs/modules/0000775000175000017500000000000012660436443015570 5ustar jodaljodal00000000000000Mopidy-2.0.0/docs/modules/mpd.rst0000664000175000017500000000420612575004517017102 0ustar jodaljodal00000000000000******************************** :mod:`mopidy.mpd` --- MPD server ******************************** For details on how to use Mopidy's MPD server, see :ref:`ext-mpd`. .. automodule:: mopidy.mpd :synopsis: MPD server frontend MPD tokenizer ============= .. automodule:: mopidy.mpd.tokenize :synopsis: MPD request tokenizer :members: MPD dispatcher ============== .. automodule:: mopidy.mpd.dispatcher :synopsis: MPD request dispatcher :members: MPD protocol ============ .. automodule:: mopidy.mpd.protocol :synopsis: MPD protocol :members: Audio output ------------ .. automodule:: mopidy.mpd.protocol.audio_output :synopsis: MPD protocol: audio output :members: Channels -------- .. automodule:: mopidy.mpd.protocol.channels :synopsis: MPD protocol: channels -- client to client communication :members: Command list ------------ .. automodule:: mopidy.mpd.protocol.command_list :synopsis: MPD protocol: command list :members: Connection ---------- .. automodule:: mopidy.mpd.protocol.connection :synopsis: MPD protocol: connection :members: Current playlist ---------------- .. automodule:: mopidy.mpd.protocol.current_playlist :synopsis: MPD protocol: current playlist :members: Mounts and neighbors -------------------- .. automodule:: mopidy.mpd.protocol.mount :synopsis: MPD protocol: mounts and neighbors :members: Music database -------------- .. automodule:: mopidy.mpd.protocol.music_db :synopsis: MPD protocol: music database :members: Playback -------- .. automodule:: mopidy.mpd.protocol.playback :synopsis: MPD protocol: playback :members: Reflection ---------- .. automodule:: mopidy.mpd.protocol.reflection :synopsis: MPD protocol: reflection :members: Status ------ .. automodule:: mopidy.mpd.protocol.status :synopsis: MPD protocol: status :members: Stickers -------- .. automodule:: mopidy.mpd.protocol.stickers :synopsis: MPD protocol: stickers :members: Stored playlists ---------------- .. automodule:: mopidy.mpd.protocol.stored_playlists :synopsis: MPD protocol: stored playlists :members: Mopidy-2.0.0/docs/modules/local.rst0000664000175000017500000000074312575004517017416 0ustar jodaljodal00000000000000************************************* :mod:`mopidy.local` --- Local backend ************************************* For details on how to use Mopidy's local backend, see :ref:`ext-local`. .. automodule:: mopidy.local :synopsis: Local backend Local library API ================= .. autoclass:: mopidy.local.Library :members: Translation utils ================= .. automodule:: mopidy.local.translator :synopsis: Translators for local library extensions :members: Mopidy-2.0.0/docs/modules/index.rst0000644000175000017500000000012412441116635017417 0ustar jodaljodal00000000000000**************** Module reference **************** .. toctree:: :glob: ** Mopidy-2.0.0/docs/ext/0000775000175000017500000000000012660436443014720 5ustar jodaljodal00000000000000Mopidy-2.0.0/docs/ext/file.rst0000664000175000017500000000247112575004517016373 0ustar jodaljodal00000000000000.. _ext-file: ************ Mopidy-File ************ Mopidy-File is an extension for playing music from your local music archive. It is bundled with Mopidy and enabled by default. It allows you to browse through your local file system. Only files that are considered playable will be shown. This backend handles URIs starting with ``file:``. Configuration ============= See :ref:`config` for general help on configuring Mopidy. .. literalinclude:: ../../mopidy/file/ext.conf :language: ini .. confval:: file/enabled If the file extension should be enabled or not. .. confval:: file/media_dirs A list of directories to be browsable. Optionally the path can be followed by ``|`` and a name that will be shown for that path. .. confval:: file/show_dotfiles Whether to show hidden files and directories that start with a dot. Default is false. .. confval:: file/follow_symlinks Whether to follow symbolic links found in :confval:`files/media_dir`. Directories and files that are outside the configured directories will not be shown. Default is false. .. confval:: file/metadata_timeout Number of milliseconds before giving up scanning a file and moving on to the next file. Reducing the value might speed up the directory listing, but can lead to some tracks not being shown. Mopidy-2.0.0/docs/ext/spotmop.jpg0000664000175000017500000024234412653464377017145 0ustar jodaljodal00000000000000JFIFHHExifII*>F(iNHH02100100http://ns.adobe.com/xap/1.0/ xmp.did:83EC32CE5B9DE5119D55E7C24FA6B6FD xmp.iid:F49D53AF9EB011E58BE0DBBA020B75B5 xmp.did:83EC32CE5B9DE5119D55E7C24FA6B6FD xmp.iid:F49D53AF9EB011E58BE0DBBA020B75B5 Adobe Photoshop CS6 (Windows) C     C     #DHduͧ@-%E2z- <R5j DDi M\"(%D(BS@<*y87*Ҭ(i,>¾i%D MϮ\ʮ9zq41h͚ۊvj=cK9EdC+ם9rFXyXq:?˗ „.S} }c]2}%}ϡz\o_)J{i+GW89ybypѫʩ*N<Ly24QaF^o}Su7Sfk* ps媯f/ vtn%$ϧ6K9xLkۅר/&3iyvsް_\6>]CyGq/O6+K\vuBZ 5q{V79y8WͥC:>砆WXkQwOs}/*ҟ9֛pes(3M\ͫ6̸v͹q8:rex[y3'lD,8ޓkˑOa㦳ˮ K6n|@yzKLWnh W:wl>0M`0"UIsbƪ?'ly8w#sb9tgщ=f2oktԤZGw{zxv|-LxsA=\b{霾Y x*θ~kZ[^r_ 3~|Mrw>>0M`7羙Z]afTvzty;q:WŐqW!Nצ|Ue*Y3Sӎu.=O~u٫t?I}׻s42yoO 9q9:XkCUFӳty8k)SpYXֱ7zŎsZ;ֳNBM!5uwb\#by#\F3~R#9/>ԒܠL&M=<ؾulo|zؐ܎Z0}<7|N9Y-.MlYϛ*soyݡW. 6{̹=ɳ` םhI+twqlr_~b]2zY͓Uz"l|ξ:?eG/GsQ'`wY29޽u`RiJҳټ~surOT>&gܱ39ЛkfY .9וf۝ %j*˕mf3<.[1Qp>~g=w9Lfbל;w*K~^^箟`N<]y/WgElw&5ntk^tןMe٦wEܙHY{6<x}SV{'ѴbKΖ^㤎z]Z;2e=g>ϟ_gm7<98=*|ᅴrdzdw HGsѾ?|W>Zx0}K£fǢ,vdQ!Wf+UfSk\zJnln{ dr2g7+ 5ݜޖwc[<=J3u͎}f͝6澥Zڌ*,6Sv==~tc~~ߑWwϏn.YdvAWz??3]q:'X4udx:6jE\Ǿ}SΛ'Ofc8zձϻ|>+$0 QuPVM + t hR Mxsw2a/1'1~oennv}Gd:]7~^㾁y8c俬dyi3IJK5˿>&׿dSMCxut+3u/;?þ-iA QF_0[.s?'~7__o9'ɝߎ#kU ӧ|_xw@N<Cy/k gMTێIʑZǚmmDU뜦g3ѧO[K/ .yϤyNe}Xq>uټ=Z]y?WOz-bf |_<1'`<1rm  Z|h=3uH9sIfoξW8yc[חӝpxe{|uqоO|[Zx0={I4=4CA~ӊcUZ&,\Lq=ys_w/gb1r}v>̧0վ5.s{8b+SG/JqH΀w"cY軞C~Cyl˲WXij#ԥ_k`N<ۜ_I\,YTi߬e!-ɌR0U,lV*Jm:]X)<{ r]sI@;g}.'i.[k._-3]ٕzdUk _9z/=y8Mdʖ "X#tJ$! E SU"pR) hǝPᄢ9חyϮYRM5n '6&F<cގ~箧`N<ēK`4K3҆.*YHɐV#p!jq@QH݇[,[9mJu2:U 8ǖk4Əޤk%<<_wI mZZW/S'`=s#-E*}5U5$QDzHf|ϯzcn(FwKh]1ܞ̅l.I/?~kίE=y'ͻNA3~}02QUlVODNő$6Z1u46T˨TdlɨEPYc-xtqׄq:Y"NG1XXޏ&z-b:v\g}]S?:u9Lq;/-,r#{>Ws^y%y5wK^^箩`N<ZrBuf ܯA}:fkDXF,l6LMrd5"{3IrN@9gt(ӐP@x Cgk~^箧`N<Zr|%Ťt陦, R@R 4a  rh-h=1.ZZ]&Z)MqG/ET'`)9锉h/kɷOMMaJPV5*C5(Y)V x 0 tԜ  V<^^箩`N<9 X(D]wh}(  8&y@'K=Iˢ^^箩`N<C鞻sN]5XYbd%XqG/EU3v@AD !B@@@P@P@PYP@ w=I˦I+1 .\[\m=Oz9z/6YX upG:u8t8u,8u,8ppQEqnr.HxѼ9BFDEE(8pŽQqH,uf;WgO\ek(Չ_ym;Y7ֹotƲfDK dDD$DDDDdcF)5sP)@T5\3qL'w43bQߏVD莑ˋy#U'&}QycXǖ<,ya.egC.1ljQ8{G ̗};$D>ۮ(Υ4|.M5*KtxyPYRJTh2")Lm%8TI[Cѳ֗1Eߨ?^eLZʒ7B~ۇky'KɔTW6ݿWdwк^kE$AxY9(Qp~$MAFC)I (pr4K;窿XPKdlٛ6!Ua?wn0&oE,#sOt!8CXUVj%5N~mBtƿ2}Wj6Im-Pׇ 5n012)פHCj%*"]Sf(a;*-!aLN3HJ @p. ! ~KC>ۇu^/褣0SPzî-1u adaf#Vk(tza)!LSTYjmp&n}خ^~e!?@eR]S.B7 Rf4JaLS9OIt7Ruei0dAja~8y ]kc<ٌ_pZ1x Bj^. "i/3gJɴSKAX*bB'¤%%˩IS`sPhRjtuӉja"j Te+%nuCQIRURLx5(3Xavsdn8K[-ӄ)E11(gMnΨRTxnX($a~"anjD'VjḐNTfԙ*ݧq4U[h%xLd+Fw p(tC9I*8Kr[{>V"I{bbI@qfmn1ID4DSi4R1*-%"Q6 ŒF;фøVqm).NUʂULfJcs 8J6drj"#-)&:A%٩4&q)J5#,_ N(Gx4f)s1qj}ax:DO|D]왙KP$g -;=aq:VӷQeV[I1t [MKΆ=fbx(GkiM9 _qb+ST`?Ym""eJYߘNDdY}Fm}ANnFc^E4DN9 ,RV됴6jDLg]s)EU_ '2dZӤggÁx="Nn@iR LjfU~;p)RJh_F9d }`x(sүm2@[&Tf—H25# -!x>\ki'B[qL:Y?ZȌeL]OѶÊj;%G%aT5 &>ga)rO.0r!*$^pd2 ;NӸ4 5PumnM(5Gv^QSqI3 0SюCvzRN/B! IBJfݨUA?!(&o );>n rt̠Fn\ a'~^Nr$-mb22m*3~^2}%q6Lw<ǚ|ۦaĵxixo[>f]|/=hjK\fCn?=N=FkJr'Wo3.0b8%bm("VqJNo}dY⤚0[yJS\KRr*Ma6jzRLJ$Af|Jk10sR8^Oh4S 0ׅN0xҹͻf*O˗q AAnvW&>NVZZQ1¯ lpӷQKr+yP1;"mm%H*mZSuܺ!>{F%呺Ksp`gvj*I\I N#urP%پBFR:^ A.r_o lDW^)S͍fЕԕ8EHGh a4q08Gh a408H2ccקnu[YbS$‚$GaDAҧXRϱHq"\ZljZ]"KG"ZH}PCP$Z\}<_k1=?=2چV[̵Ǔ *UL+sRĴ=W{pF7qWO!HX{f=ίA! 0G[A!p\m'VC:v7Y!ϴRE}{  $'5MYuEyҤ"ؑZ`֧Ϧ]E3Kؔl7*Q-'$J7*2+%0.k/9 Ch2_g=\6Ha- fIdG) RZR3Ra&Z]_zr# # lƟ{h4FD.EJ64bG',|GXf4\[)m( n!ڙ)Iv|P" *eL 2pN0:tC q;IB$)cv<>˄;&  Y` v7`R2f.P]?J;IƝ/d>T @IHwΩ"KDUbJ SK)MrRӪ4߸0Ci?2euCnuj+ Vӝ\i/>y]@1JqȨUUwسѐK8Ί] FL9VKUMVfʙ곛6Vg=@@-ߵ*]2'T%žegT:v7A&6OIvyX"+ X6}XusQ],/gelִbߨKU]&K"*śmceV#;=tz2126l8Ҥǐ^s9 )MP- 6ʙy/Gf,) P!xۇ ~{!J#bD!1).u5d\F4eVRHCȕq꓇P`Hzн']~~qӱ.I[X!ףϟE)rBiRWPkZM7LEN,QM&t5Y IfN5/`>aeItn:/vI¦2)Ӊqbgv[|cIT3TV9S#6GMj.4.I"TT);6"MaʙYgwdT88ff],xNXYxu -jiԩFԵ''gܺv7Ck~^/"TYK}jsc+ERB9 Gz!Ŧ#.J$ݾDh)l6d8l۟*݌+!j<3T$MԸd-7Wʞk/rIyh'>v$U[ntCSp^^/A͵jDjJe[yLjd%vjÅ]NT =r[Jm7IN35CeP 3PrZ$լfE4)Oo|sdZPvBKRYOxf%WQ4Qn.)n;(fY$-P;Juz//x/3ni IJ kKh5Hi>Kq-'17ͨf'2#KRPS1q)3u YK$CŘ8V",q 7-m6Abmj }n)NwkMIB ][[e(˼)đ)@󴔙;pW7?MyƘ<(j84\h1Hb%鑚ѡ[X*$4,HtLLzZrrҢ= xK4$q "BbjNsxEZv.T5n"|odlN4nܓqfLXDDk tr ÊRnXrP70*X5wǑ;EUzxb7ǿ0SKGh#P3$K*nږlJQ$4oR9}D~Hh%2 %M^vv),TCg|sd>m ˭f;j7 9jKFzfRlH%+&ds}$j[dO*ovC:v77u[E2,T|Р F4tȎԶ{ !itșĎ8,DjthjqLn8Ұn\ED2MA%+'n>;t]I/4 iD1ҕBbk5O 3;V W%N`&k,}cnr@cKjzʫKˎmV 8S]R8@ԔU=2"2ozKN;eJm ?[úȓQ%Za k/5-!OQdpH/I儳{N=F}ԍdQR=G$G~LU]ѱ&V#|SgW\QBћuCYthI4@~3Xo!Xw "eM       ZB9yh.ݢCvr]!˫D9uh.բcVrю\1GZm{HiLGFePLԤKpepJRo%dKyHm|x%>]c}-oA8MJS@KXTz!)$:K?!ϗXxw[&@I Mh#2LuQ#"#'Qil>3[u sa.%}6#=6yk}dI%dR# bGw!P7 t(n*,V᷺t DŽgw_ˬtfo +D4sz5y/3_mnNV:v*N糺}AzXe)uU_Ci.>&:%3*&:r@Ѿ4r4r@Ѿ4r@Ѿ4r|iGƑ|iWƕ|i^WM5599lg63͌sc9)82 K%pd22222222222-rM4 BwI(!Ltgl#62@ d 2@[ڿRsdC2N.I)ΉMUYS,1dT't㑊6=$6!$2FH d 2FH#$Y-K3PWα8*PP[ܩMQ0c*ٿJ\(:N1RP*UCd4 +@❩vF@ d 2H$Q ~ݍMPd 0 #qT󪛒=T%N ќ(/^Uubc;c,e2X0 )Ņo;b:쪜y /4:0DB1&;D*uZ5X5^5ʸK[ (ҍ(ҍ(ҍ(ҍ( 0ӈzҹ׸„:sȥj2)f45{Fuj?~7FWoBCR8#8#8#;y#R\ # TpT5*Չk i&K3YbiʙkbwC 3g!!jrrk5pf3\8G!C8i b n| NRLmٷ(-AFaC f%0i)cKJT7a˙giƙ$ bb`@1c8ij3mEt;(i222222P240Pmm3 f1cPcP` @0–n % T(LKuvdNBһ)<)8*T*Tي;1Q1PeYdY8yݘe ǶۖuGnXm!F` 0`h@emܥ&>(c!b0KJ0hu @  q qR*t)Me;9e6 *RRPJHM-! A1c ]W #C$iccs1=鉀i222 iiƜiƜA24up3Nm1110QFq&FH\. d2R24D B@РhP4H4 *F#!#)# $.{C 2e!C A :F#LCLH$i4d2F#NC I D2`!E>!1 A"0Q2a3@P`qBR#Sp?{,EhZ,(LS)e2]S)Ίe2WR-$̨ʊDaѢ,Ɔ榥X=XDdж;r1sxrvwGc;{Ǻ;13#:3/r,fD6vU-; Gc܇f'IS'hC\n]v{3RVًrߍ[Wcd.(FW,O>xOS؄s:#4N/[sdPO MwgX~yv1e׹W4rFž&ha ~8g,͕&B17(22){3>$ҎrT`|M=QLo-ǔr<7da{6b[Bl#)(HĚXrWsmQl\)GaJ1'%%)g+2{QHq"Dވovaa9q0eDq2R"ՑB"E8zhp++/%ŖH9Zzn&t5&T"QLL(I(OjVjWds3Ύ$%j6֬:Cq>-Kb;աB pٕ/qbXRJǗsEks)b]BZnGїFB|}1!jS9ugs)AK;ԩA5|ퟸfVQ\RGrVefVefRԮN(b;=2.kOF[es} YIKRB8&Vx3~V~ -hffff/YiyLd>9;3='|кeIn/*rD9Z/hZ4-,,[g'B""3Rc+ҙ VlW> %D_+=VO~T̏>}B?ُd2d b~F)nG>M\.#qf\%Kr_es$ֈTDu.# וq\%op̜ƛ>V%_҄UbU}}E|C_r*22ʌ{QM䋴Dz"?q(BM e2[ej=ݔA 7+ZĖ$q'72?5dNQܬR ?3w%Ui] .GS)1r)Gf=l@:esX8sӔ8lLEiNp͈`|7 >%JPZ>mJ+/Bb}oWsXy-o{޼ٔ2<+n?Nc> vftf3hfcDz2R385ϳ\j7̼2 (v;3>-} BL>sď2)w3q{^RT1.dl[-[-eol[,YlY|@>E>r6c+U ݓ'hŎ312qz2yG!.CTO  #%m^)Ct\w!(Sk6mIW<>b\v>[8/`G,\;̎ast8liBs4f*#%(GrQo[ї#)WLx$̺31#IQbK.#貺voN Ykɪ+6 #Z"Ŗ)מr= 2Ljq\}{%+%Y`cK 牋mJ/R9{.R܏,znvN]676ߣ"#m$Jm#bݎDq!es ~yĄR0lh[zr}/UhmuތؔLwɫgБ8iR8+~H#3,cݐC}*R܏><'bR&]uJ(7&FqKaݙP)nG{G~^ גԣG"ʽFUd>XW{W{W\EGer#Kmz?A} nGg(zJ9.{1*G܏EQEe2)|[")>pFhɌbAf11ueCHӕ|21~V5E2GxxxNJxxxtf/lD =5 [Wi]5/)#+Q]4QKb;!1 0A2BQ"@Paq3R`C#bp?ۚ(VXSveũ4QVuᆭ݌sb~ ,,Ǧ Ac>穋h/dvG ?R=ICS;EĎg`ՙYYWne2w__m!OiC2ꅶ7^-wB2jW‘Giz[;]hlXKI%Z`;GW=l܎#K8IM#MwL~FzGwśհխ } kG~GW{H~c{"Ф3./AE&f3^XyQV= /;~v;s1#/1_m^Z>echfff)ÂOwQ/|(SX$B:, R,|;5TUI e%G-e^ɧx"*vܵ&g9#FC)EQEQFS)p}8WV_$4yj;()P7/[DFp%,-/&[N.ktfֆg5GL%V叚V=CxbT&E6ǦWY/R$BfKlZ%^˕rcH̪T9E;%+E;DZؐO6i3F( Bf/vo:#g 5Б{cB, Kwٟg)oR|ȝ:/ Fo,K?gwzak+-d3bT>$VsƇDxvŬ_4}hlԛrQ} n]-MYv5۹5?RcC篋rwf]TZ(/bO_FݕSO'jڏak,̭R#l};#'FWԇF;qvg235ÝcطB+;}٥d^R۱RvV^׾+^KЭTK}^/r+U=\͡^\dWzْ">f!L)V4V5S)R}J^%cя ;FxxGrf]]_J?ܽUrT싽qNU_qPMy=?ԣ^+Ey/r*'s֎!yspr8YeE)>`I,C=D$I/1u#\M r w#]$K{}ݿ혯G"Vnl'/s.VrZ"n#!Ȏq"},f3a)>RN>KDU'2QyݲqKXoP$UaR46-UZ-nith9Q-д w8#n q#gL~,,a%ю܏+2#QEleD(&WGiQzYyOYC*z^/QLlEt-~dGw\iTj/ԷK[.Z&I}yItc-5ʙl×ZE\fwLOe,k /l["!eVqmoA(%jD!kc,%Eypv/#XxCspqaĎuD4"#,QI(g]G >\lX X(m1AJ,6xCO}Z7b+%+cj8?^z3938bJ;3 1R]-_@܎,'WjQtG^#]8I$lIWnxnq,,i9667bEYz3Ry^4X0F;`[k[=.xnq,h22, zj'Ea).z_S~KnvnvZCsp賉5-YTQEQP,rIwaFQCXCb}78I2\y=2e|~v-G~78.~6' 6' a,6' c>o}hlO}v;箴6%̾}|؟2N!E6Ic9ܗBWcrb|8Z=#ǚ_ Lÿ#t?+.X{bq8+ 9 T+&dO={VE!6$NXbN+oE%_b,Ŀ![23#<6><&xLYgx,3g 22*/YeYeYeܑƾƾa-EQEQEQ_,$C?npMoe6#:3YeYeYfcT !1"2AQ 345aq#0BRrs@bt$CPc%SdDT`?KDBmg87S!sئuV/徱:[on:[on:[on:[on:[on:[on:[on:[on:[onAԚ1m[o^1z[o^1z[o^1z[o^1z[o^1z[o^1z[MԦe"jkDOルBmN4UPPqjY9t#5Gb:^0_N% 5/eJj8 ZR&T3T k"UZZ8665u^ܚֲM7k5fUtYT?=`7^Jh9]*Kgt[ YRl:aQJ_'TaQ-̘/hdVti'*&MQ~%@<?:zy4.ӶuU^Jfxb `Vq[+AMs'\f3P|z|&3{UG2j5ń!uґX(jJ*6ڋ` Mኩ-P[0ߌ P_8Ѩ,_^)-9989)C*nqquyBpFTz`l+*'(PjυH1PO١V5J3!6\ЧV̭:ËZPb`%ųL}h1CL+;p62+aշ16Wc_ [w ':Ybl5PwHD,* TDN:v67^Uh<-<-<-<+RmGb: Х27AT>M@qUΘ Mm6D&e֒xH`\bbv6*l5+'^V;AZwlABrlWK "kgːv am,V(D!Gd WVtT$:ަ L-,y SuSMۉ5@Ԩ_SV-xB -Q[H[H[oUx +_pBɣC%Io #cV( \%WhA!66zu#QJj3 D_d[w@)W$ ]qt{9/*y8gTo:1ι0B kY)99aa67a%0ljxqtQበؐc(EjDuf*;''Y ԚhxXGɾQՅPݛX=lFsLka)kZt5Q=ra3;fhUjKy5eD%2#R;3Osh8g7Y(TFd'D\,M!mg}ߛ.Чz8,A=]!Tԙ]gы4뼱fSken,׶axEpxT]&b45ٙ6QXN c01 jlܨJuK&ԏ- [4bmM܉ \vqNEmXݓ;x @qfbNUiCBzV5g,XWhN fC;'6W\B{>^D4鰪`yn 5eLIcVyF+3Nd \#ͯU%4L&򼇪" nkXf_5^@p$xW yv43-J{hkIM/rad?rѠrI;bU֨pP !AÏ^36 {4 384⇰ 7-&UL~_uA{-n%a:y.'PV_WDQX bu?|8`aIȫłx lXx8q I${Bc#H=ͭ g+Hwh4P`a;p؉YBia&߻Mq5߉T 袼 &ZB!ʹAkL4 De]f.ulқّCV$p)#Ф; >F,aup>fJN92kgQp8hRi3YBnhkʨ˂/; S(:;YT;dV, E(E| ;݃d@8\SJHq)0(*:@.BQ0%ͩs&4*w6s ~)gG|KDc9LxCP HAe,_^k [%$)ʋfxZIJsNDANaq)oug^2qX;ai4IWdIg᚝ޱa"V5QV$[0 /S1b&$G9ɳCJiy O:80VPmb6YEf|*jd{p}V*$+ppV6-ga_xOTx;gCyd\Tf(q;/ \ fT^qSv;dܣ?9ThyTsP ?;Z+)܋){''k:;-;-򅵞mgdzAYd_Tָ]W;yU햣~zUh3[\ 9EWD: O4B߯YNp1ꗁ\]4n%M$8ƪ#d{d6r0\ȃ g|,\Y1>ȉ&b qlĸ$Y;  $Z x6] MaI]yppsp ϹFNҶ!Y(M;\ۥJ!.@ʊoyAPvC iQ`a;S1Q r(4pDzTor4=%6 [AvIv  <[Y|\+n>J|SQTO&!]Z?f]= D\zyiРBj+R)G.)߯;gϯ!6%Q;E8QG"/ Q&ﯫhl*'R|^u>D{J?JOsʏe,_^‰ ̌DGU]YҞCnc'vTr4#Vk1YTPlœj$hZ&L6YHK ~#HT .Ё2!t kus YvSewXWМHtC@)lHa;F6MtJI{va^̒pl:S׊J %GGVym(V)p] p2mѡRWQ9&YL&xr*0'pqZZiTG'gHVP`.TYؐ/v871ĺb|h͓u\JH \ ZbNH!Dgm4ص翦^h,kil vOBt8@q %( caX+ 1 ?UCuMaحL8&4t#{鷾nyUH8Gf_mTjuUTpĝ+4ND=F 4GsAkIe齽 vޤC(Tvg nX>)DQ ҁD7#@Ywhmݴ9v*tv з4FH:Fs<[BݝJcSJ]Υ?Է еOh-[蚷еmv"?vԇ:eö΄]X7UCԧ1ޤ;o*hrSDoR?Τ?;?Է:vPvu-nΥt/ԩ]EFD[ 6whT =יRSD:h;Kš!2ͥDS1Ž}VDռ͝'{ Z?"ŋm294ɈfmW>\(7 d)Zž+qu[ƣxԁ4v9@((ŃXkaE>GkC[gm+8UбJ{TwCoh8Ke.c ,i 1xnc{|or{ȕlN(ou@x[S(v]ҍŭ5)fa ]&0ScAjQ͕7rIcEqutc'{ Z@vȻ-Gb:^P~5ƣR9Fyz]N.ß,ԯsEcސ(%ΣUc-fąRe\cT8Vܱ1oW)79:K!D06am+[s +4S#T iE֩9"|Rю0C3ܣ8z$\1 6w\A ORhh3Z @ 9Jޥx(xNbZvt&aʝvKT<vikuVkتpdcٛa|.dU'{ _QX8x~=jָ"SCⱥM‘0l=/ssMEa;'TY${Auޯؽemo51@V6߳Ƨ 4υT²Z~v#{Go>i(( l39q Mرȫ8Ė]@Ov4fmB4i~MÕ;OmGe;dmµ)^ĥoЪ\á<4h'JLŭ3%YQUAlŋ뷋jNj.d}~sKWM2 $tɕ%AeF9OdaF` ״S[㻭:ȑ?$"S%M9$s>~ÅU͸={D| Ʊ!LɆ<>Z :Ur6SmZfa<*38mR]#Kfτc 0EǸR߇oS(`C,#)ӜНigA8dY`X/& #qY5ciV'%9U(xWŲ/î.P~=b`Fd8UMGM^EQdd^lwk$ Nv1Т`K\L TћǍX%QK#_q'MjٯBnᎬ'1xΝok;*h*eCQ=z iiU4TC'֙(=_ʍ^!7Sij4JC撹p함,K̚։H_^h 5,<3Q5x*=e,_][mu( ZnUtXGjjڡ΋欞keEc%E9$[P;0V8z Jȝ F5B˳J&P8٘1҉'X'ԋyhmcTOG)sbW'<63`:~5b<81ͦIر 2[ҞÊrddV09S^HVfP&K'ݱt8IVIMlfWg_Fͮ}K?{NM23`91jI!_{JQ3}h55\;/ɿ*vIУClXNx>eԷ:AٝKzh?3oU vu-lΥT/Է:пgR޺[BݝKz_۳oU vu-nޥnPzPgRR']Kzh?3Z=e,_][|(bCeP~=k峐_I0D#QaĦ0D=C6_zm:`xwLcaߠ=/ɿ*v]Š0 t0*{bQG1!dZ뱅Σm` mp"0sCKLijXwxJ=S.wp#Tm6}ʭt%)j@Zuk R[Jinlb%Ш'Z,fmQZChᬬ 1gOF$cHkc54bCOԶ֞ zqA;V6xA/$ٍ0:WYqv4Y:1/RsV` 3XGD_ֺW]˜3u(O#8ú%b|V D",JK8w\Dq 6mkb "LI[qFF(5K'Z=.=*7]y7{%Si 9'_&ꩽNNKWoeF&do+@+$r+Ec@Y o"$'ijߛvN&R.2x'e784NS%aejgʧld y'Ybs#UҾ[gh<=*7]'zh-gWb1peQċC3flKX5{5$9լ[n#6+TE0!;y&, Y֛ǛcLVVzПQ0ΥEEEEEEEEEEEEEEEDDD整整U5 o*ʶr[c9lg9m-7srƗ-[[k5mn[[!܋kw"w"w"w"w"w"w"w"w"w"w"w"w"w"w"LڝTÏi\06] vvƙ=^4}֯>Kn-P\0Æ{&F~N$ǖ-%ZT<%\+HB{4JRm\hf6 EjҾ2&M #~ɂ= Mߟ^ VY:xd51M˲q3j*QҽXdpi) u[7+uFnސt*8jgN8Kn(Gޣ}FͱeNN ԡCr|9:CDŲX :u|Pkz1.ǷcHQ-]Gvi>M{q*ș'69ȇ=J\Կ?*W8|ԲY=K!mc-sEw趱9E9VHY#dU9#G D$mZH{{U!ͨg|CzoU"uV0p;[zV5^aްb:v,t1;̾֊Nj?D&議#l.Ƈ]բBuXv]$ 9Dqavk<䱂d"I'pޓnoئEhWr\W+\HY!\VHRpi#ev:UgM0y:i|:SIg/ĵmO4oeA鿙o}7>MϦg3鿙o}7>MϦg3鿙o}7>MϦg3鿘l7+U\L]BP4 Sڢ#K?:cGk/TWU%h Ҝ\sB`V9YDIztL^Rɝ9l1tLaWYFzϲS>AfwZ_v-uɠz}&?Yg߭qd-<Ȱ%M2JJ8W)ӾSPYУYk"~J41o1"[@o(r18<ߖ;I5xi+'(h^1#}— m̈́ Bu\KhLyr 1ySMq 䫅@J`{ K.ajnZ9ªs\'_W-5hSz/;5>ԗi1^*<.=cYdXEJs=O:~ۆFi }b{O= Ⱥ(ܥֆp.M^4--jt0ut)5\iZt5z6bS4,Uڎ9e[Qh1VZ N R H5{UqtNIsbp n1PCmCa qg5qԃirDzK4_# a.NC`cf(ub(rYP_ܥ0ZkL8v,zDEj~Wm^&m 1-o0 `GTPlC7 q2(I:%A)\Sb鳅v-mS\U@٧Cbg;h?v%հF&6/nq^4p`ZݙD5!z\ @~ g<{Z&3?kpm*+[-˜0 yV Ys*BxԪvfh53WPpu$h駬bV|d̨UY\L\aї8 >rhfBUZQJX*+Ak2ZWJ/^ro]}n99hpS嘠#[e$1޼O9e1iscGhxcRKR֓4؎Njpx\=݀DZͷ'n|x Ie@/bƒr_iJ5C>d]fSFku uӾfB= F/rgi}%k"b9j 8l p Qlkjv/dLTneTQ()'NsOME]pbUb3ucJ2oTp1߂觔&}գw,AX}`-frCC.'s77CN#ͯ4 Vsu?8>+N(m3^KC U6_(uRW@@X[l'􄷒JV̎0B6V&طK͜a5KmͬR*E8`pӮrhxDW?^#HNPb_}YИ 9<8U]+>LlcQ1xXz2K^wT/V=s 4ju{f-:WԻ$ VEbGy +W*ue=©3u}@gȧoMbH*|`{7P_,Gnަ< -I֑wtLT1^SIa (0zRFGfGfV#e!bP) dʅJ9jZeQ๣R^Id[ù6-|ʶ-7? 8%Gd)?.ſSZUSaS!]P2הVK9g^Z֓Q.4P S1`h0%+Z ]E}Y LM)Cl̍~<3G1^ӨG+󿟟X> ۼpN掱k^`8 EaON沵:V*.ſ_K яZqغj>d/+P^x>0"⻱eº^<-X*|sN(Q \QZ%[dV%KNn\ԱIB[S> Go)thGM|#3a7~/ʊ37[g~ٽSPvsbVZkA:PWRF zSri*lLv?>/+nſEϕ 9[yc\)g0vka(+=&18 ڌڻNY BgمlcUQ+5|LiiZN|&SQ䤩kQֹD./_]vd55ǘ78&z/Z2Q.3.55\G{Ѝ!7ӦO;?wz bcURJ*66UlYwoޖ'Ukif4=ۼqeJ*TS|>wo W S^)PLehO8Z[7Эn*]ܥbC* ^ ܷ*/&̙tҷw==BOc ¹9R䦮- оvŅm8[:wn*4>f_roSfen6P"b)7|&b ~ %CU"$ฯ7: j6Ư'd] G0v7U_e9^sB|,>__D_.UM^_tX}E N0~Ǝi*G+,(@&? 'Oo Iv-o.hU0 0.x~x_V'W +%Ȓ.PG _Acc6Us1!_2s&68"V)1\ +۪U.uvg e[գlQ21BWDгԨS*"SL0)5 usZFp  Su~Gb.jw~e2QXߕz$_.X*Tty}P0)ASOJQAV_H\m6n++y@"Y+\MH" ^%z5/=cBe4LU<.8`Zm%MB.ԡ4b("@5o_C> pSiQvM 8 -oh77M8Ŵ| VZ"8Lc 5>90겟$|.ſo +~wfi {ӒSRRpf"ح!yV pQݛs2ei8Q\d_*Tk8ל].8cYI u:N++0/;6 KC{40W*fʙBEw#%Q*..R\c2Fa bbYV+'9XPf<,XpM طl eʼn7Ω~1K`p~`njѱaHeK"w w#=W gsnX, l&JL̜SvnpZRm 0/M7b2ŏ;оDSf8ܵznR˓C=QO>H@h]ceLʌ2s~#zRkV91Ci)Wɟnh{Ք!W[3UJknp>IU!<Ѯ&X 񽾷XWo d/<˱o^ŏ˗/_q[@_\cP zk]/V9Gp(f5ǟw`Yx וxg&ߣ~\!IKذp(Bsн\GP8qUF4V׬GoXk h0.`ʥDlZl]ߧPqZBV kRJ*SH gjϫ878kp; K\gODs !۪hBn˪*T5M􇉐Uv7$P+?!ga1jkq/E X0KLhFKMR"ퟵTۧsrܷGIz$X`wTbwa]xeaE`T2nHPT@KQTI[%כNzER+obhr]խbZg7}#-$^v Nu HuHNS@A$qM[ ؚ=~E8YtYHdTz]פgN0ϡ\=`fůg6%/5 UѾ&àq*1Xu`7ką*ݢ K9G:ZLÆXk ~dቓF4\Ѽ hAS BG!LPA1=OQ5k*ጀqo'zˤ|ipZ ] 0d*quVW"S4@< +8 h+fPqEb\Ҽ}#WFdL$xn f뙺Ũk{yN "CBq.;ɹ}2sb>[my"@g%7L&l@keZzx#Nſ١ wjcWWIջ/rF͒sh.hϩ3rItin4X\=/&nF%1T7kN\&5@F BReL-byWR ImMVy:]AC4Wk5]e(T8HҷKQnKkǗXh HBfʯ/^n^$Zt?mɻtKU 뎞 YpqYM>Y a)=2oj T,y X 3Ťov{/v-utQx.v}4E|z+sUg[.hi,Ӯr>΂sBڔkR-;0g66րC*_M8sn]MܮǑpDˁ\e` φWh+l.9Ȟ-zN!'Q[ r kOP6:c93U.?ai1(p@iSt]SDbJ[W9pMw&0}k9q̋J$ҲԌ|36mgB< X q{NV)փ-SkM#L%GoOg7pv-~D&M~7qe+CriT:OBgF;Y+=}co@Ve:pGcQc݅lB K.\s&y7V-ݧxśmD0m:XⲥdjX+5q/3ra8dጹ`S2(qFpt r˗.SPrlsV=ůxsפrrYvq7AN_rʎDr3霟LV+g/ N5\җ%xcǁgT =qB@MCK7~ăMTP UxH_JzZ(`k-G::LD2e;fOo;wTFL`\kV`hfZT%@ J;AJ鵎r%F|xu!TX5QptƕE?<dVwO?3~gt;woٿ3~gi;Wv/_m:˷.yd&Ƒ&iD pҥ 2h{[R'⚣R34vf<eLP# f/1( ^I-فL6j,Z /-a+s_|!C< ! D3*C78;B oR-B:C)uΑ/bPMb5⋺l>4~B<<B\Qp/@EEVt@YSEn&4_MHk_}OǏ'xB3CС2h-[̠|_删jpmW`f;W4H-&D0t*Iߗ2-*ft1-?a폧82;+vWķ;kvW쯈vWv쯉_'a|N;Sv;NNy~+ yC~}nP?''!CTzkrwsggRWާ%QxxUiM^(7ȋO E w8bּŞH8Xľ-+> r!ȇB 9S:s:2$(/P>Et QJ6ip 4 Ju=jJV(sA@Zgoo8g~nN Nl|cw+V<!'= 核Z%EBt8YkxfA`Y~a,THr;ܜB& \r3ΏHubaMG;S oCr܋MA*npe/uD?yd:zJk`祤:}}V8.adstϡly"# Y"əQp2)(y B26g a6Lm#a2I37˴>,-+Wgz/Xr=g%9/X{!YÙQ,6Y &5s~p e-h ;l^eDټ)+zϿE[Zڵ[Pmzp/d2Ub,Ɋ&؎v Pb>B46;/v_r_n;?w??3~gcx7s lj6圙ɄIi)+/ pE'"r')P8@8JCbRW*RVRr"r.rg&r&r*r'&r*r*r*r'*r'.rg.r'NDJJJ I$I$I$I$I$I$I$I$I$I$y$I$I$I$I$I$I$A&v%iGxI$I$I$Ka!a0ӛ^NRVj2,7@I$I$I$Ir6{ur+&!>$I$I$I!&fitIG.I?v`AI$I$I$͜KxS[tI:Q@ MD[y$I$s% 'iOM3Ast˜I$I+_d_ڙՌBn* IhI"{kk"rjR xTQ-I$KI >Yځ@Ra$I$]LYFQ "]F>㦴ZYlK$Hkݯy$WeCl Nfg)$S@I\I&!ASIi7m6͇É$hIrI$iٷ;F:oљXD $>f k] bY k$Hv͉2:xT12) b=aI &ٷ1&\AcɤGJrI$E/쐗`{Z7~!9Kd̄4^$fVU̿#9l5Ə%/s `$IDWFkJ8 S` 04I#AIwԈ{–R+I$v@@d ϷI$gB-zr@cf7]$Iʶ%8GNI#!`ֳ1")|I&T I.I N5$^ou`UrI$KD  *@9 ˮ$"ˌTdKBIXO@PC$IFII@x$&`zeQLI%^3瑎JHy <&BI$NvI$@$$LH ]I$ h$1HM;+`G8`$Iiv(|ܜaEM@LI'gşX)@$I$$74&eI$$D$ $ bϘI$~)$MH xY$I` $HDI$I$I$I$I$I$I$I$}!I3}M$mIx|l$}|[={?o(S$oomA \G}}<q!2I?} ^$=m}o>47$L%K-KLh #$I@\HoH A "@%rI$I$I$I$mI$I$Mg$I$I$I$nrI$I$Hɽ$I$I$I$A$$I$I EpI$I$I$I[6)$I$I$\-I$I$I$I$I$eI$I$oae$I$ H~}H A I==54E0 I$  t@$H 9`\ $H H p۾_. A  @$A $A?%dzAH@ $I$@$ @  I$I$I$I$,!1AQa 0qP@p`?u)w'rw'rw'rSY,u-[I؝؝؝ڝcdLS'c;%2nٖ)-)))eDN9mv<j-΄zP(Jى\[HnrY|^W,*KD eˬwM̕AVir3}0vb\ |Ģ`B%OB:b,R;}!3GL_^SQQȠ\sħ%hWPF l( 6vBdSwT WtdV 19K]G+o^XS^MG^_@"+t=#C88F E)`"?b.=qTsf@@^J<ϬBYj%Aq YB`esKF· ̍bA:1Nvy;KKAɗ7l'9"9yEoS+rDYh)Eڳ~ q5µíZf< [B# gZȭ/ kQJIKgQp.^w [ Nh11ڷ--D?R' @ʀʐ_TMD,P h,K-f.  o3P@+ .E+W)s&\2J3ٕ1@AB(*ιY2.Q|zgfNܵ0m/^1ܶ.EB[ A9A<\螉d Y5c9Qg:D\\`:7\.7#0 G,e.a52`KrH[8dx6(q*K%&MM#Eyyy~}ǔSP1zaR0%&ڜ+7*9+ fo!s5EO>RdKw Lk<%[`'ʘt7 o,fC>PbLE*Ҋs-K7/:˗x0l$"9k_?^P`Q+nS BQQ6f`E"^8 yoq ΃lbP6ͦ3&H0lKUb(6qpRATy# ɾ){7.CQ) QML7+"zKƍj+r_r`&-)Q5-K!%/A>]k}fOHQKE,r!ʓ L%:κf;WXZh_ \IRԱX#}U'*^kgCߖAf0o aHre p%G/Z&%"X)5p3QqsMA3BXq~);.j>B=K(zYK1ЃuNtC_$JՎNJC_k̵&8jϴd ĨUcRt}| |p94Ϲw5K?N^f~}"_S5'6u;{7̭gPS]a}s1OSؿ|U~^Ϥ~I"|^T P ͉ UG-.hŔ [Jf}e4G PpV .ӭ8-ü"yihx>Ml@&hTJ^%3N< 0[YP9-0lSXrng4Yg]j qGڡ('OC"Lġuk)%cXq3]{Ù(m.OξGkjYnrjlj`ԣD\ 3.9~}8ZY͹Pb5}e<1rp5BhdA%}y )U+YDQE{1hV_leq9ܬ+9̈́ r#HQe,9K56ˑwPt,D4W 6&"0.,Yʁb`_9D)GrP,$'n!u T;<x o~PF.X%o6'MtjkG4Tg'U9\c@0f;:վܾT-a$k\9. _$*j[ $YFv_Xt?7;W=7vXĮ `"6'G0UQXTH/a*35.W6Q옥BBSҥNP3藮ѤEs*X <4N_$g*ǀ xF}5g%phđ3'.Npk .p=H"ΣH*Qmóc\_΃@or="c6_BZMp1L JDpp@-%"샑K* DhzMW j qW9^/^E3`Y ;T1 M+Gy3JRⲗ7XpU`=Lp5F\6ľ˛prTim2NƠV0kQ9WkȾO ï熇iC2*r/XީR9QYzD,lXuS9 y+4M|a.\x\^Dyys_>|n`/AWϒ2@_d"씩CL oH"X` $-#;-!. 'Xi|M"@e-PlDr N~09%6'<pl\HL%c w (c\9RZɴVg7x\㙄9U5 /xRr踕\Uߋ_ye ʈ'/Ey\VT H4$H Ȅ9|\+.1 b^Xsz+/e˗kw;N0AVٶ-u3К5[sKfHLs 4PԵ^҃5Ե w&_rsac$UFMx& k+yWϟ;̵~z~> wV2s+<:MXE=f8-n)rKIF%YqK `/2\h;<dz}.v3IciskV=+r^K!|QYTB7.ˈgP΢ׄ5㫜4ܣqmjS)v^Oiڊ{N؝;Obv^Ӳ콉{N;Dp9|QQӉp~3s7yj?s[#EJEzXϲ*g ^ZQrFk˛+''72)u XQxv L[s^~}cxƠ@7P~_&h>V䡔SX-wt޿I%_x )u0[ EZoIGvF]\擕tM_˗.\r˗.\r˗.\r˗.\r˗.\r˗.\r˗.\r˗.k5x;WrN*aDOQdxXZ-Q@_cg1C:jr+9O^rʼ; s^;t,!_~]A%|:'?wXd_*(|H; _Lؕ~p 8DkORsxtN ,_?>]CG# f<3_ #~_ַiCL1?/ׇDԩRJ*TRJ*TRJ*T>J*T\*T\*W v>{^=cGDOtL/鹌t̫{?GekĬ2+|:'?/ˤj:'f I J@ttoGO ~z~J-Hn&t >t$u|7qkiwfBoy|mphVBsCD.i݀D9DIN%zJ)HC5*T piB3/v2r˗.\r˗/QdoĄ8[qό@J8o*\/ {;pJꞩ*Tҝ "y ,!1AQa q0@P`p?ݍyKS-Z[LS)xpJzxk2Y™OIOHOIL)LIOIiLS)%)5_7qNucMp;ʮcWU"ŗD[]f뉇,_*Oy~C! 8u9$(RNphdȟNbsD"rbCd̎&>?:\&GQwK7D[--0M;SjC\k\eG-QS7c/+D7jZw9x\$Sq*ȬH R61>4ykԋ7yP9"bδx^ )G5d=i c\EO2.dYތx.m*6J0‘la LϿWӇ"bBp& ,^pz@|3zaLµ-1+L|8|D *syĢ|J e#YD<)EKf@sI8^Pߛ6F .d5qQ2KpiNf;>w7 uʠjY/YrW`8f+,tsfW l̦7Rrȵ_17~Ҁ$R`3)))))L [[}_~ARFBu5M4F"cL c5db%;G-\m2T;x0{GR:EbmTQ͕i9ĜgVVVVVVVRWթ}ϾU./n0GVC>t߿^˗eh@:C\DLV% SXu.eK,`*@-;+*  "&)+f>t储[%WŌ eTN%zJeŢGTljF!5 xF2l;럺{g}}Tb.PIӓQaw'"R&Pr o8LyVY?|ъ^o"(uhMO $7 > 49ZMрe02/^; X=÷{ V)!kԅKQ'FT3Dns,*aH$P[k;eіgj50Kr0sx0Du)h#rZ2Qo>2v0r|@Ի;(H 123o[KpM:QGz(KYjtn.I(|lyMe嘈9H5ITǿ|c  ^J-N61 L< {5_JLLɈ]qSj bx,s`Ǔفa*o_/M)?G.fQT+K7Pӻѷb :X{Etn/u0{-]Nv`+z|(+lGZ%`C|]^p[A/IJ6y>)ʞ0cH$WbC]Vc TQZ}}0co/Zڼ1,W2b!i_vß6X?l{6?q_"R ff_ ı"r}`l茱sD|,fC҉@C+3ИSw_bg6XhHNXKUrcT 4ȋ]A'MTFO`!cqtKyR+XÜ|2,RgppTۉxf6'-ֈ; iODe!̢\*_8 ߉Zܜ;%Je2%JzJzKJe2%NNķNǗ)|:OCM@F2C%+:("9Mu6+0|]"r"n|l. aV-p#qh,,g3,_!5*?<:Y(aV( }&7=1\G 8ǂoB^_T^oM*@̳JF䋫2t1} )+zs z6|@m?TU>}oۖƛsu0f[9`zXigPFݱ%s1vjlMaf'RRRnέun~hi~bd3rXGc]D2.+m.1b4arŔ =L/Sw-3-Dj P1n|3Dx1ce{9%/"b{j_vrui:nweU)j\SX-wr-wyKqZR7,f"KG9ތfL湢\ǜĶw0E H5jg[03p7SxE/C\ɢSC"Q7' ҭqbzZ$i@Z!+y"6rVIp6e.\bCohep|l_VnSlOS#_-*Ũ@\:C-y6Da200ly||WxWbzʃTuDn8 }\D nZt:u\LAOQ~ GDuW5^z}?!iXlO_Lrk̾ '-.Y*Yn|sWQDžJ S5zŢS^3-Լ\3R^=R`Y#c n- uD#lFؽDb Rq8l#M-V*j[+]JJs|F2IvXe"LM-5ɲJ1\ J o,,ʕ8)}o# i߼ k^>AlOS.: Axw1! ,HP13NHÐn#/œ VˆS~*\*0.~5'JԺq)7^| 9'+W n$+$<U zCIp. jD%[ly|#5*RJY3jkdqΧ~z>ocl@cǚX&Wi+L rMTi@\^ǗĮECb?e6~ˆ} pnQU# Xsp"sVn`pG xV |,i(chQ)CN L _9W݉(EǗ® JrΥ|yk2c,9*)b me@MqQ,FR=E` ly|+|O ƽm'\}fhC+%b߽EٯT ([A^ TV0t@*üMjq2+~" X%ڞǗ^.ev}c1{t:W{S36>X^%c4Y,s ǥk7TSS&ȴyfR|p׿y56_ %MKel[-ew6< ԸB\qsyeK)W^t+:1s NX󆳲*S+H@/և)/UGo_IyH4GGp&(D4U4^su i\^4?>x,OIj(*Q(l.8~Kuq^b02_<;Ǘ2Le2LS)e2LLLLWe<))VsGU0ip&n9$?ÿI\+⛜\JeFԥ\O So6ԪV?P~ 9_/mٍK~NUo_ -W˟7߾95՜. h:QJ5\XkD4A.?XGp7M/n 1.%q,eU"%O;]_K#Dx'7W %7-oYll[-el[-'+!1AQaq 0@P`? 2 -B4ʃ1+%YfIĝ:tӧN:tӧN:tӧN:tӧN:u$sIׯ^zׯ^zׯ^zLoZ~RJTR&ېl-yyYlgxj,LUzZoM>I b)X2O~~}i~}h0Uֈ?8~PoOJd?KϿR]DWK~)>CR1dp@q:O+hB0IB֋w+,Z>wOpOO YQ6ڲUUEͪ[ `rÿHlGS(DTo6So6""f~9}"ijl^Gy^<&'O[tdؼYiiDT k8c[]q4^TvRU~S?0jՠ4po![\/(φQ ƓK(nte)Y `ꂗ WCeJ 6ՠ*QiXeh[ ţx!ZEE8zJ4ŇkIفuޱ GOA\lhV]P|/M[w}-g4U5(jK13psHbF(nJJ`0Ly& D ҉fA+[%CFDlMm \ hp[R;/};j&1P_?c bXw_juJ~b2J{,K~m7*?hRQgJ0"M'FW⋙ 3V⪼֠b"%?S*@ F@DՒ2xkR͙ÇJixjP^4=:@e#YLZJnu h{dm-S;h.KKC{¤`c Vbkf0k+Q{V1ĨWݛ|G"5v{Ҭ`LK=!j5]Ӯc& εXeb)&` {Ya)1 : x <0&ws7GI]W+ r:(&}(u9"ɀMϡ"-@RLj*wJi_;A867h '+Ve..4L1\ܵZ{e|&#?Tpp-XYLOVnA%:%`TCnl@FH%]Xy;=)Em7MLcA0(@8[邰'<0@X)2B*2SyrpvmF=8#ճNtsVġzQL@2*RcA[a@Amt >t>hjDPÜ CBK!zCDIiKb n->6 ќfGrF ;넼غxXpr-y dh"kFʊJL!ÍEX)bյ !kv#Q|g B՞BVhڎk+/~S@ xuyGeSb09 )>:d:ٵ &]Vo9-:`]Af([y_=62<2-/ Th3+L۟Lߦ33nX|*%n[b xTDЌAJPDh>se>|Q==n%M{0K )8țvqS֨Pзj4V]h.x&U{B5Pi8S~>P cXX~Rɵ_(ujSMfΗY Z^_oN.ZXC(6`PU]ed*ʶd=K]3(mqLb8pLA楶{Ʋ×eA`: ;o,rn3O t.#c d{jb텻8ͭK?tKL-00W  w 6)1B WWJwk:p|8-JQ;# }[S]o~\Qe?MNϬla,Oa34hz~\ZW'*L PUjݾn"s% ec))bM35R*,ޙ)4ѬsY ˃=jhũjbTn~e7+I{Vdk=kvfƶ+-:e_:Z@r$l-.>ۥ+`sJCh.AoSyvQ $\bS $9K PZZW6~PKZD*(| .5sbbG؁[FepЩaŹ(񸠤%7h+dY#(8=cK(KX~C~Cء "o>R@tDIt!PH=l>/_(ecⵓڳhؠ,CPhԼ{C"fJUL"\+@YkkpX3E ,`o0 Iar;g6%MvJzJU]r&nS@tJSv]+dU+{-~ç` uEqiCP +2Vu/J(-sB ˓`օ*nCLu.!z 1ujojX+@N{v,)/Ya` ig&y0Yv#`j(XPk2 0l"qJ@J*ڴJ*oHù=h eo=O{C]H-n?ΤHF _%!k2r :ًfeIJ\R3s.~` m=}o@ 6 ?n 6ƳP+wp#DqQ{eV/l ` "h2@:q [FQaZlNغf8Q8D"hVDmSeV<0C .ScM(3YL"ZIH7&%TCէ:HHH]&B{8-Jh՚0A;y8vfFnd@u %?kŪxo TeEPZ,&ݡZA`9L? T%/7Kg! ZpinV}pat8=oyM5PN)Fa!}N΢XP)9 1#x˱/9P"BtwIҲPRb7!:qÀ^nw mE>W US- W.9C%wzo5^5A lB`s1@̪G5upb~RaTe͞sj7ESɡ}q4-BUVk<L\ SwTX/^N6Qji0I\pw;e#'K|ۅyKP ١jP2L,h!:gaD ]HgT 5ٖX"wS(Vu0~ו>@oP7*]i 3rn*Ӯ\A8(zG f0WH D갣xg:w۳oOOjm6}7yHPd%[s'êW6+) eV/䰸s*;S<#Z.E}K%뒀8윏 PY)A̮6=f/>q9SޱRk5>qC$Q HOa0 _Ku@aUBt{="Mp`9m(u+`*O-%^/ QskrUS eJЊ/ @4՞J {z@Qs;^+aMU[N3z*-lEER[T4sU[kJW&Tj툧/bDz,õH<=!Pw6K9_ "jΰƢj´ѧ%X6#-+%rӻ})}%M] STP#׾J(Zv 9\NIH TbA?C ñP8ZUxuB\EE+ojӁ,b1HT )`Ht *T6j̹SbiTٕdEPRh0xwQuHeNfyLxzsռ/0)$x%kEw]R5"U3W6K';''pc%Z{gEbgDL_}z?8=o*S`U^dn8eJr:lMr8c 3[tc(m6=HYV:䷐Ա)L`Coh"W(JyžXlE5몲Wky _#dl]7kէ=r[6:LEǽumUkJSjmnT0߻dm\@zD!LP\s9n{|=d9N7=35A PTbv e*Z='Wlٳ{ӫRwM$Op!f4~[@z/@@hƵC1XQU迟U"W}"mH\]ҔHMAҝ:j-mYRE\aD+khL%UBqX=q 3=S7\Wɕ xZTK3y2lBUbR -W&FBMc0nP Dm"v6)`qN r iiE@{o _WZ!^X׷l1蘎,k= f.H)~c. 7}V4#JRÚaK-(ls?0YMPy}(J3GEX&YuB/|q*Я* d5g avA/5=Bߪ+Z^AkǨ_^K?2gJ!塯IiaDt%L=G'D.>ZO,T\A ÷V5oрQE% jB^0ZG`JǬPm'aFvyfKԘ[&+ovzK`=rBPR 9k8,jTY^+WȮQd16]u_+?hl Ee ʳ\1)9nR0lmC%v lfNK. *k-d8M IpFHla 1aT^9<#h lIj~qҒ>XUfT,_ͯNx(%Z00UĽ6U)%ި%ix,PkzqPt39XyqQ~.P׸ֺGa}!C6Qֳwb_tCFZ<ʈo* ~w%?߄7O?1&-}EO6=ީE%#Uu5u rKjXUPsK`^OYko0ݓx)wݭ> vj7?Uw TI h02&\DT:UVLj5ՠ.@ˆ*i! , .r8b FKl(ѥj$,'SB7Pѱie8BSfq&i0 .0KӽIHk^qpceGY\|!lnRh#BW>a.݂NfL@W ifۙ"' 辕j: |:3 (}4ƭgʿ1x14a%ɼ"˯~Pj\j*LƎQ5&8b7TMC=G0GH͔CY"A93}UَÊ.+ְ.Hb(##, jѵW KE**GQ DD@)On2ztQD{FZWC0 k#UNh"Tf)di^ᘾkrS lj.*@.hS&OD&8W!%a :#$M~`-kj|^+R!Zh*G\ 5GD\Ұly`{ckhU˪ 2 @qp4/b\U9uѭ97,MҗYGNjuh'Ј5G+KtcS)Ykn.ܣuTgU@6ԃ]FjȾho PD{1z . ˬHZ#R3^}7ۥg<#PE]4OXnm!')70*éEV`(FIR̸no~CNS s 50 36sz DNpz!k,*&V A*%6`wD*Mu96 .Ȕ\vƴ] A3֚Mf (M0Wۄ|z~: 4iKY֖/m8c|6g8cr.\^U\ q 5,[vNt9Ʊ-pFW| ТK$ *P )6z@HAG]ru*fmZ.ôUPTуU|ͻk5t,C<˴;ՌV^8<| j3I3o`͔N 4=X1.J{FfqYgߒ Ze͗[qHyV,Cf&Yo΂mԳVr/1L 7+t珁µNì$fE_ 6HAPQ`dQP3~er vPy2D5BE,cz vTBݛHyX:837` "3}JhzwOcAf=6b%ۀG~Ta]Rv~IE^W pNh<L*$luw4'.!|%Y |Mnz~m;PL̆e~Ġ [X>R ^E~8lVC3 FEz4G C=JJ亶[nYBɶYgtNDN1 %jh Tho|f\"N6KWNC?m Wԕ/cA! q>!"9*zjZkTF3m^?΂ \ZL_)jpQ)`q sPݪ&zG`j&H#2& E q9tInwu r{/OEfsea˒(߁i̔ iu+)z.C* @Pm˄1\c6!z"Ӽ+zL)zHuUsfK2֧2!e,Zilh! օ"pNJzEӢvh %ţ}ceeH'@ ey&kkj+.<P뒥ӛ h(g=ƽ1!{.MKj=&[mx+XB5p(@Ksztf]-UFFz0.Xc:~rFRem((lM8"Urb2bر ض-fX)`;P  *-V{2܎G` EULΨy)s1;ZXD!8\^ B01F #IV!x`0TTECg~'=Fz3єd ԡQPǔ b:U@[}g=F~WAM.ծ10Z]ўg=÷`yj%h3! I*S{Y|4Jz9M(btJr- PZRoDq?Xfj;zGl]].γw11CibN=l0I׬DG!SU|3e6;Q"iapK4/C Hd q}NL%0?!,-s |cE^r;];B/xtYw/N֊ lV½d Ek+itSh4Fۨ,l-4`ank,]QMFvA,]((1~h2cC0d͂yf-TXS)ДJt%:*Xnw2 IUD*B .d/eվet u8j%)ДJt%4B r ћPbB @S=X8CA!hb6.zaRbu])3@W DϴȈfj,Y_(w@/1!e/J% Q|(\Hg)\, ;f*Tj҃+kCA]њn*'Zقݎ{Cj J21R)F1gg9r(% H-:ZA}KeO)N ̗ZUAZ@꡶,! C#k~0EmuG8OWqK/=BN :Z:W؅6Dw7Fb:.#b/JUV Äə'?"ɪz3"mj_6nHSpnNj nU+W`F3G:r.DeG)2ȯ ^YtP4a[_ym2f(mvT^sĠQ?X=n_!p`GQD-.7 %f#(X@ , [\ɒQ^Ćٽbf b0QYdY,F?7V%plۨ4 栲Mƶ#CTQbCjXSf{ͭEC @GXY sč10AU*wI XGkhU՚K%d_6 eB"|n͕439qPw`|lǮ4UVzPeTo% *J„D?Њ4(1{RT]VꑴQ@^Y~ޕo,p@[ `6Ί[^z4mP "\̾j@t 6.$\sa!%kb>㴴dWRa={L ƙMPr/١un5DPXi={L-rT052G!!\bg,ZBE,4~ٕ/(L  ?GOSȋh%PΓ_ȂEt@kc iHpgԉ (\ Do< D4? PPlu؊1$BDEkpc̈́ڭ &Ivs5FbwqPA7fYfcx(=/@ئ`-UB*hp3gٞ<fsm{tLMb"Qb'x1Jj-WH0 PdT"Jm}fx>}Qu H/Z͟@Nas&{k5.bGٞ<fx>}-1K`ɥ ֲ9A@ZcwWOf$d"%Lё-&ػt1j{*żwh7[?zHb2{ZA]BUh\L2:S̻3??02_ӬW~az^[YB \yx; %s;ݸ@qX˵(nm%{=Y|`Ǖf^$w۷nݻpÎF K총s&Wq"@͚6iVF"k&QbD<@pBϸ{Ljx8A -*"M(N䃩k'7<`X+Lm _^PDI`;fWrp5Pb#p"H1zosUeRۢ/ħ}o[HMǿN:)_rd+TA 5gZ4.U,B(C]l+$1&L?xj}"m^B8;K~FW|ot?K#rϙ~-\9J!w,9`Xu}ف./zwv!KzLN1L%3wti<[b C3GIcPfS&@TeN_8fMVkXB+36T; HFtj3yfXSx XΒ١*xE +(D2]˜'˘Xqf,C#_n+9X.'sw89J9bWw:Usq&ۛto52A Ry tn`;3Ys} ىHbbq gVp\w0 _ f/<&~ˮ]PշJb.P5ЃF먅 M߻.*jgx:f(XMO s(Ucfg=Fz3ўg=yYR9HOz8\" jm&atn.*LzA6av3 3 Qq}P90̿2 ԥ@W4q0H-RJ QmhЭ(`ڻ9Q)؂݁iy* U IM쁖zIAoFeJ_Y?3w+]asL8>U f {t784_ /53,QNCG$ {9 7=YmW-ShIO% ̎[2f%.Ξ܄WM3P_5 A  ־K2Vb[[*iB"~hYFv;ӽ;ӽ0 =(dwU jO`RwEݘ_M @Ѡވ&R].᱔,\Me*nrzwzt.)z!op1u~,ʦ =W 3+ t+1 ۽Nb}3;L*+,$Tb kpט?Y e!U Ppqj YH:`&T:uTSaah TjPZT-:땯,TrPrÄXr(hX9AY!r4vBQC@)e.B"6(P"S2!jy`oe"K.,>":Sk` CTmy'vcಔ~ҒAD#kbk`đX n2PZ[J}?XпX!?S/=<~X_׬YZXW?H,! ~Hk?ki)ӈ[?PC_ K-brU:=(1p-zL}Is,6w}[*e[_v_o_o%() R+,`-l$$$}|=+WƥJW¥JTTJ*!ZrY`O}}IjǬ.7b}>@-]j\:>a VI^ MW;% z&jxöeT*n=" N͑>J2 (ﱋVv!rU"}[SrSR Uoȕ^\`=*zqu|\h)]5%^ G.7<ݓ0#[ah^O_Rj4=g$pnF\0q --(BQz*p>NOU@5hN`H)~xڂ#kCI@"vDt%}S:ܽ]}&]qa6% .Q\cy}`KBUzvYB&(RN*Gb/157`AՠAw@o,w0%Ƚ'iqnqQUvO(Ӡ򳧴^4ނC _E|Z-ac#hg69׳_+)w|s{{8{ĸ{xx pXGܲ4?Y:Ÿ')`wB E 0 1]?c ="x{ ܒH?3.W).Y 3 ;rro%i+xDJ#iE:a.e-hZ!cqe|Gϩ?L" %`eFJltT4 j;.qAyGa7ZM0q mq Y"!Jys̹|ρ \X9L\^w-J?*+ҢnI&Ye?'o~{pdoڸ)ݍ |P<@L+K~qyM+;{,1ci" HR(0\Rt=JadnD.dZplgMj$&AGg'_b߱e/ۛ/衚%KeV 'PZ_5 b7"bFZWJHfܡ_;qt'q;oۘ~.-wd,Cq^ͤ5b0m< 颪 d`PJDm xn23XO Z [őWOپRUU`rY_] L{oea1.bWQK((bSzM@='50記%N[F?L DtC>SQUO33sm&l=:g8';9j3د &6^! o#G(r>O4S*3'INE86qil=(`h? !CN!4G AW5(x.))-/),i;ohzy/v1vYppq:xh _:i@8@x'bĬIAįMopidy-2.0.0/docs/ext/web.rst0000664000175000017500000001073012660436420016223 0ustar jodaljodal00000000000000.. _ext-web: ************** Web extensions ************** Here you can find a list of external packages that extend Mopidy with additional web interfaces by implementing the :ref:`http-server-api`, which was added in Mopidy 0.19, and optionally using the :ref:`http-api`. This list is moderated and updated on a regular basis. If you want your package to show up here, follow the :ref:`guide on creating extensions `. .. _http-explore-extension: Mopidy-API-Explorer =================== https://github.com/dz0ny/mopidy-api-explorer Web extension for browsing the Mopidy HTTP API. .. image:: /ext/api_explorer.png :width: 1176 :height: 713 To install, run:: pip install Mopidy-API-Explorer Mopidy-Local-Images =================== https://github.com/tkem/mopidy-local-images Not a full-featured web client, but rather a local library and web extension which allows other web clients access to album art embedded in local media files. .. image:: /ext/local_images.jpg :width: 640 :height: 480 To install, run:: pip install Mopidy-Local-Images Mopidy-Material-Webclient ========================= https://github.com/matgallacher/mopidy-material-webclient A Mopidy web client with an Android Material design feel. .. image:: /ext/material_webclient.png :width: 960 :height: 520 To install, run:: pip install Mopidy-Material-Webclient Mopidy-Mobile ============= https://github.com/tkem/mopidy-mobile A Mopidy web client extension and hybrid mobile app, made with Ionic, AngularJS and Apache Cordova by Thomas Kemmer. .. image:: /ext/mobile.png :width: 1024 :height: 606 To install, run:: pip install Mopidy-Mobile Mopidy-Moped ============ https://github.com/martijnboland/moped A Mopidy web client made with AngularJS by Martijn Boland. .. image:: /ext/moped.png :width: 720 :height: 450 To install, run:: pip install Mopidy-Moped Mopidy-Mopify ============= https://github.com/dirkgroenen/mopidy-mopify A web client that uses external web services to provide additional features and a more "complete" Spotify music experience. It's currently targeted at people using Spotify through Mopidy. Made by Dirk Groenen. .. image:: /ext/mopify.jpg :width: 800 :height: 416 To install, run:: pip install Mopidy-Mopify Mopidy-MusicBox-Webclient ========================= https://github.com/pimusicbox/mopidy-musicbox-webclient The first web client for Mopidy, made with jQuery Mobile by Wouter van Wijk. Also the web client used for Wouter's popular `Pi Musicbox `_ image for Raspberry Pi. .. image:: /ext/musicbox_webclient.png :width: 1275 :height: 600 To install, run:: pip install Mopidy-MusicBox-Webclient Mopidy-Party ============ https://github.com/Lesterpig/mopidy-party Minimal web client designed for collaborative music management during parties. .. image:: /ext/mopidy_party.png To install, run:: pip install Mopidy-Party Mopidy-Simple-Webclient ======================= https://github.com/xolox/mopidy-simple-webclient A minimalistic web client targeted for mobile devices. Made with jQuery and Bootstrap by Peter Odding. .. image:: /ext/simple_webclient.png :width: 473 :height: 373 To install, run:: pip install Mopidy-Simple-Webclient Mopidy-Spotmop ============== https://github.com/jaedb/spotmop A client targeted at Spotify users. Made by James Barnsley. .. image:: /ext/spotmop.jpg :width: 720 :height: 455 To install, run:: pip install Mopidy-Spotmop Mopidy-WebSettings ================== https://github.com/pimusicbox/mopidy-websettings A web extension for changing settings. Used by the Pi MusicBox distribution for Raspberry Pi, but also usable for other projects. Mopster ======= https://github.com/cowbell/mopster Simple web client hosted online written in Ember.js and styled using basic Bootstrap by Wojciech Wnętrzak. .. image:: /ext/mopster.png :width: 1275 :height: 628 To use, just visit http://mopster.cowbell-labs.com/. Other web clients ================= There's also some other web clients for Mopidy that use the :ref:`http-api`, but isn't installable using ``pip``: - `Apollo Player `_ - `JukePi `_ In addition, there's several web based MPD clients, which doesn't use the :ref:`ext-http` frontend at all, but connect to Mopidy through our :ref:`ext-mpd` frontend. For a list of those, see :ref:`mpd-web-clients`. Mopidy-2.0.0/docs/ext/mobile.png0000664000175000017500000025443612505224626016707 0ustar jodaljodal00000000000000PNG  IHDR^*Iw>bKGDC pHYs  tIME FY IDATxw|UU>-'H 4BMBB" 8߱`:^:㌢:wT\p(iB ='}~;&@B}vy?{tB!B!E3.B!B!~LV^-{C!@zz:Bqu3UT!^UF!B!)!B!ӭE!44 @EEr4! ˋ F#v"l6!<B\`EXV\.555:11aÆ墰o.(ˉ'.}6mnA𠴴={Q|3M&,KEMM7V\.TWWcۯg1ڤp:r@ܤzhт!CЭ[7Etyf6lpL6m˻SۓḢmWQU, Lm2OOOjkk/oL<<?[ >>޽{ӱcGQUsαw^l©SnlX0a| `РAs=̞=)66#G`*&M0~xy*++q\lݺUR&# p\1W_};-[瞣{,_wyJ ?&?gɒ%JJJJJJ^yӧMP@uu5;?.. N'111ҲeKtܙ#GRZZ` //Ǐ7=4M#++#FbZiӦ 'OUVqFO.ubձcGᩧbӦMlݺѣGꫯ͹sΖԺI׏?O߿2EяH&b!%%4ܹ3i~ L&UUUl߾իW?x߱xf3>>>t 0 f9vU)*rŶ۵-ZC2wܫ^tjңGZСC CUUTUeM`VWWqFz)V+ p:ݩT`O>$%%`2dgΜ!++ ¶mddjBBB1c~~~\던7JJJ ӛ=OoСC={6(F\.b69r$}`[nİ~zΝ;.99Jnn.}]E1OOOnvzh$;;ŋȸqh׮gϞeժUkZe:ubѢE' q:̘1>vXwΛoi <<Çq:L4mka29s&fΝ 8ONNNU?ejƙ3g42b rsJaaaG$s\(rIMMSNfL7evկXp!!!!ʕ+W۶momvELJhq\hFTTfBZXfm52Y;v,FQj}3d//"~;?<{h42eGΝ)--פ y*y*$Oo<9ƌ9N:[ouV|||Prԩׯɬ]Yc4`0裏2|p4M+j ]td21zh飇d /Yt)/z FEnݰ".ҹsgΜ9̙3}L&L&aQm۶Ã>j{Nxx~~~ͪPN2{}{[la,]7x~pwuРAk,/V4<==yꩧ(((h*"##%jr~m9`>}:wqsmr) aÆQPP@VV~`hEQ(// eee|^+!Ν#??" N Nfg_B_E=rѡCk׮;vp:tpM#S>}0x`BCCgϞ8N aaa8p 645El6?EAAx3ԩee_hh(cǎd2^zijժY?w\v TULJW^yJTTEԐzUUU1M:'NHAAǎ~zwv9r2nc IMMeܸq,] &p8Oyy9=v5;.$O%OGmv 00P5œ9sp8(--ӓTyۻIUpbccر#]t]v`Xp8z%sxzzh"v{Ϟ=&(Qi۶^ivWͼ1dee5kΝ;Ķma[v^{ ʕY*0E?x&Lpѫ&|{fw$y=zJyw֭[ 4dff=Þ={XreϷlֻ:P_={6"!!K/( gϞEUUڴiK/DΝgE4˱X,DFFaTUb`Z[\Z ? ?k߾%5/۷'::dȑ#v}]XX/۷gŊ7kkkYf eeetޝ8-[Ɗ+4iGKǎ =+bl6owsĈdddk.͛(O!Y-z^҇6x@@CeӦMpzON4i7nlW+SZ0K>o9x M޽QUr-Zċ/x\.YhQA2u1cx߿D+7QwΞ`ӧKrrr`Yp!yߟW^yQFU0 ]9^u>3.]ٳg?~szIZZUUUۛ+W`˺rb%&&gQёP7.ʼn'G4xs\8NwEyX|y YF222LJYf1f̘ Sm0L|'?={^Tgy3tPN Q^z%v;)..fҤI<ƒMXXEEEdԺTeڴi$&&Dv_Ҭmn۶-xyyQ]]]oa2,00hMߟ|wyks=GBBuvŇ~?^58N>lrkO6 M.X8L3ۛ?OL03544L֭9s&={W֙wMuu5w}7!!!L& FIII'ת"y*y*$Oz ׯg̘1lْ3g`ҥΝ;tǚڴiCee%TUUQSSl6rXlYn{iɼ<DGG/+?ԩSsϟӧΞ=K||dee%ǏA9{=>|AF޸7tH0jԨ&׉'o_|Qa4L8p3g6kbOOO̙Ø1c9yys;wRTT+ A=Qy֥(yvŢEp::taÆJqq1㡇h42~xݻɱq2~7Lz~0tPzٻwo>mڴéF4ն^u4 呑|\.w};w r}1p2iܹ_=z`0رc}K,4'ORZZAvvΡCTԛ+Sg͚ń uWe;_fW|ߜL j`0@bbbk5rQTTH^i }f֬Yڵk7n[;"xEqKn/}%byܹ3W!:}43gw!""SNQ\\|ٯg0l{X,K.q%TUE ن}1vX=ڬ͛9s&3g|i^}U=;Yee%fʔ)1^UJ mٲ>}ꫯwq= xzzOp8Xp!mڴaذaL&JJJ?~}g>g=&$O%OQA_e˖6zj>SWR*6z1~x:u+=NNNws!##^xMFeee]ee% .lb0rWz=z @oqqq(BYYeر#&Myzf7ƍW{r% EEE 4v]vGpPSSS_Jxx8'Ndɔ3w\}dQnݨW_}_~W^yȑ#GxG^^^˜:uٳg3kƵTEQ BQFrmn.Sy~[[[( DDD?]k3\g}OOO48jXXf [l-gZ\\L҇S {eoO%O%O%O%OoR_UU)/////Ghҝ2Ea֬Y̚53<{F ~r6i$ o>.}Q瀖 *SKJJx13 ׽#Os@ V[[`;.s;N>CFAttt"//;vh[4S?sw7xu]*eeexzzҺu뚩um۶N:]>UUUFTTTF['# $Zl撙IDv?0k-,ĭ%$$ :PpѫҰa۩hszȑ#ltꎔټy3L&8w\`RSS 8=dorJ<^u<<_3 IDATp\8JJJlRBT!stF#3SQFUܹslb2d !y*fF#& ݎjd9l6sVgXaɒ%N߾}QUmltil6l6mf$""łXf?nFF#IP[[KII xxxH{+$OofP!U(Frss̤\ݻq\7|ÀٳgC!nc}PtN';wԩS8NEbLBB]09sO>SAnn.YYY 0|D\\.+Wb6III!**Yѭ[O=ӧܹ3~~~~EQ8q_}'O&** )_.!yz;r8t999TWWӢE :t@VneEQe۶mFՎlQBܬy뭷ܹ3}UgͨJ޽dtYB@gĉ޽łiL&\.uMŋٽ{7vr(..&<<ɓ'Ӻu*(BEEvEQ᫯"55r3gL0 4䶮\7뾶;5M#))zC C1ǎ㷿-mڴAUUv%Kb̘1z{sMp8X,Ea˖-1dF~guemu6˅hkh %%N:Kw{k20 z{+m;{؆Ž?Yol ;;f3wֳ՝Av5wqc67cb#n݊hm۶z%Kg#nݪMV+ӦM7nшkZZ7nTTTd߿Z:tiѢ>ٳ^ǏNDDwRǏw1m4俬+Wr1HMM{$9r۷sѧO}BDDlݺ^zѮ];, vbƍѪU+GNسg|gΜUU9r$QQQL&nʦM"::#Ffc撔۱Zٳvڱo>3f |~b!/1So 6e~ӳgf޵*RuO^~7'%%Ml6s)ϟٳgINNFUU.]Jii)W\c=d"??ol6zIQQԩS 6E\.;vÃ޽{[Ã;~z eÆ sqfϞʹihݺ5ɬ[ d2h"V^Mnhժ۶mcL>UU9|09|0fYb֭[db̝;ݻKvv6{eڴioߞǏi&RRRhӦ ټۄGqq1. 2Ddiٲ%[lzH!~)z穢(v:t(VUvONpp>^={R[[_͡Cxصk4w!L&m|@rr2deeo>q"##atС:w⩧/#GЭ[7}?ܹom($%%a2(**"))mH$!~Iu(vzbС<,^'|RĽދbwAZZ?\ <~a6$<<={0h l6[nɿPF ͛GYY.SRRĉQUC1|2334;vp9NuQ[[hȑ#L:.]\N`O?k;a|Mzzz2auV*ܯh5kְgn(**h4C` !!3f??ѣG:u*=z@49tPkkkYr%⋴j W_}IHHCt& v'TU իWǔ)SZ3|ѣiiiݻvq*YYYfҤIzsmO~z`ЯEDDǚ5kp:7}'G.{b7#`00LŔ)S fa2x8p ӕ^zqw`6gҥ"A!~Qzᠬ zrChb>O",, UUޣcǎۗhHKKLJm۶M۶m 4 &`6;L:EQfĉtOOGz{{D^^fӵkzJpp0=xyy(dffF``,L*/p>|(BCCq8һwo֯_wL&]v%44ݎw}7555dffOqq1TVV;ѣ\., ݻwg˖-;wΝ;Gzz:>>>Rfْφ ;a޽ܹS_fl2.ZnMaa>HQoӦMtllf֭|w8z.Ç̙3??~pA&N_7Lqc4';vP\\_/,,#GIRRFڷoQ\SSþ}HLL$&&ݎdblذG}AUӴF;c}u022%KaRSS駟קqOyp?h4ndddӓ}qrk׮M'>>P2339s wݣn`#G0aZlhb0~xRRRE}}}IHHEQfɰ[ԛ)OUUmtիWӳgO}-@BBm۶e֭ӱcG֭[lj'С;wdx{{sAΞ=sѣG}G#lٲ޽{c6i۶-dff2p@Zl^TT &s np `` !""BPŕ4Mj 8r%%%ѦM  ?NFܰl8}`RfW֚555l;D]Z4nMhݺ5ӦMpvZrrrӧAAA8N<==裏CNzr/PWVVz'bAUU ((^///E!V;)VUU8q"|̛7-[2fC+//GUU_oz#܅7g1 ={i݂w N@@@nѢ;v`0 dKԛ!OFNHCd6QUZhӦM<1LQPP@||~.vUUUhѢ6LdeetRʸq(((~hF4 ~(Zl)sSUtBBB~x"##9}4& NJJ \.BCCԩ?{pp0IIIWm;N'ڵ͛7_ҳJnS^rp_Qٳ'N򗿰}vڵk'۶mcżkDGG? _n>ÁhR`0Y$=2fw9?u҅u֑KxxxϜ9/Ç3x`*++ٶm1< 7&L6-[^pܦ*r#q/h#o#'jkkM]M܊z3n[֋}u3%77y1|p6|Zh@iݺ5ǎ#;;}${ۧM/P!)vժUoEU]]͡C֭eB ;JKK^k. TUUEmm-AAAM .4PYp!Vz__kHq=DQVZiq}W{#<‚ 8}4]veԩx{{y?s{n?~< jCUU%,,~j233x BCC<\¢*;v_~dddL׮]9q>R}P\\?חH֬YCNN%%%fJ՞={b`2ػw/*uGt)B?G>}M6Mxx8?O2h IKKc׮]߿Irÿorrra^phCUU:tlfhiTTT#+>>44P~z~ӦM|/!ԛ%O===IMMeҥt:1l߾#GhGlݺI&a6q\rQƎ G׮]ٸq#TUճEUU}{nx ƍ- |۷o_~-G+5#A y(\|WϺu8v3gl0Uc???-Zᠦ+VPTTTh(PF#xxxɓ'3enCUURRRӧO^|E]N'ڵGaΜ9>|@AA/gև_ou$==[zj"##޽;_|oԩS,[ jkkiټy3;ߔ_pgU#G2sLf͚ѣ9}4_}VCôiӘ={60`555dddP[[SRRR8y$_|#F˫Q$#SoOnkf C{k@{׽՘ ߧ.R8|NlΨ!ąd B!z~ng͚5ߟvQUUٽ{7ХKtjȑ#Z|F#vbϞ=Ѷm[JKKپ};"((>}ЦMN'[lۗ;wҧO:utɮlfۗx>s `0p9V^Mhh(555hhd߾}|$''3~x~ƍ5j=6:iMػ(tߪ޲K@UveSpuqtDu:3^uDGqDd `%Ⱦ=V]?&ayy:]].=qRRR֭[illG 2@z=3yd^zfϞMkk+ 3gd޽F,X@]]?#`00x`RSS1 Zvݻ9p 2=zp:TVVKEE : Ad  W$ƍ:u*AA$S2m4 [oh"())ᡇ&/m۶a4$׳cF#---<,Yf;nV yygسgfm.>X,ZٳgEnn.t:dYl?R\\,xxxdraWUOOO(++Պf#%%wy$t:۶mnc˖-444oS]]`dggЀ,zv_l6'|˗S]]M[[o6O>$eee8plEt<3444k.nl٢_-ZLSS .X"tifl6kQJ<==c~$Nw9W+$E  ݐbxqN^ӧ,a/_SO=رce]v1|FAFF#F`ƍdeeQRRBaa!s ^}U;Xx1 ,7zff``zF$I׏O?Gy͆$Il6ƍG(++?dt: n^'2 ㉍qZ{1JJJXp!o< :N:z=A{ɤIoX~=-bʔ),^͛73zhmƷ~ˉjK/1i$8q TUUcQYYI||hjjȑ#`61 Bddd(ggN'uuu!I>>>׾]jRXXHpp0aaagݶ 222ķHArt杻Ru:?={$55͆ |8^͛7f={6Æ СCTUUqM7ֆ2i$V\j={rNǸqc),,Ϗ!C`ZQUٳgSZZJ`` ~-YUUQU:8ql2JJJ,zsعs''N ::___l6UUU2h bcc?vZ*$IDv;555xzzsV*++E@(uuu(BPPyU>M Bwf2֭J?y$ɴuTgpp0t:RSS  ''G Պjns!ϵ^nׂk^Çٽ{7~~~p׮](°aôypA]m݆^GUU };vࡇ"""GjE\jN' 0O?hFUUL&<I5|駤e'x{{3l0VX)Sxv~NGQQ˗/'++ -{ ++Ǐ餴4EjRRR>g3 Bcc#z),,dС"2j[ZZ8tp5װj*>3zF#+V,v;~~~ddd{!2fp`0^W^?Κ5kk=ɲL]]_}3g[vƍ,Z#Gt:(兢([ey/NGPPIII :^x{l7|!IדGtt4ǎcǎە9!I~)r ^z D>}xXt)YYY8Nrrr׿5c<ߟm: ?Cjv(xxxך$%%{n"## 44TK*++$|}}),,d߾}=fݻ7&"=JXXv'OKHH&;v( ={DUUٿ?CQ GHHHqÁTWWsQbbbhkkP"##)++СCp88y$`4)..~p88|0Ťo Byxxлwo~jkRo޼?d&Nm[RRBMM DDDthzRSSt[zRX,9rHϞ=8U 33S[Uj۶mDDDk.-PSSj%%%T:{LUU111fJKK&44T|IA8/Çdڵl޼Ӊ,dff`F#)))|ڼ}RSSQNGBBL&zu?}tN<ɗ_~ɗ_~dh42|N'VN&X,nxO;wt: k_ӊ=555mFff7z=gfTWW( $$$h3$1aDUUõο3_WF#:[9r$zILLp0k,ƌ騵fcذaݻCjUU 橧Bex s]=C;vmCqq1zBBBѣEQHMMGB`` b ᮻkj,vz=AAA_"..Ny`28q"={ NG||eeeW:d"660, EEEc2ejkk8Ⱦ}0LhˤbZq:nQ҂,nۻn;+x>=KUUUwfmΓk>k_Ikk+F'N*F???Q`KDqEEuuu?O*++[ c+18vڵk;ӧt:kdEEHĉ'x9s&Fl,Y¢E:lþ}ȠJLÇeҤIDUUN'߿?SNpkŋz0g޼ykj*tAW^i?өu])))Zz]Fz u('Cem@GQdY&11$},˝۷v8p@N<EQ"66q|ku>҂®>ҾӏCjtb0ի{޳>zHHaaan-O#GyfFV-TQN\ÁhkkCeIMM {Qv;ÇGe9r p*ZZAaez쩍 nK!!!S+X*J(/ȑ#8gС3[o->"""##ٳ'Ǐ 22-[hP]&I$l;w|4i/2LEK$8q8l68<==F# l۶MVhnn ^Odd${ncZٵk]VE4$$ݮ-ih62N?(?l`0PPP@aa6"Ԥ-ʴ ɓ'bAR/вU.,,$>>[ ^ͽ̙3kQEQ#>>5LEQ(???tj۷3|p$I"==GRXXHssV ;0Wb֬Y̚5 b6Ԃk̸şAA.IHH >>-ޕZZZZѣGFyeԨQ呟yׯvJߺu+D]]ׯG[FUD`@ff&{e͚5ȲLll,l6#&ӕ~Ԟ+*$$}b^~EaÆ (BDD}Äɓ'ٽ{7QQQj] \K$կ~lٲz{2dSSSo~t̘1C_uNZEkɬEQػw/G <<06mڄj꥜NQx$a2ؿ2  tnooo $Ivw̨Q~RRIII7 \uU;m}rIⴠCkk6eKAAO&>>>ZQ*A0a=͛"&&+WömxPUٌNcҤITVVK/qa>c6l*K"MSNϏ'|#GsN,YBRRRh{l۶aÆRRR̯+/65h  O<;8z(.Y  G/N B"Ij׏Acm_r-`X 11'x%Km6ƍرcZƖ0⋼[F3~xΝ{4hٌ=Z{U:##n+/7ߤ?N,MApUu[AA~1ǏWkq6A. UΖ Ov}A.5uʕd \W( J,ο  GAAA@AA-9$   /@SS8 "   \گ"  p 'A"           "    B   6*Æ #""r:sAGll8Fd  '2{l̙Ü9sضms!ON||<&++[,D  ps8$I,Ȳ$IWUUE鰿N붟n|r-[߿?V]]۶mɓnfZy衇G= E:-vRT㤺I.|#@ W"*P"=Vq2k?ito  p9q7|3t:l6W_}5?8qqqܿ r! mmmp ˫[~fEQ(**Ȩb\PTEꖟ1''2zŸqց0[aOMvv*Ȳ,,Qjp IDAT˗+7@׮]T9+sf;QA F#[r/l  p%QUjrsswsGCCC zx{{_1_^Omm-gfĉx 4n~^N'Ty90J ]v:~)2SdcuD;N+l=pR_Z>sss3܌lfR#,,v***V[[?>>>no6)((J;N>y$fY+ (2aaaLNG}}xwͭƍYb+W~`׮]?^6Rx t]fXL67'xsiy}D@2hll'Ajkk7|Ν;>}z>>|2~VX~ˡC)111 63g2uNGKKKy9vTUUQ__(L&:t(ӦMcvt͝w5\aۃGDoXlvTB7Mj*>#NȗPqUÃɓ'3aYtiᗧ kmWk/._r%Jo}g'%Jꔮ ep8IA&3^WWի1bAAAtֵ Z6?3z)ᵲ2-[Ɔ $$$mǏK/V+j*~_#]ss3~߿~kXXf[V֭[ի炃1Le8ӧթ9]Us:!W1]ׂI:ncolNzW \$ t@LL FǏvZnmsATZ{TT:bl6---ցݺu+-رcn`ڴ:/^LHHs۲|w%I oooEAӓE_ҡz?rL#FHNN^HpoQFFʸ馛܂:/֣ :dT?Yzr+|_a>yWu]]_ AAJ>`Ȑ!L6M{\VVƺu0x ܹslj.ӟ:!!!~!l楗^"??C=___N-[|_W>K/Ν;/^oC?dҥu]g,̈#Rؾ}v2 Ǝ*^^^dee1uTtk'f:Uwm [8]׮  Wtx }gTVVKDϞ=7o|rݎlւ֭c֭cΣ>ʄ yիMqq1֭tȠA9r$&Mb…@NNE뫯!Chkjjxݖ' cȐ!t:/:KA ] Mf'u2rל'RWWG]]pQsAAR> :L8CyРA7mmmmncǎuQ///gnm6Fqe\u+xA# $$$M#غu+۷ow|So~=l-8|pΗfo+WeD0@8P CWy A]wE#UUUtMiE*((nk)  grziӦ;c;m?Y;Z7o/NXx1o͖-[}L&ӧO?kfqJKK1 f|MuSv?ٳguxXx1111xxxP]]}뮻~N_3f۔( ~)O?4qȑTTTЧOE:)c2Hؔ[ @% az{ Eu]Kرc(a^Jzw1 8DUU1 #  …:W@\\ , ,, ݎhdΜ9nۜOߟw<ô*b̙dddPSSC^^^!CZ̯g}+WAee[B85?o޼ .7k,V\Ɋ+m8p F*LJz{k}eСx7ο+(qF-"h=.bc 3_Gg7 TVVimm7p)ԩS7'ڊb! miA8NuA+\KKK:J؎, u=Áxnll:]}<#s8:={ EQ:L%3lܸQ 477iӦNǬYx9xkƍs[v|ui5j iVQ0c '%*qg5cN+TB$%v]!. vs*vBRRyHddϺ$b??N׍=?BZZ%ݻ9x sba˖- 8 Bw@ii)!!!DFF^J Bf0`0( qqqi*I 44{h/_NAAMMMnŨQXp!; ^?V-M^{;{f0Ӊoiii꫼KlܸYڈ83f@hh(#G%!!n:˱lz:u*_tfg_bڅe$ jU>j,e9Զ,bD߅U]5jaaa]q>>,Xd9lˋD&NH߾};/,,%Khk08ŋصk455*Btt4=z8SUU7ov{n\uUuu:7x#'//f<<<۷9 ¨^:2ul=U_ as WvmF?*lWHڶ/9?PPPpQkvFQvMssE_$\˴һ8y$IIIQ]]MXXAAA8N>̎;عs'v"11`"l6 ZꗪYr-UUΠA8x y睬\ݎNF=z֑^bb6۷/sرc޽CRR^ܭV+_5ϧ-pa;v;'N`ԩL:]vg닢(xyyqM7,FMM s=aZ>|8e˖1fƌ/ɓ'̞={Xj:Պ?w}E~ BW D$2A]@Z!ît;gF;:9<#8N<<C233D$ؾ};6mbԨQILLYڵk7n,HHH?ӧO׶ fڴi[{o͛YAQN'qqq78zMaav!wEF##G_/// ѣ{F>,L2%Kp]w+mY2n ;fӦvwΝ;Y|93f`ȑFv;}Vs㣏>b,rA^abs5˶m0͝O>?$ixvVkUÕNYicT/7^m & p9h]X0܀9r(v]mO=wu]_Ӊ'?~^x_~85ᄁ'OyN^Sk[UUN85koHIIޣ;SQQ}innF$ؾ};YYYHL&HHHp˰HII!66NM hƭ㈉!"";wҳgO %%%۷{I$ Jcc#bЭlTUEQ;#IHHh+ Z'|^t}j*ְA~~׿ vɊO<@ff&YdȐ!^}ux[;pe]b^@f5{l-PH*YGX^^ɲ N(l/prIYPfp6p9:rwX,X@jj6J$jϔj*k]hA5kkFLL pXdY>G ]AO~رcIl6Fō7ވ7"I tp1l6$$$tt1tP7( III8p@+8zU$ ::ol6l2"""An())^x%DEEquב{0:-dH#f;J@,]| N4lRqL?ծߙ T60?OG|62F Bjja0:BeY:'Ov{.Nc1vXEAejjjx뭷غuwѣ+Wt{krw\Z&~qFEq[=!99\|||"mmm[u hiiۛ=zWo3 |wj?Nl&Mđ#G:t?۷/k֬AAA%Ϸ` t'~c{F+TiR;857fSS$ H12&z=xUU,0` ==KFݼy3ׯg=}5\CUU~) bܹxzzbZˣGoڴ رc1L󱱱׏͛7ExڴiXg}n'OtR0(7 IDAT |M=?}trrrXx1 ,d2gSO=%8trqgϞ?YeJMM6 GuV+'O?A{?-g<-BmmEAA%ep~Fes ^npp6zU\_$z vOOٳ'W]u 7|3SN;$""wyzc=FzΕ_W>VXAXX'O cƌCeΝv.֯_ȑ#;=? E^^gbż<0em}XNv\??tPvڥ3O2d-W[t illÃnw:޽n 85ͽː!C$ °af̞=Nѣ Mk5k֐N߾}VAA%\s9Ǐ|x,t: 㷿-#F:8! *++1L$''wYф Tu駟p?m>vEضm+Wd477s뭷ꫯ]w~;ƌg}Ν;뮻HJJbݺu_/ QQQ."/z)SAۭt*/邂X,F.ĹUUf1yd~m~GCZZTVVjuBN8iiia娪JEEMMM曌3kF\3AvJ% ]K]9ZϵL BW )),N^ĉY|9a`A(Nkx6bibYȩAJQIӉH@!BS B š%d,ЍpۍW>} UX:޾4`Y`NI*ۍti:]׉T%h4J4v#$ B!̋|C?n?:܇O#v`Ȏm(!a:khDkiA(N;؈(BZZ~8Btt]H$D@}}=x^I!BPahl 5#KQt8Xgw=^ߑ@O+ 38q, EVV}g"͘)L&ISSez{x$B!ęI?vq=15 Y =گ1hW^TEQUUUo,G dea4;xtşFtN:ӀFr? qt;xu=[b[󭞻Co)8MEcccJ; АXZQ; ܌LyM{ird!B3碿;ydiHe]\HΛVȠegCzz:ˮ s;&f#+*r%DG`P]nb+<9g~̙NgCF0Du\.WJ$M4555RtS:NW !BqЍnJV7kkY׋1u f0)pT l&eg@S00[-- ۍOKP\., TNleaE#XFP.ipbcXۃpbqp0Y8@QL#hj@I&=~Y(N`a7wnÐ) B(BVVVʗr.WQ0h4-B!8#q̝31,M=h0)S;gRIJݾ>"aFsS[?i iL=p0jŻSږ.!f5zsi';qQ̑Kb ٸ|pD55Dvlշ-HBD+%+ zCFS1v"`ݱga!ZvNb98zm=_J#*)!~m\ee8s?|#Vga!>i8 ɼj?|D1BO$4Mn7ԬHӉi) . !B!(՞c[pO c2F3j4매7 $s ]E%M9--qݷʟI0#1{ѲsApdкד:x!M@>'vvJ oHwK8 q"7hCNJi˓z#؂wXzklTψQ֬Gt.eƍE;L룒*5Xu9GʊvʑB!gf0`F@L30 CKK2;Q  nzGbMTSUF͠$HkU!8 ttXX` CMKK\t75|}z}}.#Oi#B+?=pv ?R.*g߅?ǃڻg"<Æ>s^ICж]{Yln% B:;>[ǟG"x#W#HLq,Dc=zaI K7M+`}8  B!g "Z?vW4 H<~Ol ߸(ol9<^m}@kObt ҦN2MZ›6uݎSA>G P`'X)HBB@Q :ib=S" U !B!A1l$F tZ+ x7+#{'ux7^ޫ^GÊwvb18*'ЗZ7`س8;9NEIi1 .KB!BzП,͛#Xp>@QXMXJo= qH KSㄷnIBMϰ t$9ȸJ{^hɿIJ8IS,KB!B\Ç(&y#;I[Ț}31%7~"E~Ѻ0A|Υ?cQ\D0p:hĊMKE3g8ɸJcF{ .ٗw891w_хF4t]Oi+b( ^7e˓B!졀 cxpd@_Ҳ4 -#G~E}k%c~)UTL~=g2.rv{UB6۵3YTd$/f,BtvXFȠs?P<^UR| O_MH~įTOYTx^8`YG4%{O"iRSSmC4TB!_? bw%]mP˝hg4"edfYϜMG. K7P=^"R_e6u*Z gQEEeG_;|LhͪDZQ\.998:+ÙӈWU٧/F0^Sݩ2bPJǻ ,j+b/[(& D"MO¡P]S]H.+---ۛ@ee%s%\ `ܹ\s5n9B!F v0ޑ:kשy֒ݛqb:Fu4/xКOP >H.#+p8]똡v";Ӽ%}EK\+KK/%v`?+[M_DaefKAXe%?Aq8Wlk=2$@+!3LT ;7nF~$Bк8s0Z_A5!N%NOOfG:XE("LWf̘a,YH]IKK+dԨQ_cN'9Z(_|S9 !8rN=&n2,TGnׇ؀t؟ia4\%pva[WTa˴m]B22,Cī* +m3,pU4ډG; >q?9EQ:u9R_X}Idv"[wlG{#9E,$ ƻ"Lv`t^F[`G q,=Mi߱ohh  n7=2&%B>L(:H$eYBKUUbyٻwvmذ PUUœO>I]][lߗo8577g01)ˑsUAq((N%wwv}ޯ8;ϙX^)mvy}6ܓy^=qdeA(NAcc#_;@$`0#0=@nRdf͢,% MM&gqVs:hFMM RH!@x:_1!sK$շB$t:q8h4M ljD")/g&KZZ>8ٶm[2eQQQC Nj_;w:"dmm-UUUӫWc?x ݛ.p8=0B;wdTVV@qq1YYҊ ׯ>o߾4)++;fh(b߾}inކlϞ=aJJJHOO38eddзo_***AQe!&[pMEY{ SΗ!N$@ZZ.p8lO-4MLDuLӴG"Mƍ>%v;m-b)(0KN:p"***tUW]ɓD/W_}u֑NCCcǎeΜ98Ny'hnnĔ)Skp555`ĉֆL>foN8pP]]Oeff2zh t^b >UU~W_ʹiN:ݻw//6mBUUF͍7HII /c͚5r~oa_W^yrמ={x)((ફ8۷OvB. ʝꅯ8;S`&uuu:tX,$ՋrF}m݆dq.rNSO=6mk֬a…׿_}UzM<gTVVx<9رcկ~OSXr%˖-_~ <{Mp\hiwgiK.#7?ȑ#xg>};7ol޼j)//225kĘ1cuKٵk~)QF ?? ;ƌã>ʟg.|͘ȑ#߿?ipBvI0^` 08x 6l,FMII Q]]͛oɥ^ڵkIKKc?0:~5jׯg۶mL< `ׯe1o<|MLB{{;/"wꪫٞ-[P[[<ٳgs}co2d>,7|33gδ'm[|9cРA/~f͚E]]*+W`ڴib1͛ǚ5k\tEk,Z2b>,w}7&L s1qD򨯯=f̘1|ᇼq 0Yv-c׮]n ~\{lݺ#G|ry' ǻo}h׭[?rA%ف$;;}t:+V0uT~A9s&s̡r7p,X9sK/}]Fmظ .r~IK2w\Og^|EF(>0Me˖QUUSO=墽~UV1`{#<Dz,:-[ 7 _.!B PԞH,!$hY{#s!Lyy9v`[ PASSyfƍgrPWWmK2z.mGAAAAiIit:[7tb>k./_NQQ^x!W\qZ0̯?|0a/^i455Q]]M0DQ NĉOcc#555;(B,c۶mD.S?b7nd„ v7l'Mt]gҤIPTTDKK|Bq'222HKK1e^LB |'4773~x͛7oF$' cΥW䳭*P(t333ioo2s0IKK۸\.rrr1cÆ R/Xhmm%zj;iC%##îYn&pf֮] ͖t: ˷,X,FZZZuNY]D% !at]7xH$b[:{T-˲v9Yuou.t:W\q7z+L\.nߟ Rl( vm}k|˲xgׯ?Ou֬YvNMM1=²sa]㴴tehƆ ⋻LBqqݶ3NqOyf ¾}(,,v}^x!K,aٲewyD"}]b=\f͚? mܸ 2c ̟?bZCII ;w?墡X,iy~g͚5sy!Cxwx饗3g^5kŒ3r1n8ϟΝ;)++c̛7~ڈo: ~YL$!:7e ˤIu{q#vލL8vڅ1rjժU5f)**_i)k׭"$catijKq ! e4iӦb .\5\믿΃>Hff&YYYʾ} ct]0 nFvxXŢEx뭷@'M_$F;w|뮻0i$/_΃>w]ϢExzaƏ0 ںlY]'6.=^.Q; z)233 0n8 N6vYo("~鉾Nիe444p뭷3Dhƈ#X~Ioi :4b޵Ƙ1clذC G1^xfrr9r$uuuTUU:}eҥ~Ɵ{./np q61Mp8L,Cu=7JN4 Mp\x<&4BfEaΜ9dۈbE]Dqq1n Fyy=*##[n.اOCUUnЮ Buu5RRRB?`ǎ|hsu:̘1p! ??C-GwMKKU`ҤIݛ àp8())׿u>7O`׏`& <ÁH5;w.}---ddd0x`{OSrrr[tyfTUKD\  IN:HPUݻw( p&|1ggAG>QF'FW(vÇ P[WGN^{&IUU DQLrzܕOuX,F<OI4Hp8eƌ%K(BSyS"5ԯ|xb-WU7M- ի.s&|MbW\qsB!;x>M]I4hT"..7R}*#B!Yk޽,Z3grW#zvK/g8~ZZ|\yեhN ^xSWFsPXP۲e vvIɐ!ChjjC'N{{;R^^N^شiDuX`4Dl b8q:+bw:n0`0eەFijj"؈AI!BVaa!w}7k.w0i\}}c6[hfNJ+xٱc㋳Jsswys2E񐕕ESS1EOa455B!g[?_#p:}v""վKTL0 8ty8x~>saѨB SʓTU$.E8ӵb~G'[hX,$@0LU !BqJ׺;5kVgyaŪ>f.CVZa贴԰k* i!`j6i5['pť_6R3}oXG}=… VMx y>CWVvy~,^7xٳg3j(nfv2i$=\&L_Wvl_ϨQ3gkVf맟\!d3_4T\.nwm*k #mEB!8SePU+VOO&6mCэ(n9imTE%=#/ʪ=\6u׍g'ݴ(+J%о0 yYK ,+eҥ~^oj&{f:tZ֭_o?dڴi~̞=ں:͛G,C4w.m۶+txvj(ę*bf2StiZ ZE$6P[[+GB!8dffrspHeΕDc r> lrsi.n{1\BQ!Z6NH[2,0K;]0fM]Um8SPP@8i 4˲v<N'UU9^0zv*^0x<}^B 8ixR:4x A4M4h4@qq!B!p8kC{)Bf  YEFZ QѸ 8*NMe\L҈D oA7L/Ͽwkm2d999nܹX4-[Utrp:vxPUOzQe˅8e}%Ǽihc#eD9,B! '0|FŸqcq8dDevFlٺz >: q(HDt&7/neTIW) 0 ).ꍪ9hii1_UU{@Q.w8UU>2-@BN dpUB!ZGqq1X^zsj3m1L]Cյ);>Eé`ӈQ4͇7;GiJ܂?%|oF)~kC'KUU8p]~|  !N'޲d}=T~% B! .=\N'^,df0lkXeL`7a&M-9&wye_vuٺZq:U/9~'cda݉_766{.% ˲춆GN1LU@BN4MCQ /=9$ B!ėлwo.2{|Ss#ϥVGsf3v'ivcD#qr2\*nc_uo% B!IP\vlCfF/z dӧK4iэYE IDATQt3 绩ong{Hw s8[KT2{wBۣ*k׮%7bb1224M{?8h]SRLәFUU(ah7x8 E 9 {XC8F `Р~Fٹk֭;&HèJ,28 %44M Ð8x<t]֚ #H,s$߿_vBlwD`K S/-kdsw0e߹,E9R,O9TnuqdYe3ypr`Y ,T8|ݯ()W  % 3qDjkjO|jϟO( >ŋɪU~]Y|9w^ǟ'oNkk⬑F(##XGz$NBӁnf]TﭦҲ~ǡl k3Msn80{vdϮ(3`Z&zqjoq&ٹL6 REsUUr%1KEr ST\={2|0<^/SN0m4 L; (@~~>7ofժUrl ill$///z$GߟB!gX,Ncm#n;p`!Lvo?dq]|[oeߞ .}55AKS 5-# p(!;7?d݊ } ܟptޏg{?>f%+#̾z:ƚb V\٥_a457O8[FF444qZZZ0M3 Azzz+B!88awQUwL&@Eh XwKui}jZ↸TT>ZQ("쁰e#df?~Z,af3g&{{?.$[W⨫ߛ/ hjj&)98/;BXxFaГHTtkp0LRJ?;o ?C<ظ澵3_p(Lfvg/[AM ͔{a7ν"={O4Mn²J}Ϟ=oKBBB&)))\z%TWWkW&g:SWWwX2l6KBDDDDDhni0 N8'Bm݁G(˥+injr߳e/HH!̦%p:ޘh"f42Ƶq\\LLN;Q(<$g1~ I cCD{= k62g'5?[IpS$n])쟉݂;MR`(D T_೅ˈ'")I& 2c86k_@}C~e[ D @]]~*^/vl6~z41MӪԄ;c;;h.6@ii)W^y%> IBB\r ^xa/>Ds lIgM'aF"! br >Hxo>? `- 0BXl"  vN`#p(ݞ mv #9,uQ $%%LssU!rvq8ր0 1 MQQQvjɿh+/}~7x&zf(GADDDD_GE?M7&#zx<ZZZa WۂrmvdՌannt:x<p89s&,i[1p3nܸ#Lee%ݺuQ"y0Z"""2)W[@K0$ ƿ6 Á|8Ǐ0 VZ̙3q݄a۷/wc=i8 ӧVH$0`^?YfISSnZ3GIIڵFDѭ""҉̅m+@_ܷ{R__O׮]=ܣO!njmP@DҶQDD@|||wte~SRRB$Z0h Z[[?>7|s ))[g1qDVZEll,EEEtSO=Ex`Ȑ!V8F/ p&Z[[III0 JCrr2vC$!-- ݎfP(DZZiiiq,rg˖-Jl *%"QY4q:n0 N[n3B^/۶m#ЫWv`vŋtfun21vXkx'ϩ#yO0i$zȑ#6lXJif?^z3gr)0bN:$}Ef Qޥ BQQǏ&φaXPݱ`ݎ0 BA@vLӴ=G[ەӇ|.]wm=>++{wZVZ[og}M7DRRwذaviڵe˖C1l0~_-o l޼@ @|||ȱécbb:Svdfaڕ">>>̺u֑FBBTVV{n1V@ !!.]rJ={/b mF(bȐ!cimm4M`G}UV¨Q/I]][lihf~\^ȤI/%'6/..XUPDDDDY^ةjˆJMFll,,Y믿ػżK?F^{5km۹袋xHII!;; PRRСC1 p8̜9shjj_t: B<\{DEE駟v --7j*N>dYb=g}6|iȿ! uVUNSDDDD >MNNm !G57pYg?;3nf @ff&7x#n壏>.#++ ";뮻9sp ONN]vnJ~w#33={t:(**⩧4M:+YgENNSND}QZ[[2e >, ছnҧY ؈률@9F2>Cj`ڵx㍝l6 7iwHD:]YYDGG.gGyχ*""S~m H{p8r8Ү]IIIɿȑSH)jwGD mm(0N+"rD∋SItt4 zwDDDDS&NݎaD"`RiQZZJvvv,;PC~%Kطus~/D~Nk;wZ_,l6;H$'|¦Mdffr)2vR~N+Ytg{ɒ%lذRSS9餓(((A}D"ڵ Fvv6iZIp^/iiivV\III cǎն8~1ch$EDDD y'pzj>cۮW#~razAss3_|8l6u?^b޼yEqq1}^z)Clp8I(4M f1{ln77tP&^}UN^Dȑh̚5SN9֭[y衇>}:wqDEEQQQMpiMPիWBaa!քtz)++c۶mdffңG|>5!6MS[[KϞ=III9亢S_'MUUO=SN%//]t:anNw … ftRzҥKihh/6l_|É'{aqEcc#>,K.%55P(r׿4;w./466_Oaa" t@ ڵk4iR%@C2`RSSYt)O<W]ur p;g}{9Z[[7^ ;;c)u8檫"::3gO2a :%%%vm$$$o]RXXh4M1b>|8fݺuL6C2l0l6s>(SLꫯvscۉgǎߟ;Djkk)++#`eee?$/_K/93yw+8餓宻")) f3i$ƏϮ]:u*=?a HBUz0}trrrDxv-fQ]]ŋӟĉ'H8梋."11I0$pyѯ_? `ܸqTWWYx1GgϞD"Fɇ~HII r{ywxN~+V`ݺu<,444 0 ә}歷b̘1B!FŭJJJ0 䃇`-$ 1?(**bǎ,\;'r 7"MC """"Ǫ#G2o<1 0;|8V\IJJ #G$##aW_}pm6x֬Ys+m^/Y8kAUv'6>Ϛdn444PXXHii)x^[h]DHLL$##7RSScbvZNv;}ke„ X={ Ĉ"""""0 *-[իyGXp!&L ##VGe۶m$&&~955I&1k,ynӹٸqcB8 /$%%+En:|MyK=;v,o_ׯ;nfaHNNfڵ^Fbbb۾K+XCm=z4k׮gժUL6yYt>^6nŋ׿ENN~Dǧ-""""rJLLSN{ѷo_رciii_w3n'h bccyimmeĈ\~gMv;;^|E^xzM7d%Wll,3g_dԩ޽4&L矏!::zӧ3eN=T|A"ʕL2ロ}2i$xM=Ctt4qqqV >>cMv;DEEa&gy&as3j(Ncc#̘1Gy뮻gsv~UD~\ƨQL>@!"gySED:;p[miZڸ\._[{ae7 Z@ @8pv|nfoK p8p:sU)8IarB`кop:c"~m |>pi%|8!::P(d)lnBPZ0iiin 'O;1m[0@@{EC=\gk@G"vnkcӖ鿥eC[nc&~{_w=}0 Úȷv:Z[[y+ү_?233YbO:1"rd555 9 ȑ#x<̝;ŋ?L=_h tE!"""""D2d#FnwV6> !"G) IDATQ@DDDDDDDDDDDDDDDQ@DDDDDDD:0 """"r+// ( """"ǺݻkDD:r( """"""" QSrB!6nH(j4M\.jEDDDDDD@ii)]w~@\\^x!_|1111q5uV 8U`0[oE}}=[5**}wGDDDDDDH c8 0gwc&\{{|>oNAAvuVHLL ʡLUcW(XNDDDDDvESSA?iR^^>~VXu3d^YYɣ>7ۿ+VZEcc#zY? >#F_| IHHojjj(--%!!+'""""r\.R ቩiv;Ncc#ի)(( 66M6l2 $|>6l@( 3k,>N8())/K/eӿLd9CjE1i$Fn)S/x<Z[[Yv-.^zYˁ*ƒc|rIIIaرR\\L]]]=l\. @|7[\qrhjj駟7 ++Z1|7vN81\~=111dgg[}deeec***(,,`c3GII nō7tjB>O׮]Iq||}_;111`٬<""""" 3vD&LԩSyIOO[oe…ݺuk7IzSYYo4ط a%ꫬ$..}';vorBBqqq6jjjmmh N'6 4 FDDDD8Np}Hdžaؾ};at:n`̘1̞=ߏ" wl8vwJ("H žl6عs~n۶7|%~ PyC`D'@͛7 [nQ hYa&1?&.@ @8_~^&ت*޽;vhhh ,iGuu5{no:X~=[liO>O?z}=餓ؼysv+++ٳgOr`Daf"u/Gll,cT09Pg⥗^S yߋJHߩCD}ޗԩ2Mݎ>AQQ+WGᬳ"!!b,X瞋dر̝;)Sp饗̬Yѣ~:={ߧK.dff{Q__*W I$!p駳pB^|EƍG8W_m74hK,+_|1)))|,\/ZD D+|̙3,8 zͻK׮]ڵ+| Vǎ_~3lܹs9[!͉֭[imm%..9ɶmۘ9s&֭]2vXsTWW3sL _9CU^^Ϋʚ5khnn&33aÆ1vX^/| O?4_=}9.n777xLӤχnhll4M<^W^ELrrr;;w.gϦtjn?̘1Sb>|8wι瞋ix<&MDeeҵkWaXIL?_^x,*Mf%xaxx~J$!**:kvjۭ.G(U Oz뭤vZ^~evM7DFFcƌ!-- v fxqmo;Xl>,rKRׯ{a2rHn6N'L>7|P(D~ի~1e5kr7vZ o[N=TC]yy9eeex^Ky(--'lw7|~;233ٳg+WB `]Yk e$%%qFnvonMFӭ Xj---0`RRRm۸+xmO[d sk?f̘13}tɿtZUXr%%%%8NN}:MMMb֬Ylذɼyx'(̙+.?TxOp4o]RVVƇ~Huu-554ذa&^{5z-N'>}Xm.6]$%%ZDȠжEn]% tD||< TTTtZ^E$""""GIߥlVX]ڵ˪T[[Kuu59/Izz:;w$ӷo_ v;---B!֬YC8fԩVvU5|pzɲeظq#W&))q}gҿk2uTJze彩f޼y̟?Aqq1~֣; aۭU mu04MBPn[E8n֫?/Nˠ*""""'˅뵖﫶Jrssiii_ffkW9`F$N]&v[PGbb"111abbb/~Aaa~}kmm4M80M͛7ꫯrpmmm-˰a8쳁ZZZ"11ZB?h ;j_n6w:h7ُD"l6m:@DDDDD~d&Lŋzjn.]Jqq1z_W^aѢEp8(//W_QXX[aN' 0#G~o/^_|w@ֳgOFɴiصk'$..+]$!!.1QSSC C޽TN'vUg9^.b6n?OUU ,Z9sХKk{}}=;w%K0|0wxyW7xsZe4M4hUy _㏳m۶eɒ%<ӔBqq1#%%.]X}ksuM(ۗ@ O?Mmm-⋼{|MOO'77ӧPSSo#ѣ_}U#++뀕D zm;ßgB =: [`̘10ˣ̝;P(DLL 'O_4M?~<٬[Fk+]n@$!**I&aO<iCQQgϞs=̝;477rK/%!!@ @SSi y7ٳg{p饗2tP~_;pp8O>jIcc#~=ߙ={6SN9.#Ge.2 ++5&5cԨQ&fHG[cUߩ""ߧiRSScMﻧZB^(jjjɱ744PWWG$!66v;~J233`2e "))]Ϊ"p8#..0hiiN'v"[`٬}pZla[mcAp8xWZZJCC*ئ 0vZ2U+DDDDf$''1^tm&viw$77{㻴?hnr.}tg"+Q@DDDDDDDDDDDDDDDQ@DDDDDDD:UE!""""""r4"""""""z ##C!""""""rRDDDDDDDDQ@DDDDDDDDDDDDDD84""""rڶmADD9jDDcaN""""""" """""""rTP9&B!D"#av5"G[  j*B~n󉏏hȏf˖-޽[@p$zák"GU`׮]|v_iĉ h6o̰az9BA O?@-PRRaw_8'|g4M< ++K24"h00.K rʰOnx;w~Is5Sj>3mƹKNNA뉋 q/"҉REonn}|,]G.]5jU\\!;;ػlɒ% >lLdƌܹnݺ'r8rssPDDDDJ9NmP̜9s'ҥKJJJ),,씾ٳ墋.^Yljj"PYYε^KQQAWJȏ'66MMM ݺu麗(}f͚ҥK;miƤIb,Y_̨QD#SLᥗ^3MMMlٲvZ8HLLܯS]]M=HOO%KacӣGbccٽ{7}͔xHKKÇ[b z|h())0޽FDDDD.-bݺuک`0H||!`tNp_ݻwvyǙ;w.@կ~EQQ}+f[o%--b򗿐GQQo2e 999V466ׯg}6+Wϧw޸\.222X`{|rHKK'RGxg`yyy9&ux~\\\wt.f͚\κu묭 m9N?t^/iii3P(ĺuزe ;v`ذaE߾}9SY`6 ׋nr)QQQ㱲3O?ػZaڵq4DGGSPPn۷kPDDDDDvXƍǴixn>h?.&11q\ڵ+D"zQFY|9۷oC(bۭmcMDGGw~<!)//ӽ{w ++7o}Ыp@ p`&PcZZZ:cbb8ә;w.999?pCDgA@DDDDYG LO?`׮]g~W0MH$Ν;!;; LӤ:&b zm]-l ~:e˖|rFOp'??_"""""ǬHJJnvr`Ȑ!Y'|#F֭[Yh'N$**zO>əgIuu5o?Oի#Fw&iii .0 335kְj*k ]ߟ$Z[[4h>"?@ ֭[|?4`fwՑ@vv6Y` .T~PTTԮ#F`444PTTo7n8RSS;w.-{o#55{ .'`Gnݬm={fѻwo-~JJJhnn&...$""Clz:ڵ]RNpTX;<;|{w yyy vvNJȏ+`٥LEDז 0 kh8rz fn[2Mp8i}hjj|7D#<,+:}¾n$^oaJk IDATt]BYʥ$NDDDڟ ;lݺ]vJll,999dggDhhh`Μ90lذCuAZlB]]L =w]l6mo$ H$iW@vv6_|d[ZZ0`Ǐ'P?rN?#,X`H5MƄ 0`~5kx]lD@9va8Bhmn߾ٳgSQQaaÆ1jԨvv[al߾ 06"~[ڎoV 'x!&&ӓ ~[ݹ~l\{䉈av;ׯgڴi}ٌ58v͛oɳ>K^^nvc׮]x<rssud2^/]tnDhiiaǎՋ;weMm6̚52~_ҭ[7 ,ॗ^"77Bl6NRjkkIJJ"33P(dMN'555Mnn5\.v؁'++ZٵkQQQ^-BeeeҷEWtB||N~m233Kp:I~~>>/;wK.s|̟?cテ~6\\ ,"MJԋۏKb{do6nܨ\.Nx~7~COM6W&7 ?g>n `-\.D"E;#ϧVcH$|>`0nMUUU2讻C}_Tssi۶mQЇАRnum+L*( .Xon:DݻwkΝ萔W^Q__ng}V9V,oNb<Ϣ ɓ-L&DTVV1L&511O}SnqUTT,z1ݰo_K jÆ zꩧo}KJfڳg2U__2wM޽{N_TT`0t:-c533#۶533GϏ㚘P.K,sktttH1FHD/l٢_ǵw^}SRQQ۩x< __?yYo|n"Ģ ׫rb1̨-R~=ݻta}{S6u_ӥqAB<ΰPH$ڻw>Ϫ} `y@//by>}Z?QKmܸQRSSܬRr9uuu~֦w{555Hǎs|w]aY_|Qvҍ7ިV544}׫Zjnn-Pw;#ǣFMMM)EMMMjhhPee% ʐNO~R׾#GhppPO=yٳG۶m{G?Qoou9//]`P c=}ԋ/(˲ym]sq}{=??Smmv힦K/W^ѱco~S]]]n[nԔ}Q 襗^߼l֭ުL&o}[:ukn `` 7Fɿ?yaꓟ>w|avmjjjҟɟ?H>O]w~[lVw=czGj޽pN/OF=_U%%%OZd2_u=#׿ |ͺ馛422"Iڱc~7S?я;;?;ÝYPX_P\Nmmm}=?cy^]zᇹy睎$=S\ x.TX^c{hr^ם8lvcz<ς.]Sx܎meYY%׫L&s}.~NaS\.|>߂/xQ~Kg9~^{og޽{ (GepqE7{rW<]mw{ٶw==/۽V{@PqOj EEE\ V{*P@|@\F`P333*++8\ey^c ٵk^uR).2,l۶M~Pޛq\=(=lrV; XD ( (@P@P( ( .@P@P( ( P@P@ ( ( P@P@ ( (@P@P@ ( (@P@P( ( (@P@P( ( @P@P( ( @P˒۶D@<e[8s W1l 7nj!m`9O2Ƽ鿎0% EL),r ?KR.S:V*R*R&Q2Tiiy SXc<1nM$J&JRFFܜѨᰚ/}IAkۜ>;nY1m{kx$ b*sTxN #ɑ5z/S }b.ד-W]m:sfggZHn6T}}}mx-ip:Hھ(Ȳ,71[K}rrRXL  \׫%IFwqUWWvt&xVH%OA<%G]οH1Ѩ$pFXQgQwWxta=si-|+>pX<4)[#,Uݙq Zk)$U?n&]wurGeihhHO<6oެ{<+Td5H'8?`0fVctbQ[цڵӾS .][rѷ Ǎ>u=FEG0-kH?V4MFy%KWgU-⋚#́HwOzLۧm՝:s5k,ǵm6544x4>>ӧOwGffft Jb>r5s}xa۶ۧIҾ}C)fb;s1x )9 KꝐ^>'3G}F[>ixgÎ٘ߺb_**ޫ}P%Ţ$Ggl;{-7 Y4uϥ>|#JJL}s m:|umg%S#R4)t46-[S)uiR߄i.fYL<%=~÷^w9]B uǝbVZZZ֪AMOOŲ,=#K++Jӫ(J?yяي.\0'l?:dC׭"@<W__|>&''511U\1ˉ =r\.l6+lYL&^zI@@_,rf S k^zidOΝ5??k3h~~^Wii6mڴ2utlQ' Mp޲:C*1xJ<%.Y;laG1I&?ڟ[6}lQ{#DA[\(0/u~QkFwm7Zzߗ]8җjAYZ^r9\;˲VM`yV#˼Uo٪ YUͮl6aieY)xU%_襗^ұc$o_LI:m߾]9JihhHWuuZ[[WFVTbjAa~Zoc411~ZPHuuu~dF:1o;Rږ΍;7 fS)ui؎Ã~zY)H~n#TXgtV~mM93HZe5?`U$iӦMkn%mV|`ߞלvljJWBI$:{;`YFGG533+6.+q8mo7|nEG<jkk/|A\Fn="1z9˲D3h``uV,ࠞy=*))YS7.&_fHzw]גN_p42먮(HHu.&y+!OSrԥcoo2xh' Hu?k+)ɷOŢļɈSrq(0=MQkI/vTWj~Rex<'?FGGy(lx455疖:::VT,Kg裏 J.sz޷WUU|I|>ٶr}ڶm۪_JL]15~^PWW׻,KNRYYN|y/ڳr9555K_v)VZ/Xq=֯_|++.wר6ZzteSR~NܹsCL]+156CtСk$~b#+A&'mk6ۇ=s?7} ,Łk[e>|SA<%."w8o;҆:Ao>x,N#ycvG1z`׶x sW8j F铎w4d-gKbd{5tu|/ 1S/1{릛n6^+U0TSSΟ?/nNAB!%IB!s31uUk8y8\.s󅸱~k]"kKE>O>eY/ٚ)/0 KLxJDEdt|ȑ#UK`#*MF.R@ihhXSQQbH/:gm'=4XIw, ZiyMNu7IQrFdjjjt]wS\nO[t}ڶw g2Vv%ݘ8z{{o>b1ȷeYzꩧ iӦ9r8Sb~5[9:;(6'Uүa鎭F )u1M],(?pYط,k7 Q7m_=+r_>/T9f⃴N*/mm*C+ot:}OS:uJTj[Ź]{jjjVnMMMillLe)*+Hm133X,@ D"ڵK`pUIKL]1(󪨨P =Bk۶U\\~577+ ؄-v3~7O-^pv}nKk* )u1΍9xɌIф}xh&?pY" 耣5(`fQZ _fJ}FGcBK.+kGcץi7Y4әw>irrRCCѫ IDATCW eee{U[[jcgU6uYpqiQ&Çh1?{bP(_eя~,>pV:-ܢo5^5T!K}Gy)؎T]b{,m]=GSA<%.!9Oo"Z\c^pHؗX󎾵O5{.cx3W j8n/YRZ ETF 胒I4*R%;y@t% MMM]7hnnNHDUUUu۶^xAΝs˲499W^yE]0p.s.ѣGuIw7ZI:~֯_m۶dcjw4}C oJN;v.Mn S>W ۑseb4qjuoGL%OKO>#Љ* 3N:+gc" ɓ>]یRk`Yɤ555N|J$+x{A]+v"h"--^lzNzHnvk%T$qºM˲xܩjCCCjjjZgm0-˒eY:s$nPkkB;B566SNg?0UVV 6~Mvk(^A:}ussszWߔVMGHl;_pZO|}H$yW~BL%OKHj̴,<sǯ3jTQ"HٌtvLz{x۝RCmF]PY:W_}Usss$WγeGtM2/oL7&? uww3˩TȈ|>^z{{k׮{믗zzzeYzzz499fLNNjppޮ;wDX#UTb1FTJtzhZkL>+L椪tf\ȟJ=˨ȧ_ OSr%zF,Gώ;zጣh2?u6Օ;Itrؑs-rig;BRCQSeYy"eillLT4]P1rMpV۶}nIW Xg:Z2Ξ=+cruwjƍs=>Yb1^VVnMFUWW+JhffFBŹwcC֍ab*1&kU*-қ_IMFvэΎ;5#r4WGH%OSr%P!}F2TԨ?h+|itVvd.vm'_8z$ba:{R{]~߳G^u+1(L*-:qUUU 8ڽ{}СА{u=Ieu wUuuz8/Bvڥ >|X>MJ GSb^xq[kT,eL 9 ֎uF.KیѷzQ$)rVm]yS)QmKQe7i㣎|ycGG )H_{}бeibbBO?Μ9l6RxHQ2Ԏ;VF+Dz)r9x㍺T^^,暚s=nm߾}խ=r*))QCCڳ٬~5>>yw$'hOL%1=rYo7/O,$im5ok?`ӎnTʖSS.R F]96Iѹ)f~ Sˮx4883gθS.W\\h੯׽ޫk``@tZnݻWNض=ڳg~Yڶ"ݻWOx<]iWŽsͺUSSnLu! V1FT痱ݎz_(Y9[*J5j5HL]]1x .Y>O|jkkHD!x<ڶmUQQqk.eYMMMoVKK˂Ϲe۶nU11T.s;%oՁK :@tzU=?zohtL Kwm'NFuD5+HL%Ok).7Zdh`J-hYWn7ٖ&}F}93wy#I?O{< '?FGGߴIeY_5w3j¸48O0ZnL ka5%<'Y0, ꋩS 2e}~zmذMomk V\8cZ 6&4"XcwsWk%npSr?glV*rLYN7Cd2?\.~ɶmwÏdQQQѻ5)Tfm)O/v˖V<78T*~_^WLFƘw|<]{/;JJIܥ+,ovG\΍a{gﵔנCG\-kYͼpox<|>ٶ^zRԲޝ8J)㶳R~)߿=P j# קb;^[ί xxv Mc̻ʛ9(/ VTfrTrw{}./_ڎ#G}xo7bf%EWn' /q.%2#j%D333:tT[[ 6hllLHD۷o_\ӧOY555 ^-Vٶ -YWㆲ,KHDgϞܜ}m ukx<:~{M :uJ֭Ą:::ܤ`UHf=ǑOJS(˯9}11"y7vi.\ۗltiuιlg9y9rTUU] jjjeYoz:{P6UMM͛612(dNr400Iy^׫yQ Bbz :s挚 Z,\kzu1555QѮ]dY>;vhddD7n\\^9..7N:ֱct7ʲ;/{Q **J) NbjkkS,sKC/;\tr^yٶ@ R]s5,Kںu٩`0=T[[+ǣT*zw{Xh3544$˲v\{$Ij^sJۚ||1n-BgIZП/@ڙB{b.V WnWl־}T]]-q ޮt:-ϧ3qD/OлT__`0ӧOk׮] vV1J>@Jn:Ν;!dQ21F֭s ou_)#G]~6O8zk5/{ړh*_, IQJWO;lT]a.-\ևyk)nh4h4۷^]]]:wuy<iκO$qt:X,Q++J7X2ѝL&w0… 2(H?j"mڴI֭S:VKK:::dJ433vf Zs<Dxe۶܆0Z:I5>>#i|N:5,|?% N;fbo<S|b"7'_2R4D$9ptcbemذA===fggkvvVHDi% 7I$ěYqYp8gym I<_^W:~U^^g}=n'JivvV\N\NǎÇH$tuuu#;w"ʶm)EIwhjjJ2:zlۖmzݎ\H$sB2F#m/-LPzu 77LMΞ=&ࠪUWWlZs19^XF$Yw?7.2~q*ھΨ6]h6Z_#=h>!](Hwl3 Ǐ8n5.MVĂ]]]fm[uuu ߮aMOO))L*N+ͺY*͛ݑJ MKKٴ䬴T%%%5)LvWSS4661y^k~~^QWWjjjDޮ:;wNz|ڼy&&&DQh%d۶***~aO\Njoo訆 4?e28q­wtthzzZڼyBFFF$IHDmmmjllӧݑ׻g*ZȈ*++aÆe15 ) )HDuuurV4U&B֭[^+hÆ vg,W~_PHjllt;ⳳnGOLFھ}^L&,K===Fm[jmmUL8 eY:}2lΝ;ݑihǎf #y.'l\椟p va\j:GwlKKt9j'im7Q AչFaYΜ9p8"e2ڵKTJ}}}m[lV^{uMMMF|nq1F/|A7\s5Q,8*))5\Y?^~_333ڲeB1F6mR<׉'РX,M6iRzY/rbmۦJ=jmmUmmٗJ֪[DBMMMҶmuavm r7,31̨Ĩ1OIddFzT(4vI/H<эlNr\%DۥF9(Kl6ꛔFfSi4fdwܜݭ>M+T٬~N:aĉ2_WUUlۖQ[[uw-+9鶴Fz]mFko*ˤQG4wt|QGT[&=v}h}pf,#^g4YiH$4999i˖-Bzñi&iffF~_zڴiZ[[بIwMQqq9Xoovܩ*tMjnnvG>.\elܸQ#~_555U*Ryyg:}IwFpmJJJuV{TUU+ac4˺II[>hh&_5k0ڽo|Re("IRbTV"u6]Oxq&&&&ب׻`۶?+Hׯw;Uxvڥi%IUWW+H%%%ھ}ᰒɤ*++Ej޽58pѨ 4==wq_Wau(tZ[[H$2Iڹs2U[[?'mJTJjnn֦M488mذȮ@E"[N:sΩLS PssʔfU__F7a+leYы@ mذA555x<;Ǎ`PCCC|vʯe\555йsVAmڴǏK m)3d~[ 2|Wmm!]u^ՑwtL dtv,?#`s+RiP:;*K.tFm-FťQ]V' B[-~f2Y*++S0T0tGu ~<Ϣ³R\\VO3IDATXH{񨷷WŚeYnllTcc9*9{"XKKgMMM)˩Jmmm n^ L&UUUSܬp8.mٲeٍ^o_hz#%}瀭yGGK5%Fafvccp&fmcQ{a{f9fe5C4wvvN95zɒ%FWWL'^b|dG9(TcE1 pٌ?ӅQB<E1)*{OD@OsOpEEn\>O^atuu r`h"X0rss]j###8y-vٳ(//122ݎʎ\gNTUE,Fqq1^/9rK.^ܹ̎s̄B0=8\.# x\FUU!x:.\R,^X>g\Us ׇP(@ 0GbPXX+WE9 4 Gaa!vGf!K,Ann..\ H@4Iwlϑp8,ϋ!Y`35DNS/^c0p8z$U *fvy?xoyR):(\Wf5kvZTVV~b+FII ϟӧOWncB~~f4w\E!5XLobҥK7x1*Ǩy~\HX,`*:P(Iǹ96‰'zd_1xgv 4XS z#@[*Qn|ݼPO' Nhf?b;@J7`EMqg<`́bA*B2ŋԄݻwO.rJW"q @MM .^Ǐcݸx"(֬YÇԩSH&F,C0DUUۇvTUU՜00_ ԄH$~̙3?!V\ UUP(~@@~łd2 !Ӄx<`0lD"?~s1 !bH>6Vwf T.{fg4nҍ_cQyḱDFFl6iTUEff&l6}8PWVV ϛwT4MvDQ,\P|}ȹxEEE|Z1vtvvd~WFKK "222b ddd(((@[[rrrF97qkAߏ\op8p: ZܒAEOOn9@L$Ē%KG!   8}4>chj۷rT*%/KKKގ]v^hkkûヒT*%/Ɓ044׋J8N9sH^EEEsnk~ L WF @GGl6u|u.Rصk{pՑ#G066 K^%I!`׮]PE\+|,,% [r퀵k"J!??hllDGGBЭEa|е瘎 ]/I:Ԕ< c3;[Th8â,H cާyWn>ܹTJ0u;4M㋉[=z!33S^477cѢEt= //͝@Bرc?hs*++ԄN PhjjDii؍Q9F)M!8lWWWV 22yegs1Ѝ;lױEeS0###l v?FNN<uhv^NZzsᘱҿfc`1U3NҠ ;4Q/"`S|. d8t|3 Jso0L&1:::ӼزZxiNc0)r|b  ü<\tI<188(ssѯX,6trr-3ŋx<]EQ"̉u, 00 ^ 'M>ch\aSm̽k, #10(8?;YYY@9'++ VrK p`nebVوp\D"p\Xrc(**zl6y|b1"˧rrzb1h92oXdQ5 ʵNb3UUST:֭['3cԹ;F(",]3 d"a, f1_Ӎk9F1g( 9=a\c(Țu5-ljis455aÆ rv}eJTNo͉=$Kv5q+龯kt[wW7oVm1m3|2t'v pjH;wDEEVZ%;WsؘxLtX,cǎɕ+++QQQ1fʅ;]>L3EGGЯ_>o<2lbNڹ5]{^\X,sz9H6N}Z9p>vwCQ.w ]LO &,~B_ctqj+2C'\\Kt:^uLt8FӶL1195tް68ɤst:D4k#2Qi.br];Ǩ5hsrM>sr )|;X,rbNwRg;c)&Zpw1*(w91jƨa|Ȣ鉈| sK""=f*h|>[hjkkL%"Jo2O7F"""""",@DDDDDDD, DDDDDDD@DDDDDDD, DDDDDDD DDDDDDDt Vq)Fm;"f*3<%)cc@lg?7(-- /_l4jEMM ~bԩSpA$I!Xi:-ˌ_/@*B*πTby$~Cu>|vB{{;t]ҥK/~˖-/?OG"`]Gy8wTUS#++ |r+1SL%)y<]֊Z_hnnѣWPH$ K/f@={Vttt|;ӾG---Gg?c_DBBgB4i&3OTf*3Le[0OSiƨ|`|>/_:|Brv!f*3 ~4Eg^kN}s[H3otOLӭ5/q%Է%]Vy+I֡}j )$l|zD/-;i> "KiUm?Zc-=Mjw7MkO=O6kWhs?~jŀO7UkiMP H4O9o{kWf̄+T 2mu uHcDJÌCsRxK`dD$~UKob~] #Ofz8rJlm'<S{?t7Οl ۴b1{~|6 M_JC"ic'ӜIi[}zu26O/3lU7z^ss![BS,z$4kid_i!Q]\pR5nXHcKm@Bʑ`W\|9/l ;ty`wc?NG񵆽%ky"K4[cL֚K^%ռ7jP6b/&MEY}k} <-i0ȯJ,?Ozծ_¿/&OFF/j|R"ɥYgS&<$£u;]F<sr~m5o5tφ׷G}'ڮ$?_#iCjKY%nBcrO:2i4IY)?WWIIR^{?FK`([xOEFOEh<`rźް>*ȿc_wCXR/@_Sh]\ $&!x&v7 {]$V3OA:7(_? Ω?z/?mxQkvs('BF$8Sǧ&jz~\vXEtl%AZ^22ҼG%:|Lg|CD={iZ\7G" =cޡ%}mh3Tv-q Jd!T~hVO.㹼wweq 2~si|6'X0/x.ѴD`v&7,NxZKIȅ𡚾kP5 H0 ;gź^wƟ}[{ep15RS~#7ZKmhͶ&vQO[3u5?L{1h'=~ ZE_NݑJZOYb*Uviȥ= U}{ʓrX-QW$>(h{k`Alu]r{ͪe]FjcM=Qxc\|;i/gzf$g+tCh(<͹~$xzx+a IqMu\ɫ_VWq=QHaEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEP\_Ļ;;Ynՙakr@]GҖ((ƣ&V77&w9|U(֟)>e% [>d#k<((((((((((+ncw<SPRFKj x<84m;X׼Z#,єsvL\̺=5pZD )㧡]7GI aZCFܕ}=u+ ZPΚY[y ^ko5BYa| /ⶼbKڻT@AqޗLSڬV/ZVmۀ52:إWe/,|ǎt7:+o hĊOʰ/FK=(6[ LtA-7OMҭ$NkZgBXP2tuȡ@ڤ' Gt^4vI3b\#_IIuhW,xEِx)xA 7Ff5$e;y:ԫi)$,4uSu7{$uퟥf~!KAs0S9pzVZw[^BYFn s0*&;M+̽&b8Rͷ֓ľxH/`H\Z࿷Y N͠ s?C5A[JA<3B;du TIɲ)vn/oeR*މ:ɢkKVg,cRghڌzơwf2 E. o;|ey> K*[Hɻ+7Gi5̈́6pO(3 Q#rAܪ0:8]v'io8('*>דDEk\I!`\ȈIU8=I|PvZjVX7g~OLqVRYlYu$bI#ϧXm/Q.ϲW0òdpNWLM4|sAu^F"IV@Iܠ qKhirc o ©_qӺG4]yǮ3Wt:k=GU$aIyEWԴ&ԵvGQj8ˑIF?kH-KsK%l$y3 u˟bjQ^ǦibS Ip"9ǩTDnFie; ]'(>\0iΕuvkXgs;N:LܰA 3U/$C&8S Mq~3zR*_^fYRGfM< 0p,6]-mD_6x SwWmm|9m0U͔exZtk(ծZIFgzR:a~ f2H}cztڔ "n❷2 `ui6? و6ݼzcyZ?/_*Ft8o)a]1N})VRnnI|x r=1ިM9.t6t ^6˱ l_K,ݠ-b r%WjZaE;wT]F%K!GlZV4h{҄\݅A!ZMX."Gr)l{K ^J.^ٵ5) .㎄=V?Mn.m۬rmVxNpC9@U*b+tгaXܪJ3Y4B/jwdD!Hm؝X z5v4k(XqEer |燴(Z"3y#$@\OZ(Š(3.<{q%r7s+K(@zxq:l:+mgUynͤ*\F8`.&IlRYmny#Ҷ7wc7ᙣY̜X uV&-ML?j üo뷮=e>~շo뷮=i[azElcL]_?M o๑=RTXI%vy[wݎsghڦFM-mG3n`80:9%DyI0 sDm M<KF Mrvܺ N"2 Fܹ\<}+7VV6-n ڑ 7{PШn]Eu$}V(>VvU Um5WQdrܫc>4nw$SFm+P8>{ß4I>klB we4~A(>_uk] #|6x>-Bk#G>q-ij;diXy"uQK3P2I8Sb9Iadet9 B\ߋq;%H㎇Z5{[] E\yZCe,_w$SFm+P8>Kۍ>7vyw]F8;M&mi'VGrcn(;=s|ko?:+mtj=F[,Jlq׊Iյ8o\@!pS<@hTכO(uE!BpFKc#ڭj_fkweF 8'hu}nI wv!o%"8R#`HV= WA]%ޗ~oXaz`hQ\4!ԤŦ!N2=wY~4Agpd$g A8íEeVu ('W{kM}ᖹ՜\$^ڄʜ8G=indng+[jQӀyΊ|>!YcP0WGלԏkP&ao+^M*aItMwos F( NI?խEaɦdhw66Sj22ePҳ vb:?SsHka2'%󅏚;hAwp`re\%kJ^l쒴#AmpHoG%P1r 3и*TplQ@ЙtdKfG,;9s}pWPMx#8R:mߜ &^%̷py]w.ϵKy.9G3ZQvAzآ3#la:`^$ӷy(*wgԟ,,/>:\\-kN@(q޴ <73mP71>ޝEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQExW{Xmmۯ"uEHOrIGZ+ռRF)3y!Σ>$׈ Ԏ{}NNƾ>j|s'suII#=3p?2h* -x\v!5Iz0?zYd#}Ey1$kl^Ql :܍)m:>aYEy/$A쫵{ܭۼk>ۄ, kCeXhđ}[jrhIcq'}kZ3\qM {us]WZ#,rsdKDNˁF@ en5deh/4\iw=MB ^ԵyV vs8O'`Y%u$3d%̟{@<f2{EyUkqmk{612] *j_FK=<#zvlocџĚ}U˨",Kjy$fylgzFIJ+>-Ꚇ ۫c'șݏ/3^n =(]kSZ婉98zTdϤ话T#2W?cjŔֵ2O5k˪>^/5_{o?gIuM{]гojw>v3}cU?ƘuֵPO;eNєW_ j 4yιg$kO-L^eR?oy;>3>2x?*ڵ.d֯줚_y<(ctƪT5ΊYfK26h4eWkYlZѤi$9آyG]npʊ2YQAExo; z=yV< 3$Ji6a4Z:f?iybhJ˒gG#Mi2'wj^ j+i-$!Xƭ$q!7gflQY 4GOm#G<Bv.cBF * )mFΊ<+ټtzj|v((_;_?4 g3nf䢮ZָbUr23i/?t¤לzԿ}sw*GzKǪQhǪrE=l ZԄ(Q\P>kIզ;Z+^YL1 <bC@Fmy,p=\+t&?rEyǏ,2y亨RdjYR?'SFpN7drx|+j -\#[#H j1!t8_T_< OEC(;ߍc0}/' עU\r(B(wp!E|@c|es,RZ?OՕABWֲQtxsM]/V{C1ФÌn]Nkϳ[9{؅m]~R:R1Ecf񸃬 F'=W# xAPGv 9#<Mr:w]b+VƥfVẂߠ3~qch,2kRXg9LPgh}Dž7<6)Y%9\s^txIăԆ$E3;ɷ%[Ǧ-8JJcTUugz+7[CuغAn6d0޹Gm7&yT{M&xhih fo-Fї ߊqeVp ̛oSIpd2y?lXIx OwWx_s½:wdKc|Qetڈ7WCتIowiڕGnZ|0qzd5}oe.-9w2K+Gb{2k[OG|F5 lCm*«8!A,2z?dxgSJ],J,I'uk"C@HL?@?3SUeBB`qV6=E$B\no>ڹ( gs3F(7cs'u[B! c#9 pN5fy4[M=㩭:jZSiXݷ! ؒy;S;KOѯJC~ZoV]O#@8(jjh)#YOEfB[ x#~kbmoslm; P%\ /qz$TefU WqZG+]kP]Q \v$ $}*摨imghb"|*^zw35Zo[lj.D'а3:4q^zE(ҴbY PF1U}L SegjC\JPzWCe`|Roou33laҼ˶uTSGڷyYYHR#9vdª&|m[cޑ@0Qv{"9&,UsX~? Դ8]>D'EsI7iJԻ[kccjK>5a޳JI>⵼7y5`eX#(l@w}&W!PpI $_E4b#k%B(Ի&f[3Y>FYs}Ӟғc+!klx[pXe pAzsC 5d{3sƾ&" :c`W=oi1\jp.v+/;U/IkAJ4bUR Ff(@P9U28xS{\%ϖ~Ve[XMmWi[}t5m]d PBiH.e$ g޴~y5i:0~OzK$.&Umpצu `Xt:uZqj2jWn!yN̏wk< )9#M-p>149 `J㯯j4{k뛕UY@ˈ7?͌Tm-)YMA =+?N 4BXyaqO؈*3py|ƖE (QP pk PbӣyX}]=73?;7PZ6[[ tQ;IwR|)<^3c Z_2_4ϸUpݜu]QEhdQE-dȌ?>:ԿJhڇy]~=^fWPwtN;A/(5wE5;%EG;I~R_AM8#ԯܢe?/rosuO9#4[5"$Zք[ G:yQhVvs$0K͎y2<;E˻l_b{+*lXyFGIN(*=rGTh2\f+m'8kƐK'*Tv>ڤO$p͸ G#x [c&@,0r]v<3bz/E]wD̞iHJpLPN歧8J1G:8$o+\'9 hڔ:̱hf{ҥ[m.h;ƛ.I OշW/kw,6M[eYB#򃎧]ys,-h-xS¸ْ8L5+Ȣ|?x5iX ; ~שSgZ)3Tl/Y l<gsLw1qWbQ\l3Kune1!pCпNXUmL(:Rcy~ˤVXE"; |A>`gNr _p~{ZHBC:__#5ĪxX (sZzt#$-^WRz׊c t ~38*a]^mҳ#{ׁ͈*5I"P v⣏OdUiXЦ~GCzVvXgy9=!JbiQ3F@*H?iqIGݒ9 o~kPPWvn1'?ƴPsl# #ImLUIP$8"3ғ)q9,gh&b= s~8@@>i@*nOC; #7f6;jpyƺvƟ=a\~Ziww潜2k6yzmk&_1y[Vq΀dWI|yO+nC.N:U[5Fuĉ1ʜc?nr٣շP^4cP @5xz;^jSXJ f^~fJmUl9XS\$anbs{>l.^EW.f+k6H؜cy~Z9EsO|q%A 5M6Rh2}0@rѤI|I!ݔE|<Fm~6m%Ba©|iu/d{Ş񅥵İF+x̿H"q e`B؎sϚ'!Э`,dqZW׭ݴc1*JUWwjMRPh|21;;zu22B̅xܧ~5WAң(r=N/u}F x;wix KgN P~nxz'x]0y}8' /(Ek{c{ g`#8sVP:}4c?w9F-zUM?Eŵ[Z@(O'5j6N%j[)SX?^i3U4VHmsvQ[Z5;YG$Ò;#F{ )/!H6h sz=rX①l*C(;x[UĆDݵGZRL?~^"f{qiiȗ_HOԜ|A KwPH$y3vmkkx;QK?7t }-8quMO4n(u?<@˃[u#+ w~.deRqq/ȷ#_Ԏ<+FWB3 ީD}601 bmqoc3]@#̣8$v4e5.#v8k56+ gmg̨XxlpūjIio)jkp2a`3ZX-a~1Xn%3x _w?n|AmKӱ e#,qeb?GNJuS>gȵgQj:e̹Xa 8+uO_@D*U&8<,x"\K0CgxgskK&,}fqS#9oaXI%hE.YuΛosh5rfbeqCNX:bku:ntlͽ($+GW`u=Ļ8 g(]q#eBL F$߂+ݫ-yPUT~\zk*9QEY!EP^3}D?^>\P-i|?rDy_yUZ:̼+<.5&ۏ֙vG{u Bz+%[;]ZKV0>Mgq\?b#d#'ҺGjvR6.,`زo0e}?6q3K%e'GCU}ϿMBCl$vi%mn|iCpƃ%ż#Gli37\SKa|APi]BT,2r:e+ ; n?dWik Дf;0PG^ϣ[:([H29-4iqXK~kw%bR$HB88՝V$jwkC~|Yٻ[^ N]n"]8ӭr:.Bfxt gz2C|ǨX,2C;_s&xR?iʟ15mxL60;2ai$r|ev/pjm*Js'#bNj^Lb?s*mn_S-້TPnt]ZQ z.e< l 'fKwE&[6Tsk{G4N1po FK$#[b)ǣ&jc:WM* ?*T{* hf̟ϧkY JJ_ǩBQQHMƫ}s/pB]5 (Fr#ѵ!g:N_,I<5x؋Hpw@u%8XOAU)(%>-Jyn>An6#99*bq`~FGB$_r[jǭCFXn$ws֯[Ah%p 9 Ú|R݆ݧ>Տu nUvpPzc8A'߹hp۩Yvb;1[m3.|H==뗿frVԠց9:1+l?~UĺEgV/$%?yxF*mϙY> /$;ϜcvyU#̓ia7b`'=kr <7-c7F3zz=?v DB ddMkh<(ֲE-,r3ߟΧ isHmh+F۲y*s4׆&QVMʬœgW;Jz~5wqU|ghZLڀVѢ}MzTz}>@s*U\ޣYhJEt"hmcwkt}nٸ 6/[S|"gwU .-&y_]CEugel>ߝ}U_Avmܘּ_SKq*禬/Vfxⓠ"?~:Ҿ캖ku:dmten:]V3$#H\}Ky? "rK$v p*8pj8h1<[K$ֵ͜[MKw6Z4brO'?WN[운X=$rt>ڱ<}5^g$ecz :Nn GҚK_oC` yq +ëu{ha|˟L |#}Vt`?[S]MևOL2<yU3"p'wA<<\#q({K UQcdrDI' y4ՆCtY(ʹUXu+{ҹHl h0ښ | Sp@Ķ\%;[ICM.YwePFWt qmsWƞ'!m]pF ?ttm~ʪ4᏿?S:Iuu|ɜ0)ڄt믘Ih/q)k|xu=8gO #@uZ߈yzs,d?/,wW0J'׊}w5M)fbٙݗ,Tzy' *r{/zJŻk nndb>@0_jk\3@W xXK 1'w*;{Ebůs>uAm458%{xk^'% ˀa&0`'|^,4AhK@AxZ@]-UpHqc^1o\]?li'~wnXr&[u:menVkOtdRW|LW=>;X5+Y8?bFu s[ b!T*6rGjb]Ǻr(!VA`ߚvOCv?RxN9&([,<״W?tB=Vi Y"TF 㧱hQEPQ@xt?Z9F578*4鞿Tb)T)lnY4,%滝4F|}x*{Gt:1$ dmեXOy<1 ,z \ߚΤ83u%MN_H-'_J|%hI9ﴝ!"6$R,De$1&]6 `Ȅ\ҳ"طNtZmAԑҸ[RMtm[:ݓ+KTir22qcZW6-4V|n g>մd2Y8-KH.1H#*xe>lNGsYi+{ju4$f,xyˑm4gK G D6o@0OΆQXmf{YY_F9Oj"-CmwiXJt[ [Z-ɶݤR7 d`f??sS&wocɵt-NݳNU G p[4wΘ4`Xݽ6b,D;GK2Z|s,VY&gv8ɭy" 7ža޼aC\m)aH!ጞj{a62Hʱή}5:q[Ώ F C/LyV+hʇ.Tק?d_RBѬL )] ?SRƫv "C:`D+ˆQth]ZY1'p ՙs f<ȘHPr vnh;DΓa=@zUh:3Hz<Ó:RMƞRHIݾ} $rGl֐ P m t ېZ\s<05wHmR1IldČ 9pgGFͯq9#Z|c,>1ss2? ]"nEa*|qۥs^ 6{aaC! u>JMʔ[;*X,s7=v\aTgWDo-B(3"^{ I.oɓ?d7Yêdu⸙~)&`ڌ^{_h1g?'\F>+|1+]^ KDs_^_E-+Q' xw`8ZnOD̰>s_Tr~W[;-̷ZڅŴHdث.8?kI [h"3;X 0ZʭS^x"-fdgm myy2w-7$kk[dd#Ǩ<+4W:di3b$(ރޢ3ZOHqFGC^"M+lZ)2ߒrN xVtR.e9|!R9.AW)*}l<{ѕ{~x>mVK'qN#p2@V{QX,hEܩ=:ʵ<;-oR@ӧV%y2=+4D+-֝#(I3OzhE + sӎi\h`ԓk{B;ȣTD@XA845jsI~%i:孯ڧW$9#v^ޙXu }:-76&YY㉂9H1,u͜`(?k[JWcopg-sq隣F SI2LvPr*q+FY, ; [7p ,+(?2q1Y"ZFj^ӌK2~_B+"Z[lN&d }6Iexf.?wS"^Xwr:{ŅS'oMs-^7P$eol*Y .sJ'ī]Xڿ3vŲ{@| 襯$5M/I  -k,]WI h:3СuI%MӞcp]UI1u+'uz#)mp;8 r l^#|HT-lzv5&\j v0*w8ITMS6gkmࡤC>bݷʞ~byVtiI泺rR3?jiBa9sFMl[t E!L!zXj1LI8g*VmMw#(N]Oc7z"`>UcpE6+Ք"^G@ixtbB(AԺ1{ZQs"Yi8ð7 1ko &Z!Q T*Dɇ#U=@&H[>п5_'{HƟeFK#B7Y^;F;Z  ]²3D )>t &YNӖX]J#d(s7JǝsDm'IĆ9"}?*cPndcl|r{^ kuڊHS,mT*nⳛCd--sW#Fl[>+H55aWkoo5 u(.KwD̬ss^kmdJG F8b) sGsCFiawFY Znq7N3?Ս".uʿ9?Ҽ'Ga}GTk7R (sH4K6X16{$IȉhvD=7Ӛմ`bV0,,{?,׭KkeLxFOQ<~'ÐmL00z R\k+<&mBTA5 CaxÌ [g^՞ՠ΂ =0:1ξ6^6nm!3{HpGJ&fOФ- %d0ҋo 5o%O4ZEz޵S[Fw-km~&gzgՒl Mm7{޻UGKsESG@kx}\Y~H2Vy>R_ٷ#v -A__cQ`$/9E {[VrN  1+7.)˶ӷevX2={]sx.u_ N,M>/|>%gƣ un4[m7LI4WЧ!B`A]$.x.V%'vrG`玼j2ITc(1 iu F}R]ab2Rߌlocڗ-o/_»#D沟mg[XG g^Rh?)c 4s >ejV6oslwsc)r0?jN=ϊ'Mq4, O#wþ?)c 4ni9Y|o-;ix7eaEym:[D[xN줒`Jkk̖N-dVM<+OYj5̻Kp zIh=O.> wV^k5;~2\iwZy9v~jP:ƍ dA?M(dմvS""S(9Hq+9w7EUZG aj)y((;IV¿H}bdv>nX鲒/"IK0q?W+@'*OF\mG־>z_%_|Zukur^+b?4B?Oii@ϏkbSNQEB ( C'mkC'}k8ZJr95v2I?)CiMZm7?idIjDmI沴kg1_ZmkKayOg9shw_3…#!B#=pzKP`ws;Jo%q<+Ss# G:繪$m(eڲv66$;R.p^߾;|%kŢ<dl?;>jePR6qGL#ٔk_v e V1:3zA^dKo8tKKw51ʛh=9RxU%y~}Ni|=!eg9bpSSaD[popjno:~4(d/ra+" 7sQj֑ue-<a'Ld_JfN26x> :HTD'ՃxjLӤܴnDq${Uy]&!7lf*fS<-xF/̓r?ts^;Xʞ"[q,h09绖(,!;׷ yݏ/o~!۴W, ^hzs\q#ؼ`,VHEe=Eq:cHSiMǫ78]NJEeTxR^D:GQЎWh&iQ$$TELz=2'$lb`[~dN˄Qr{*/J{(?ƥR!-UYS uXun3R ΊéUqFȳxQ'1aqoEQpxqw?nvojpv35 !b(TQ~ q\J`nO^FC:*G?ˋqNñC298G} x|[2\ȎP6lp==^f;O't4д+mJѥ6HN?:Ӌ6/ ;K2dr?ï"h&!Cⴭ :b^ΌP /RܚO֣um*cT7 R7Dߺ9\`|wq]Q[jX|낽q֓Wdҕ`7@˿pS$TL)S)XxF oӨsڙAB2r\G[z3︅r'N)]:bK8N&(уWr_ e^:63ҵ6*,l":޿ҭn"ݵw6O?S늂y!qg 3\TvqdƝ4$a*IdW^|2I>H伕%aK>J6㦙uVTRw1ӌRiX߈0[תo\u`U޹ "QPPQE4WUj"Xu2sZ`-1<\Egۜ~kq{j[De sWb{E2ȧ*LA26|ˢjRh:6.E-[1ꡱ&^C !啁B|cUƒ[GvQ$QcrMwC9j:ڞogs&X]{ iN@*W i2AGtSnU3z{3'_W΍.,T# cAIHg>F'N\G_*9=]G}?F-"X3_*)FB4 7Aº +6l:,c/jQBK*cb2*ᛇpT a8}@(I'玥ےp;UQEQEūs?ŝha#篈/ĽYOJK\![5Tܓt^@wdO Sr n-v>k u){9$a$6[Zv2SIլ69b70 b<rGCG=)E n03twNI֤d{t ]Mh70 .Tyqu5',R+Qq~yȡ|IiTeaʆުk[]='z'PQ\6vem-wsTUJnZET[:46Z60H!?Cq:X3D)7{㷭u^1x-tً WɅ#$yy\T1`1:u"V 3J]-tY-u{ns' (8x>^`>+r׉_k M~U(H;d{?G;?it"w&N1|?ugᅱ6\v9<B*Lj.aF([s=zu jt~l9E|ۂ0F}=(ZNjt膟[ݩXm;@0Ay9g$eXM-jq?gwJK#yOH==qV>+ anfÁ?c=WgDE)|/'0Q,>*hHcTԭCuR]꒯mtq9|-Wܮ~֝*xH0`fBOeNy $WKY 1XF!FN^W{8-̰ʊad+.FH{`~uU77ciZ7mDwyГ\Sš̌ cdP7 r-Hg/= `c:#s]l6n,efnAQb*ZԨ>h 넂;i>* Y\m)*e ,VȲ6r996# G^8=oΦ/]q)Q+)wxe8_׊KO Z)R>X#Wl"Ke(G'Wl lVt}jv䟼xET563Cp?jvEݿC>Vhs7ڣy'pyFtM42haʜs)^ $Es*Xsc\?mIP#$UbBƋ ^o߁}R~o_:]ͬ_}N>tZFq"m=[WqsȫG2fx8iC&V2YR$.GRj+ cX:RyXZM9ƧpMI, wҸoM#vAs]t|GaڹZ/\4n|PF~SX^}ń%oĜ!Zoj<:#oUqCy֧%n^ }+> έ3K `=L֖s|-g$lh}HD|mxk/;[Od~@*ŧ\"}w*&JN* KH&ĉcrdm%q62(d_i&hXŴ}c56HrY3:_{s-P4{9^u0 0B~^N+y kEK:wdUe.M bE8z:QLe.:58pw#r+m'0!i\xcc5tvx&Fר=y+˸-tPDdقBJ$$-ZMM@aNK0obf9=ў,md֌L3B ʣ{#Lyk7c]¶?Dz`U޹ #o^r|lQPPQEj3zhBL aFO<ZXGVn.=7syvnG=oAARAW2y3ܛh۬ϹhĂ[Gw4w0?|{}vH~ykȱwQ.U95w,O ,׶TW\Ś4'@ 0͈ ]?\Py]ZyI3ݔ,+doc-żZHdHU=s{V]gj'\s]I%P Rw{{¨O} y]\dXn |ɭuk[V9Wvs=B۲5%|*Ơ@((gZ= W[o4dbv$O+٫Y Ԧa.C1铴 V5ݢiOsOu%2gpJiЉtm2!uk-ͬȲ˂zGf񏆠tƸTC3m*9 ުs4 ok+R1ǁTcq5۲F2|kWIUŌ1R4rP^<5;InARi: 6s OSZSkDn~u_jF 5/|-twx?, \ton_ hKI3p; WYͮ.IH. r\]yUowC7'r{mq)E$1?/Oyi-^EAP}c71 t84|a9ISW^31]H9f˫#9#.A""ntNϥO[kᖹAeQO'8SV$7BW)U$񚎧lak~шϽȽ#Uk$gb w'3?n&+CT*wdvB<>{ŪF[W)m;sz+/nx^h| o?Z|[u/1*4jpnoڅڮ-`@V]jBn\֨Zzj|OwGo] |:;1^u[3wN8eUC:[I֔l3 [ubF+b=9 8 HUm=:kIv2lQ}]\B$+?x[3m2;s.X_#揦q۟]qq~񌹣K땷 0c9PXL t[!ej~lٙBzg^ƪ#Em?LA_.IN:8ԳWٮvK;Ψ1=:+v(]v9Pt5GVçJU 3犻T8e򡉤ad? &m s؜Wȫ| OW Փe`Aԯ_)H!zl69幡g]CNflhiL"16*=/jtăީ$8oCek{,Gk#R;r*Rrh];ׄ feV#d/KMD !o&DR2{UWs(VN |Ӿ'NQ,dHdS|A&OBI:~1 ڕ #+i/Τ$Ҽ%d;P ޾4Ґ`. ( ( /j_cդXԜ]ӎֽ6puuF8aUeV"$s{TI#u^V ` BA'Ҟ~^du 6 Pq`gy5nMn%P bxW,В'xm'^K Ț9I4QGӁ[}7ź=tMi cż,A"1^=n{FNLH=lkB.zvxRaU7 K}H?/s }DՍ u;k_M1g`oy7_\B-UGck3G)26rPK rηo.EUO3gl/宕&sJ𤗓[Gy`bV$8ABMfyG'[gJt .ُ3u g2Nǡ:M>˕>{ۼb:}KTu\fhbfAE>J3[n$qުn {&/,dQg$s})1=5I=G)!mW9T'M"i,PègQO7KO[kK5蹌J>5ڬCbK溶6m?WeE!82 `kԏZ$= SZҴP,y3K,Gncǿd杨gm$\la/U^mi:Ҫ֛%LicznWҕl*vŜ1F>4]eȲw/9Yd21I=p;f?"+궦zbI&GU/ =NUP j.Eft M4:{2 <8"WҸ[KuKi㌑{~ӑpzqg$V6+| 607 3FqҚJpsZŨ;yu^檶͟!% CZKV@NƑ)QІVF$syq dG_q'k–%ƍG3O.`S#(ʏkμOq-Kyqw z_ËQe%]!L?ּwƷP|A-٣cu1YB<3+ko305Um vV-O=z#m}u=oVBc#<{+K%ֲk; tHTIb B8aPj-gxpOpc]ZF6dh4N7Wnxp }3Ӛdo ,U"Y>#ﭖ1\`zýtFhda9Hw}N?Vst26Ű:j?jIX,d,S6$} jL*a hQm<*?cg"LzFHX('g@8mm WgȿVq,9р,BgJyK&Y\Ic8ՑoejCS0Rz{uǿf8[Lوg!~$7Oƶ4ifim3 $ u H=ZwBцig#ilTn6s`* J(=(ఝmDGƄ3[=0x{ ǹkAe2Vp:d*Hi$jg ^b>;Y[xkRՓK km&#Ӧ'A^cs\IsqkL3;YK~+RUuyJ;]=[4 sԒI>]VOY n<]F6j:.c@fں.7F[,(]#ҽ[~KAL1gxJ!@*c4Ut־enXu C|A2y^ qƗeIv{tkS*XSҭfޟsM^sG*,͹ܮ˜G`*}\߳gϝ?Jϰs.>EuRIOs2ZE>c_>Žtֈ+Y\cOM swCDf ޸ őYD]9~>5߮zd6w-!KONUHBMa.p0c `=3Z:-¸K1W, C̹Y_3>]yW ٍFziwHCHjݣ݊0ebe,Qk[пkK%m3 ibPmQGoq9Rc\1b<vJXo{O|\G1|i5[Kdfa 샃ҼOXCmwd@ӎ{KJ&ot."r8t%~ u&jXsbOW|Qx7F{5*w#׮ܕ+wY01 i ԺTڤ,Vr0r1 U8R[4ן춥Squ.$%М Vmgͧ~n ̟g<5Wv&RYf*U'}RK4n48UUo Ӧǥ[A1<~NV3x+\^]spdjb0xҴ3ǽ)9"c{\֋㛠xSX@2ptWO(ⶋjmK2ȈȐ HmN1UJ<0[תo\w>=o5$hfn2rZ±3^}*(}e]( g8 WURaj w ,ў҄QUCn#wն(&\Xu䁀j_-cWG@oKXwEEhaj w ?_;E)^;TӬ|#kix_k;R b܁״x B;y`srT*Hڼ]o5OFjje y]ߎj<2iVK%ٴ^F6qn~g$r7+?w h*.$;;JR{Rl$U@}LcsҌ^K0ܰ#`{n^`|j+Wg/wڣ"oH^t?^D\?4?dڷv$=q>ݕrrh=kĞ*bhM86,#[s0CxH8hIp1x\uk{/q'B`xlwgN_ۅ견 09p2y0J㎨G8hHqכEZ6/e"1srA+еjVF8-&hRcVbpK.`P;G&?MN=h_'y@7G!?-XjUOsɺo,T/sŸNJ1kZZ̖Hzݍ7|)9^ `ӡX܉$|8F+>DnJ bj8"E({;xd`})oIx-2 eX =C^+?K9 Tӵ;}ۃ a;2z=8Y_M=Sm(U& YG[AMnU5Cr KGBA8stZKYXa!Orc=*PM$70 |% )d񐾆E/뱽&,}eTvkGaȌƷFf'e^'ڭN>fm'tYyon&pA# B{}f/arSWڰq$YwH!zט_5CLs̖g۶s,8v.&NI8 s|g#׈%sZ)˚)VQU&S_*hoh2=?᡿Wʇk=?᡿Wʇk=?᡿Wʇk=Mgƫ|7O55DǚPcv;oq_&0 5iԗL vߣjkHl4dm@orѳm9k+F q+ ٜuھ'.BIX2l7$gn,oM] 1<{ =T Sms+B r81O6uB.gkdgv6tx"MDk\l\m n9g.uۘvN7cך`{-Z{Gn"o~262g8䟥zL<85 z΃ew[.ɒrzw浿n#)7L/]Hi)fnjмc 7Rx+ +yh!;AS۵!KI[»_4Q[eèYp\#1@sҼ /քhP2x^do+ºFH=BqH-1Uϧ4꺶t}܁mG6k'ƏYyty&ͦ6ЇA{TW.1MŶSn'VUlj|qp, !ӵs1^{tJ-ؚ-1$jUsIϕfcs_oOM}.8VV_֪t'1@&]kk^Xķ{BvuGEM5[ %|郞L\E)@d.Tb`~Ƣ`[+. 3X[P|jhZou1NTHs%#'P4+#O|sM\e3Vp?Vh¿c/7EEX<`kRP)FMd +&??d+&Uo Qg,yjZO=@=ni}}Kb~eS/,cK +&?<}| O厾k߁;|m5 )C2۱XMJ_puc+&?_X ɏƱ|5ynD&7Ue0W@ a2O sCU⚦EXWL4ƣ>s&:'"d\$I_0!^yjiWK/,c]xV {k)ߘЌm`+(+GL9 |WK\ޫ)Ø zӅߡK%zg4dfH@ +;H9 u9[!}s#S:]niG<nդ)9lF{dָѦII,[Ȥm02g?ZеatMǙtQ2;URɌn#ѳr|HOZ\ 8> s ߻鴿ok/+#/QƏI @9Z+MoUv!l]2@P2I<Y~"ԟO0]^+O#,1j;'-UVoeI!!Jay=_[IidiP3{ )ӤM).@=3u:wGb! u+H۞N dz^1? Ix%{\W;:w8NצFAM}RD;?k]#aQլ?"Ko4R@=m͆NAH"38upK 9W_3 cشqixͤ˪D vW²Os/!E?;8ٶ8ڜ }l;a?5_x\If{X\T޽1Vvgiຶ̉ rғv**l ؍x'i'<"ջK3+.\}s66:1$[>hsksM{,9Bx?E*j敩:n~My;}Y&ay_UGSqҽ>,x.VsTd/0 $1ȸKNZRqdǷN}yr,h+2OkFdz<X0!? s~αۆL6qu2k?g}LyCa,z`WM.Foݺ7wN%c޲UB5)M6K1̺cn+ 㖮)2؞Ӯ%b"PO@k WM۹yH]/xcV e'r2'5?ĭR+ ~$pک޸H8!]I!O8?㛕?^2Jƕ᪚..{ik3HnrN2:qu (e]%p)КݒH!G GrY%ZǺU!5n> %z8k8O [V5 G]s+2Wj7м3^j(?n屜7+\]c}9 QV[ǀ''8+҆J"0""O8;T漛:ާygEke#-6~9TsMNڥ(Ҭ$[fD @9n-Y[5 S=gZr)ҍ̿xZ=6{m- >e[zG]VwBn`{ף_6ZIx-ܦ9F{YЮC5n!EͲT!3V2`Bjrp2p.>$}xf̠+^sǭR5h&?eT T}ޠcxXOI0JyRFLc~ouW߉q \hi{#; :Q-2#nr+kOoޱTycԸ&Kkyn&u(ԻI!5{|Wķ.vz g`?)NxM2uNvQDZZѱ]:̐-i_.Wy~;\qkԠّ8Ż'cgQ $Nq_c\}*ɑv?\֥ג@VqKY#t⣞ c5\H۲df;NF" Hޟ*OVuHč6X)V?*p˲1J6st+t}pT$cuq=9M3Aqѿm@NMnF'X32 :s]X?O0|3 +嬈Frn #iqgs eBD{dד\7;H"/r ܰxnMX3C+c bxjҩZ(ʢXK:WH$O=>5-rR#u  )7ST>-GI4DbU'$!]OZQJ0|)kntO03裀~5+m.͵ B6;OOýYM~l1(1BJtoy8d#QKDsJr=_~&K扛62~AuBkK/l>R {֦FVSM"r\Ld[i~E2,t=foy\Ӆ42&5g ]y^.$/kq>rO ûטk7xqE3ϧW ?]H܌,VŹ!%+U9<9!<^az'C^T$;8= g=Hl]2ktiNpqXYs'$v~?:x˺A#ovӚT_3H9 ¸Q+q9&y9f$`j%[3F=T YG]9M5fa Y2:F Q"iPZ)IKΡb<"u~&Xwy$Z$6HVrRɃS^ {N_M7 y[Bo򯝼g#׈%sZq$^7?Ca+N*1IٍC*KH2#t5Z /yeI-lGF.#'mOky=0_>L9FGAYݶMs1|oGI AϡY۵KHMLld+hfX@wpxEb-ew䬫8+<ۛMVkff}@\A!d=~2y=+$ hA y%bn=h=8 OWԞQ "WFWq=ǭwm!Ddn@ 8f Rm#nI*6xv⳾x7RkYz| &Moc:5ܧuҢN5 # |Tʶ6.{jp3qYGT㗹PySTz'$luG/u nwǖAB9n3ڷTݙV#A$.HsW^#6⹖mFdYQ@#$uY%Ν+F tؓ~]]5s~;WxF$r^wp'<-wƑFQ(9v"Q*(P0Gƞ'MSSc߇j_0iDu_kayu5zXlx #vJ^N*ȷok [Ab,{a^à>DL Iq*ie=qI. ?qw"B򮖢UusGcɼmGI֠k-گrc?tGÚvuھۯ4j~xGtrc 9QzP< |?'' ^?{nJ>![mmEiبR{f-M_owqhJ ]vj|8suy.dۂ}dkN^w^(u.?Y\̶᭓ M$c=7v6ڭ?rWjcj> .u;p@=1}Foŧ˪g)2\+r8a۶9* d^ejm ]\Y #k>ιsS[gK g 9V1[wz6_dv G8 I"a;5=N(YWYfnhnUER;;+DsiSiZ+<ј弗 (vby5v|٥<!'$rJ-6=h JGַ G@+ ( P[Rg{ +GL?KΌ7Qیs߸zb}" Bи:?<~W;+&[O H2.x=ƻxu4x1$pK46;Q}rI,\8gTk =;I7G^(K̹거IH >ŗ JečvHvIC#1޷jo#<{~C`=LPtՆA ~0 #.$uyl= ix]3mHfl6?bM+GL@#RF9r.!Y/I `:om k%F7{J<8&+F&1}ײ_CC/RP+<\&5ڠ !Ppkmīf˝̊LSU;Yw񂽱ڕ̥YTq~j-,_[OcGn$7Ўs a{G m7=+>!6IQUIc)x>`k\y#r&zIIhDbM7ez{8bu Lv,D2+|eZrJ}x4O>tK3YܝߊBXo?*oCzԓ}[9V^Z[\[ )H$w?To|l,>!Tx7A>1OKV3QK~VSf*+g~xNNU8>:֥{$|Wz^$ـ~hwCے+׵%ɖ j.$LzV4yQa *wkBrmwҭdI0vI j͝XH yCki.Md=bmU^Tr!NAЁV8ӯ9=>^bJZ+ ԬG@+ ( SF=;{ +ճ.Ø zц{Ǧ) SAuWw<9;y=F>(G&n{R灻ڀ9>>S.H^֐9 WK*?3\'< 4Ý*2}? Ե,oj9¢?x׃mrYZinX79"֮U>mg(2O:A<_fie{s6@Wf IǚȺ ݛzI.y,ۥ9舊3s'-qhCǎ~5J@'5Yf+ۮ?8]d!M|n\鷯>ڳT/ir\տZ,dpp[هVAyQ%n -Vo(p7cfw19:י|BkAo[[ṳ{OщF=+-+m ?μnvYS|r.1TTvnʔn#\]gvl#KԶEwܞJqZ,tlGq0ޜnWM5w,|+z' _GsV֪ƅп`DVu K )U!DV;Fp]wFuS6n<[ D+nvCv`~޷%4d,sU bvw=%lbzc\ңT6n> [JàS3vmF!/(cׇM5 5 K)qs\)BksGU./@g,@8ZD\2=O3?ѭ^<:7$:f۶?yozkVE @QEQEQEW-+t~opEӴ GOj7MR@`HN#fEm)O5ynӜW̞ nj0ڼQzh=s#J '@9R ʲs~ӗР?[YU^N)4 (A*љΧpe%ԭgMe'ş}J/lڬ?,T^e5ܭ<0db=MgI{ڻ"^^ouF JMh0?:<1mkz20[yqހ(Ac_ +~Oi9-WPVhxY𦠶5*7_*$6%DU'lnt inj{v>|pO\twcSX$gZWgƟQݧdVG%H.^V;'o93V&xQCc{wTzWQY C?ZUPGF'#^ϬZ(4k lc׊]pt~ȓyaq;f3}oI{Q'q3rɊmCy{ 3{2zc\l~&UU08 w%'&R[{x#`c59kep\%X)0GŸK?8{CXs^y?.G.Kt?aw>?`C> gC)t|@8?>Cާs飠1~mnm cNzW<Ap?-j_STϼ$ةl)w&Ue%i3ݦUʜc?ZQIU|,kml MlbKEst:N0 #ZOY 1ƈP֎F@&Ӻ(53$P>+vpgMc$l{WvJ6JJ)31HbsӔ#{y!P*?jHT`N :?78|\co\2R/'ť\?װjw2y^[ݜdj߼`?R;Zr߳?UmV-$vN[Hn5 ۯ. 6vu=㫋Wagf/|'8y<~/awm P p?]\Ќ^8pu+<]7Ƒ!Et6vُޣ;-ܖS Hzdڌ ? ŗtͤܫWƿkKjFMVmu(9zQʅΣpx=mzvqmt$T*FATQP3>Oҫ9[nٳM\γ+_]5?H\qv[qRn&[vc=kŵ)&iol b>«J砪7'dg::3MO=YpLcVVrpڞ/&H`23MO=Oڈ??&s?޾'S>z)g?+H.Aks˲'K".8g?(z)J*݈׫1E8g3Bj%@`Iziv9ҽoCf'c2}=k⏋̄ hX(xݫgݢ5a{T*[оH۴G_<񗗟sʞ/KKW]WԖEGq =A}Zs~ ˋ|>#= 5ddT}b`94\d_X!aўo+- QmO cuk7/*dxyۙ׮`P`0F*# '/8 *?,⼻玼KZڏ٭IY<.^@NYI ~-[_tm-t%%va ng:b zq19>~#o1!֎" H?]m>$qԩ","M}KB?qv ^ܥڙP{)V2Y.dv[9rq\yYX"?z@8<wA|x5ۤE& 2:3jr!13VO3t$htnW>-ֿR"W>p)"4cA)Ϸ͹i'UW=4|7KTτ^$ռQ;b7 |+h@0:>7AYPvCg/ /Q /WQ >7AYr_\W%*OWʺQ \{ßeK *7AG)ҧssQ_sQ_쫨Tj`;ue]foZJ r(H ( 'A}m_$|h?va# [-V*4ۂefdTgӁU5],ytZO4eeM܃V:{jzΩig?gVGQa1fR>g( ،JTjڸ{| Be#1 (WuO:wOy@::+I?oA((*j3YiP$nl@ @5'$DI;D񣞀uJԒ4ˤrL$e꫎MfGcc@T>_` -I弑wI}B٥D1-.aޯXY'Kix`*t4N.3q-ĵ1Vgg|)$l2`) PB/S_Qx0x@Mr%lgːt?C)3.H3&23aZi55X49(؟J>'KP;|\V?49MvC$ɍĝO?*5mx۴{3})4qW>Q4{QKe++eO>6aF'׺3mj,7vl!kr2'=U'DZ }1Mλ ׀ZId@A,G]ϖMrQд{_]>T XZS$ҿ~-omBs|]jҖʲb֬ _k3Q6zІ 0kRj95\<J\9S I[MM%¹ w1屧 N b5rZlXd`oMXºIFw$u_l M s^!$KrS_AOj,mQ 6h |%ezt&C8;Ĝ}#?)EB&gaNN:1]|1 TI;ICm'Wj%>mddCb~cTݺ6duks85y$NdP cY4{o p+q5b[oYK*'I%Ist;y>GѳեMJ)˗K{H0pwpp܌Ҧt,2s+^F*@tQ"`=k?k; nbMcxb()|'1eOmwgrgRT)# 種P)UO~$M6T;W^{Wim^>ao,;Nݟ]^.mFuimF\#aA|0$>)i/֡IN2Qs/jcK^b=+>=1OX045.I:ZN7zқw-zapL GPA~$#AG98ORugHsogug?!'bvy4}jjoAv+}kcX`.ITN?ZVOk;Fa4Bm/#ZGNL<ΎH=Ҡ~^@Ziź$CHv1] !س+`cNبP{uCb1%NApWpO|/s/f#/"NOwu|r訫R3*#;A$RV4R֏ s[[n0{bXj+;>LR0瑒@t$uM Zڭ 3b 2 .#u[HGmʉI#q6^W0KÖ#xO?=\‡^ww$uH<SWh9RW,OX1ii \Ȭe;OǪ:i%T$ArO\J,9d+'G(K8eqV!lV=lVԺ +bB(G%k[":G%o[": G+]f!K#w}%LgiLHsz> (>Tc'*t,2<3)ú.O3Gvf8N=k6Dxdd6ૌ}wivM´19@:2w m| l^EH7ɌgkI?oB ((ZeռX%rp2FT-nm٭*7 yWr0=5.dḱw'b/p߷nzssrXEui&-Xc\?3(:)5rH|IƅYz|G${gpm(3=zƑ3J³u y^)%ֳiPNy&Hf9UP'xrF7gV.Z>ڬV\-#%NU˃+}7O6a=Z8REqUAlR?8/LpRLrz^]ŧ|VFHFcxc/b Ls_@jŨ|[Xӭ?%]5W:)<\$$m,1o:=zfoU1Tv=뮿t[hztPOU'~>$}=f.? Fi;IFD $$:.*-1ICtV'ڨ[TCon }*}÷N5j,}Qu9uЄe72 h BtUl.-hWko$Y3a4 \gZ¯""?)XMoKq} E5("vRIcBs>u NaWBeeR1s] ioFԮɆ8pZa- 7<+euf,Xj1"xv)Πu|oUR1Tg<מxwYҵ/j=v; obMVR15baET6:/_JpMc$rXI$ѹs',<#,m>p6|JU&N+qn8+}P~FK,3O4-\E6`G 5O^>G_)Km qND۞)BH<?: ѦE((I'Ҁ7/;o_YJ'8cy~,Oʬwv|>wPf7Ul3qW I{p[&:b8>|y8mwL<t#MnYz(۟!+|^ФOy`b:"iZ槮KmŽx,ܶ_ݾTC65)n.Ty \wP&WӠhTl$|$q(ӫkV^ Ρ;hdThA'?)X *akyo.^ AF_rq9zUx<j)@k1poˎ@ C% dewK9\9lq1`Zu8Iuyh. m˄!1}2[BxnI*ƪIRIg@]xT,G\Iqy/|$-q֭]`dF -G#ަMEkNpJFh׵D 3'ڮTDmE V +gn2+$qst. DA1_8Y[IW\ x^O g>nLpj++H@K!$ǸJ {#7m?-b5e-z+u0-X,wZp8fچm~0?_ҵW:d"EBsȬ/Պ qޡNyY~ \:}E$@ܠw9AU')rs[SginENWe>'uicMOSbOhQ3}_cT ͷ1{+۱ܦ<&IDtƑe pP^K͎ӊV2]1(WU\֓;kQFOo3H73}kNKny8{+i=ދ`^̒3>c t ZZcmv/U!@ w 3c'̖G㟴|m;v(z~ kug(W|LDՆ&8YmQo91W4%PJ4<~\*),4J_o9\2{3a]RR62r2Fj=$_gO%[q<݊4 =$Z {g)Ie`.BӼʮd] H>Tr!x#FDscL<iv- qPA9=ĺ8}G34t;e|2ie䊟 Q>m9J^U)/g%jJG^Y "EAeo-Šeej8?OcVҬh]BYZ +Xڢ&y1h+F8ACw2889qst@I?Jm{Bp2qwhy?V=I*X- #*~aGFѼ7qɓq5>g?ָ85 M2qf H?eT۾NǗ6j1CqKHHH'UOMPNJR-2I=JSgH<#=BX7R pǞ:?j+'^+ H"J aJ !aUυ$@+'\μonti@1]O?:>>q/X)UraqLi-yz@JFG=k>=;cѲה:l [ur\CXdKt6ڡ\lnr9~j1f%uG{T{=SF˶'ܽǯ9T33Wwosj֑{ie,wh*Â}TֽƱ4nmO+yHu*{cUsF/ZĊN8 xv篥]s(kS}IVuU쎜,T[+ v䃀1ӦjrsM- (_z{amqOΒV*ssgПE**"-QW{UW)GZJQֱ,,ṝn~NH7Vi0Ɍ8NqtҏUYz}:% *cy cz8A|y%v]m4+- n޿)gZ bFņNӞ5^C=ϫ<B8B` -c$ `?uJ+'V>f;#h?xi+ǭڗR$QElHQEW]l}o_$|jp4o?)Wqڒy UPj*H>|QN"m*HJ@Um2&((>*F`)!FXt*ڨV5qmtBigs8lE>q?:۳G>ԠacdXB#xu޸ܧ@ ,X}CMFM_.⾡}&ĀMjm< nI-gsErsYCc{ R}+r&EY%`VU8E#>>bL8U 6P.& WmfJэ슎Qf 5gl&ē2'I/1}We(ӨQѝG@ܔ2m5cy&5k2l^:W1V XoJŏ|G "xZXgP2xOV6em=n?|}_Sesiq\vU18-xu'8GW1:I²p*r+7ݜàvƛNG.ZJ6@Fjvc!LxU_y0Y}q\-ԒbKM>LbXQrG;ݝ0+jSy#֓<-;1ө+.#Xmϙ #EFw(qIysj*ȧ)+ԩͣEwAUEKp-FC9>ऌڞ R 0[85t ge^iYt9Wךklu!}K ^[LS:a%yY\)z`.f$r1^c^;qzj_?r!r+73@O0&q+g`+];?ZU&ڄ[\c?һ] ?uh|AدOqN>tgּP(q'#>ToTcf/"hvIlI C"ݔ烌TU ;~1 d=(K[%mr}.y(c@Hx )wmk{x4rX,N"0rpF@w=e\366TՂEm>}upјʿuܼ85rV$c002=ϯ3["IR*qfլw`sst3°ed8&uh%rx3߷Fb$1m *'z]P"%QW{\>< }a9?TUUUq(xgh,}OC6wr% 2f0(p~8tYU\́F܏^;W]KYJwX.cʖJf1*q}yV]7֣ёu UI-=zEl aOSIEX[RD( ( 'I  |-Z}WU{>2_̛Q¸(S$.6]T|یc%rso 4"x3|9?7'GrrP:W/t{"x&3:agixj xs6 lTn8 zџп]п]|ѧL'.?PŮ44gc2 0%F}}M]tHq[$ =LR_ˏ.RtQ Wx__RuitrGy+Xn$%~ӳ!j@9Eqpn'zG q'.a< @/7v-KCdn'y].>\]J+t!+FO76o> n?Ҧ=i)CcG̶}A%@jaıC/MCS0?&]̏<eH=S^:WYgq[h&Sq[h_kaZI솦;zW,?WsƏ>Ɵt&IpG#sQI;>texdž-sk! qkЯtMCgڢE,}hU/8H#Me;$ 9+3R L #l.I?R}Hu@{knkmB?)m`T-'H۶i̶lc19RFG9{x~y YNIU4ׁkU1x#zbi)I{n^Vy/u[{ۃ%ı,0lOwwSCFu*XFF8xv_CFN u\M=ƝYvbrO kIַ)׃6xbʎyߩ!k (icR3/\O>v BQ`ea_@<1@T>:~O*MEjQ<_Pz*B1_aij7y?h6{jVM{0'M7O*_ ?O*Z=g;"͑ 0\?8> ;'W/ZBtD2AQWV *-3^)E=;)ׯ>kogY6-cJ^$qz['p!a5ž hf6+9;qӽz.iimws+#$vQaq֭ ܿ 4$6Y'3? z57\rl!QI6,?|wBIY>xKqDɹ_T /MM[E4|Op #d89kKv:&G}M?O9ɹڜjJǃxr9m#,61^|Pʹ!HkvǑ!;Q#(uU) &?O]L*ҥOg139eSW_PŸ'n.TMȴjO"NOwtzO^F5<|$QUҜ&.mX>?SY)X>?SGs#2,?Me9Ei}~ͭs55iN;QZMopidy-2.0.0/docs/ext/moped.png0000664000175000017500000055057612441116635016547 0ustar jodaljodal00000000000000PNG  IHDRơgAMA a pHYs(JtEXtSoftwarePaint.NET v3.5.100rIDATx^x6Iӆ6IR & cefIf[e˖d%2%b<}̬fW+2H|ܙݝͻwgOHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH39V744X,c-c-c-c{g[HHT[[sB{<]?e?_1-ƿ;l\SPGz3WTT 77+...FNNZnYatyy TTz}[_}[6η[VDqI޶uUU%{* ߽n| (15 %I8""HNNFll,JFNKKuD~~MlJJfYNsQ )le$҅ 907dS>a(ˇU'mpJUҧ!(HG`R"S,2epw##↜X$}?ʊ _){:;"$Jz 86M_rqqA6x '{[Gܕ[}mu?Glm񲲲//q`| (/Eȩ(**⎊gJ:..VRu^]ֵn[ve}uW[߲eW[XZ+믶eթj믶յV_mu22p\= GG$$'!<:)q GXl""b o`Ćx5q|PV.t."6l7Wx"=)vptDVz2_$#>:q he]#7\'g&e-f%F3D3 غ2勂#{.E6nAM,)y %$7Y}._Al0`ed&Yd /d$ܜtħ ȇf;[x7 y Ķ vN.J@Tbƒg'g$gd#$$ ll'gW$2-̶yY Dbt(\n=|7+אϗWMyݲoZzN]euGݦ[V[[ԩontJrk}t:uZunYɷVnnRGR eS@ uԀdh.'$$TiFaa!JJJtYaᎰn0kO΁u{8ػ !6aѱtGH7\=}w8~()_+v =\`7 0C \ pfd4bxxyE|0?wζ Nj{ *ʋqh30M op.RO+HA@d+2$"-'?M{\p=<<^'0kc+J_h4\=L6/O/_w$/$ nבkWĀ_َ؈@ر0$WPwe"ԛ;\9{ v AF_b;~%{ėy.ZHž$ .OF(--󝩝 <|gv}*)>nOwҗ(n]I[CӶ髿՘ڕ>;mk[(n]I[C6}B[X=CWpG"*.~ 3p ز4.tqY C@5.nnpe拫vpt!P+X ٻLggQ  vCظ Rnow7D%Ep7#ԨD&e gx#=9!) t]'wH s^ 2r2ٖ@$%&?( HטX2vrxkNޚٶ3h"鉸jk˗$+B#`81HIH{LT(g(mYE(Ou >~AHKMdY7oPnU?]I_eݭUoק6Vt}'}uW][UZ_v}Zk#nOwҗη˃Q::Z gggs8/LS8кm,4-ۦXߘw;]Nwz;m}m/+NTDzN!SNyʼnq(If{T0YlZti!!!]:Z z | h(tg "߫iO=6MP)Jk2TtNd+파 vYl{q{m~lsr>UYPGA* 6;0:(cSnGm4GZ?Н=e4V.ӓ%,,,,,^GPPP|stsh!VAAk&ӟ"(TOѵ҇L6җi#`V!P+}(c(t:ШINXXXXX^L +R6c|:㋂RPD'"+%Alۜ/61_2SkCT\#=cROPPZX~{@1 o`8J Y ;Al,0fmOBTxcIJ&#QሎOF;¢YXƅ#(,Ecc"2:rMMBtm[%o#B 'AmPdVJ!>) IIɨ(j*v"`"8V@rF\ 5;HH@ 3@//Kc or`~Q ෤+,.|.>/,, 622˗CrTOM|DNIMBFN6#Lg :6S:11nFfqq&O35cϧ@ u<D ޔz:PJ< h~i<}c߸qGihYeLGɴA(Z6ѭ}(:MԗР1u5E :r*v[w0\7px72B5Dx:^y4N,M zeQlټ7ke'l5!ݓAlxCC؅F.2ʖg L-)c$Gdʠ<1] ,Iq}g@(J #211c'4x#AxZA|܈H$!8}>>EI{iE.+sf+! ߗD~?e/Do?_$ >aAM9FjϾ]D ;5v`|.v foϥKx@QaDSY9؅~;Ʋ ꘸8v$ɉ:#hvLKMPuZz5(VO@ uҀd#}A)|Q LTG/;QOiWLǦe(OMc SJm!TYN)M?t語ࢱЎ=ӧ{fR]l,x8\SWq##,/[b/ǐ q~GfN ]Sq8n߂֮:vaF;uMEK}غq 6oݍwۼ{ 64Cpt-v QZULijZD3 a ) r&0 @VAߏpvvqrQq)hdD  O7/d#0$ i$/ i"Al4J򐜙x!-'d(JTᨨk sҐ^>2OHh*'Ţ]dh( & h֞5N(ɈeƶiB6vIdx/G38d'<}X](sض0'lzg9 G2{>~afJN2l"b=zkPU44 _wTV#{aq!ߖPvq`ɉ) `/ɩl`3rO5u;࿦ N.l]b]THRJ&8J@b4Ƴr\wjV.{.JB㐕pD/)FIe-}){>OEj>DXXX4ߙRؘӨ 8-#}W%XbǙd<Ϡ8 aH>/ii "+S,ZHi@S&VeS; Lh<ӲWƦi^-ejAK^4Oh5M !;1Eju3McǗQDPC\O O#AytPggNmt@Z8,l/e(Y)72KNKi:yFc[PV)嵗gn~z 2o/OTʲymU)l1 Śznxe3Uo/䰓&j\TiYZnvYVkPzaaᖦt_:I93oVtR`x[|>dʔU[}e-$b_HR lKJWTwZOc1My ie9e6Zt$,,,,,@M7QΝmqk6zTh!VZZ?VL3JZmS:}V<|unmLm_wng}mQWk׮M만X]ך-]_۵+O_^ݯ5[V_6ŷkW2bu_kַv}mo׮X_?e|zu֬oY}j}(G__}ַv}mmquף>[V_Z8W-]_[[oe|K(I9'V~-$ ľW;+ :Tn֬nw S|qZ3-Rn-mNb[]V)Vv-M.[K[gutUʭ鳺]lKr*Y~/ZHu)fJVֵ֮M׺W)Qֵ֮M׺CCC5*JSYn?}yJ^֓RTVO_^RsdT1ӗWԩum=Yik-ULeź+uz>w[OVZKSYn?}yJ^֓RTVO_^RsdT1ӗWԩum=Yik-ULeź+uz>w[OVZKSYN?BB>˖-zʔ]ZPGvhaaaadBB-w@ uܵ?ƝsWvjn[?E_aaaawA~.|‚|h[w!‚|d"<"J6-$bp? nAI-EbV|"|I|Z@(6A)[a yp MǍl8!4x qǤ[XXXXnn}k`H89!#3'N78  #59)|`8} |=]ab~)IBp;\<oOFm-ZHN ;jd9%@^)sV?ԂB_~gz|֚?[ZHo[WukuWp-['>>f Dc׹2~%cq5 ŠPxljQ8zni'!<v6p fG23 O!/+Vmar` 26cezr<]mp V mfvnN GLͱwN$imh!;->`j ( o@Tzn_C>{k1Y(KXXXX9/7Yi031NLqps=p8gzɱ8{!0?qIΑ9Z'* 9Yi0>nO 勈K΂Isz\Uxzz!@P;-^y?JkQST@dZl܏>H 0;lHPG[}h!;`BBBBBI:Z ?X_^> ݓ@ u{?XoܸQ> ݓ@ u@u F )P$Ֆb0w | X lo\0Ƈ}^s@RB<RPSG5M(*CaQ㑞j$U,E|\,2(W,.D\\r2bл̕f h nߠ߀>X4kjhۇtmU!f Aテзwtnb~^5z/qJsa0 > ~ 7PȠy=p |h!!!!BB-c]dݽ[<#1. 5p=U釸IgxN9wFqsY h}FEm#|7@ u h}􁥥%,2'{g}Wp |9V,r vc510藺t}6v_uq[WlebWW^ -$j ϟZ45)l{RSS#}>G6sMxI(쫞kA`l"!&>Oi+`{j/ d3ކi+w_wh3ٍ?FPאh!VkdϷG;} ,ZEH ܟ#7d4 Lc¸q0?|P{is1`,O\ה;f Zt4UM;v ,ZEukz%ZH%u/}jhGCc#71VZШz%ZHnlCTh ۪ݙb#4[JjRcn8^CTb\!$$$$JPG-}(>an.(Y0y!BBBBB BBt}M%JJo"QQ DNn.B|Wg1)AJtsQYU@O/U#8 \kHPHPG-M466"#5 I)YZcbT5I q-Fa~>Q\T"5t/M+++Cyy^yJ>sh$ZHֽ4n'= BB-w{7o9!!!!!%ZH5545{s/O"-$$$$t'-$ @ϟGDDUzw,ZHHHHn$ZH5]]YAi5\u_kH;udooϒKjf{t. 8.]j75VIS~hFHJKā#&0=z )q~KP'tmm X[Ce@7g\`-F.ؿu)oVcݦMo bԸ)XhsU?'" sƏƺ1rH`ظm+ 2NJ tƂ{ck}c٪87ŴyKhuCFM2R?8W,”冸j¼)tO`?m6lN?Mu6`$xأW0oQՇxasV|K1PZ/\xx3 -y~ۢ2o9f \cL}8@pR1fdlٸg@ ^u"% #BSX,vBq#*Y> K-$ %}|?p\a݁h t-& ͏bү<1fxUWs(qqc0iLظ`YjSV1c{A/s' Ur)0G-9K.?^Cl;qi.Ylq%K1~P[8~!F1-ψ)+P/Ecؠolw\~j|oic iς^q{a4n8pfFpvsGVj<֯Y?Ash;% Њl8o8&!)>)rh!N(о蘩@b2¨價7jnH}y:t6 GXKf? N 1fJu@G^-zI+&cظº acqקGb=̉ŘIb(Z`9X8V0vvsf6r2"B0d(Ƶ`og[fVs=}|]o#ƠHuj?i4ԞKq&/)gݷ(~qF5E`s] z:&;P:;¹sOEv{0BBPП9[/o=Iw&8 _F2;3f닗PPӦ "s| dY'T,gekV5Vaʴy wh4|_$bCy}i~ 938r2<>.7"lN>L?_׭-o:ep?LoP+:O"y:_EJf_D* p}'ؠF©*P9kq351prd8_j'B;#4鯾O-$ E&f'莑ȊK/Os.G: n]V>I-$$$$t'-$ Nt;XHP';Q\T"frjzD \jkQ[OiBUt[:][NQS}-*ЊFGu(*. n=Ʃп]Rcc=EEl]ŨoyUEճ5HOyM).-̭LCii)n>[暪*]cv'rsuggg~Q,/m KYkr3 T KHJEc\@'7xxi(YĸX1Ebl  !9]'4/OŶ]$Uu(ɉd␑S2$eqMO~%OWV \p+;F,ںz$iz".dPSޏXTTס9Ȍ5>.Nn)mq/_ I]/$ԕ$ZHv?heG\2? ;+\,IWp¢an}%9)prK8wLZ|`≳'":[93xE1C}ajAw(Avr,ଭN< 0?wV OA@?ù c]Gm+̄@~a慔ݿgLnʼnShh(ƶm{` 9&MqU8\:n>8yJKCS}N`q)hr q'ޅ ?7{k{\0= ?o^ϵ#LWḹ+(+1`uj؅E 5@^H+IP';.0pls%buAgUeN[Xy89 MOkesdW5"*<.3 CΘ҂l053fpsw|q1p,. l[{,.!(Fz2]Jyh)pvq@[;:4%cpᆴ&CBB]E:i?/;koPt=T_^^ OG'DQT<X 8o?+`}]npspBY~*S8wAV\J3f#.\<p9gLƚbȶ Wp4\vC-^Ĺ sqY!<$\; lY .>b2blEWl;y+ƅs8q #]ߓ"l,[Lʂ38 l] 3`dr흑g,qbCزpP <=3g' ~^(,+*J2qJ]V%w|mNCBB]E:i_~pBd\,b8gWZ`(|g~jSHߝ.pXodP';-}-^6噿k-(s  bpF: n  bp'f1bbPrR{f"::5u rЭ54յrKKUV޷QZ@nrG!6zݬ eehOSґh!N(w_~%ׯ_/$=`ff}`eKѧo_-m-4|,/  mp#^όÒ30lڙaَ3rkKY9 i<_]Mۍpj:?,[R%1qj 6`1߇Φ`/XXXຳ7heQX)3(j@]U N?t#ZHᅦƍ㞤tmm-*3qႝ\ӎjҩ#aF8⢳0kL5щ0޹V.I3V4q-Y||9q٥lz؜;l_ôI0m"%֕l?anڙ0a.] +P>У#`6cԉSf#'`89q N1y2Θ4_5a0ܾ;`eW}r>}Gtb 6ZsfbꬅN-L<nEQE%NmahlT,=,İ!u"BGULcssFBF q EU|AAMP'tS}5,Ϙ#1@1ut?D) iN:Ť%lHUwYcI+>VCi~1e558y38{SG A(  46.5e8y[H0.0360yJx^5ņOf ^v8e~kN#CaΓ(ωŘ+acE[ 5c ;so5?:< !7l`>l_2.H@9X:}bΕžQ">l1 F5a6m3Dp92Cse`PНP5ጣ',PS9WG4>cv 1DZ>KP'`r"|CvTS=.17q|qr-Ûa1".#f{a ШFpkyb∡˫@%f܋90lRra_ s@?Z`hV`05ًe;ϠxRld&k @/߰CjbwŒL˦c@,j+j)>?*Ec䛀@5Kg=*a<pE,=[c!H/q,tCwg? G1`cfݵGmE&/D"# cH@ttcTh!N({"磨Li_g%b޴ <"7o"r~0`, O$-z\7?KA>n'bMmcǰea8e5zLOd2.]̢JXۆkމ0?XS*FfX,^!7.aQ[T'ao ƍS&`6Z.aq`#O+w^CIv,?'+ 9S(kbf̙K  q8c`7+7'Ü%`k aQCc=1=&aH5 1~,[Y=y&MOb^%BEeAeM*n \T|BB]I:{Amk衷+ahE=S8Fb P'h!!!!h!N(cBBBBBw#BBPF:ϟLBR֫wot={s7ٳ7zt҇+@&FY-U^K#u%ضv2[\r}U) K+3mGiU3`hlɬVnj,"T_3cI6c6 3HI(ɔq}) ͮ$V08|o]K#/z#'AZv>].bU!13f&ODj\c8˨k6q ?IFy{qu:͖WQw3?#,GC]\YEʽڤziht754 0plD'e!>"ǎjlϝXUWԱ}:LN""!V&[1f|DE ~w/ DXI?epb(ٗ|x gltgmp] =ܺawN8|0#^ݿ+節8Ę>aн5Kp^7[^cC00s:̛20 fF;jb<>Ί%suܵL¶˵|]TF42;#״`R|0rJS[{AOs N͗qXշ}h̙3yzSq8eipNY:F+ vIl}Z,V'ؾe N^CcM& vX PQ@oiG=K샭ai&C iʆchoɓ` طz6 ӐͫaȡirLd/nJpOi ffLz9>n*-^[Ftee= ǂiMρ "*/؃h_f`((oI-+k;w.,^}Ku4&)|XZE1Kf0h4MiKs& -&,qŸxx3,ؾ'pEdU82rQQ]NB: 4/իp5X[[afqcl޴n50oR7`llmcƜ%8N bf/ތ)#2^=kڸmae1~$_ϖ=0۷Gᠱ)/]CIK,7˖Ʈ}g~̞4Zk; A^ng4 >;BNg`s 6K1aďغk W_:k:AISM ƏcęH/~V+glu -0{N:Lj 3'.Ίc#6 gOprLŮm0r"DIU8h `ۼIX@zđ69;_U@9~7.bڜصa)YiS&{@ &gak}F@rB]+'1iܼ趪Tٱd vAqpN;CG`߁1|4bҒ0mF]^a#↰?wg/sXk-c?WlĆ0i2/it订$]XĆ ]P"9GAş@ uJ l<{O{YYˠ(}J ~b&GmCLt,r2ҐW\/ͣ^oH4hSbYzvLAYU2SPrѱhlCd?}P¶%7=,϶e%yYLT >!W=ٲ6aѨ8/H _`0bP[>@d2Ie'.6 %ǣ}A> @?o*̬L`T3>^CIyg$ŢYҾ'$t/-$ u7S8BBBBB/BBPF:@@ ݍ@ uB n?][}^` {vvuBB=u'&&up/]CHx"Ãqu~Ą8>9>!Q+o椤GɏQQQZ倀5k|ڵk֭'_aÆbvofUVhѢ$$$It}e04*?/ FFfͅ+#Рe1v/ZzjjjBzz:LLL}W\+VVfիW= ݍىMf';6}M6ɛ7o+[tжzm;;wlw&޽M644lm޽{l[T[6w޵ywkȑ#d##;6>bqcǭ u nI]o G=,̭vMcNDEbp5ks{9W4"4tQVTLb:-ZqҴ5yB O6O8ER]O4{m;)wS1.*K[$$&!'5;l,+WӗޑN...)4fO%Fo~~JIiNe?Kg-5V~F?̳J/T2}ϑo:)AUVR?p/߿Z~wuW?4G_m^~M6fʟZR r?ʯkW^PY5)*'OR7_K)m[Y'-T~OTs!7ޕ:YJ2Ͽ)4uo{~8Ae~)cУ**8a(FPp‘d'ǣwzFSAAA?у)>Xֵ en b=ЬA6@$`Y2<9<jfsh~B@S $8?kɏ˩bfˀeNJeP֘˿!Vq3+y-@֘A>+)S,/lcu$ +S`s:`Xd9+in)Uq3 717b>H&VR}p+,e&}Ib5f@^KTvKS üNa9˩>0&@@eK 0 ei3*yYC.WA[JY.dP%@wrS^)+o.WgOb~ʫho`ccC:7l0\(GUC ?kQr^}tҵtAYl}LVY TVfT28Kna&(RZ*5~M VJ[ &Pe]x~9LL-ónDW#Ͱ,GЬj UR r^e'(emHV,*6-` Y]D65@s8V3S^ u3j+Jy~xJK22fX~ٱcGQB VՙprruWE8ftk5+0y\=uAy,nr96c쏃˒g,\w[X 6-bX> V.v1\ɓ1f80>g,Ͱ} 31O,߸1`T]]E$zd0SdxV*XֵϿ Э>`1EDT 賖Ug%UL3dȚTfu2-LS GkRYy5 Xh 4kY)2Hh JZgf ЊZoJfVR58-z: ӒN@s383+KЬi3fz4+f@L)֎:KfM^̺;h)́uN,MոUU,GT U jˑfЬYgU%kp0h @<ݚ56a5@+Z'ܮ~ktЬYǬM7ܢ.(+&R5R`UR*n\5TikQꔾZ-Kgĉt LEN`aai[r`ضg?FkW`ޒMXx Ė}}/Y)9pp^ߵ }$0AnҸ{ú{0p8͝&Ƽ1tT߷6,Dzu۰kn,]ffؽk'&[c׶?ivWc!ۦ~c58x`?fMǾ`4>BZ4+fЬ=?&;h8dx&X橜Sg <ҷh2VZ*$UZg5N56DKwݐYɓ[<+Z:K6H7Csrsz^,;YoLsl|mWu-w %%%iii Y@rf]ˑۃ4ERYs:ȷh]27+hr+ݺ- Zh >Z,Յh]pn }ni5< QY?<YB+7Mh9afˊ &ʬVєn<˩ jLn Zm-VTcR*[UָZ֩#) P&O Pjdlk"<ՙWR^Փj0LֆgTWGU}TS{XR_NB\w3A-PXD>d2YN6 ͺ- ZgM,yNj=-?j @YD,+jVYk& JZ;͠Xf3gd *pօf³buUe  ە:5<><,_cedWoЬc fv*հܜ0K0-VRYd5DkYe^/C2S:9 aYmphYqij`gŭYf5@K֙heh]Vpp֝Xyֵ&}4cf uBk3s pJn:,啲Ί98k"u5MLPvKWeXfyaXd,RY UZ )ڬW"4m,4T 4K)ͩbf ˼^uu Ku ,khYΫZ[M@S9AP,HJU@& k9mh)%`TXM@kOhZN_2z.t1XYNdI=jk63'[B4Aj DSZhy@KQgiG34賒ׂg5D}&4˩*xV490+y xneXVC>spn>s wP2@Tgi-i [ TqK־mb-oL)fh}&Png%\V YnӅiY<=Cgd Yeђ%n1uvY ͚2^ 4+}Ԡ,Y,lYd:UI l]xyNJX'269 JtYjW5)/kOeXuҶ >PX䄘,55"E% CMY6mUɁ⊼re&ud=n4!skfE@+ Zmτ"jh@+(4eUn<3Vy$5xfPܚ[g5DK.zY0S*E%(+@Y68͡YI5V@Y yź8`֎<Z̚T3eCBKS:t"VZS8EU6T Zu@ u-Y@+,Yg5<7 5g5@+y ˲rWG9XVŚYgeUY͊9"RZ+yzdN`fu*`f~RfzZ1-/i)cJc"RIt7R.gx]voC>}*dgA=+kXF,- $%Fb̔('X\%F{o!8ldUgcƺ9'X_KP@jsZ_z=:f :} tk;E*˿\`^׶ c-`^1cde:/xG~\Ll~bBOj"OP]Xp+yT6]G+'$-.D4)ST;Zoai5.l4uOyvz[GS%kfk|/Ti+T?kYKk_ܫ:C)y^S/bO]V/V.e}tۚ\8*y*eUV;-e_i$_) ̊%n6{h!I i8wv^rM{K6ǷacXp2fϙ?m,zٱ6',3 3y%J3#p W,b9>r8nDrc885X 6izi1bXxEdO1c{lI3lkV@gl~L@ 0k3YfM@ i$}|o݈û7;,Y .`g0vލ!;$C6#%t_ ;c'cNCe:~F8b&/)6ށ ǰj~:MH iVb蠑I;oM{O>NmmS//GX=MSjo~^zI x뭷穞/fYa0G2/i~;gyFs,/R36o03zjB\wb̙Xz5ޣ ~lb~l,=p=zfَF+X{5)  @l[Ee8v6l!0!&lWWL[^G=xTDDGG+Sl;|FgX.n ;aDn,z[{!t)]v-&N;vp޿W^`j1+lݺo… 8t`6a0]ȝ:u ?!FFF߿?W-[Į]Bʕ+-n/qܜC/˗/ǤIsN|?#F9HIIiḸ8ӓ/EP֊4GhCCC 66W^u#͇ cC' }9yy8n|[6Bx2vG/*ݺuGSSGir$x"851oE{O46yS,YVolBz7v>:\8qiY0>e'DbXqtYNnX޴m;| ; 8cŲضH ª˰n!2 JK~ꩧyf'p}Ře Rt֬Yyt駟8XD-CdZ'ѾN?#2<3VM`"Zt7M?Ky:S:jB7ڋ&3wN&X}?-[ai~ 3p^s8zhš0>78f`gtg2jLq #)[fp'ptս߅C\Ypݺu'ONC2zػcشc/N?Y`8fr{H}#@ <۰aLֹ,~4b;L?s@)zGje婍"Ԧ,xHӜg_pH~>J<}NSvϟ{9tc/ i}'+ͅVUڧitkVOkhmpD,kZi1ZH4]rt>;c-Plٶ ֯ƌYuv̘:v=1pؾ-06־z̚CE+c66܍u6aǶM{0h,ۆX zؾy55uϘ1 Ckep,ͅ"R^NL҈:-@w%?mavA3ȼ-Mu # \d9Xw􃒈@?-,,L}{Beggsn[kh %xrEZH#vr~54552Kl\;Y(bh$V;q||<(;~9//(((@`` 222SnWϵkޖ ]\\ƟSAYYY|9Jlb*`V YDnᙊkKN>!c<&n)Lduӱj<'6))o@,vr u>l~ (<HG5bڃO&6zA9SƌAbȐ!IYttzП'-슾@f1,N8~#C ؇J';.@$"xDe5" PPTA3G[cx=z{ld1o5Ø)&s]y" 솳SsS8xT(M"8fN{t\z~ zi1y` x )O1A15E84ƠoܸA<44&h485M Qoqs38Ku ߓ,dv`B@7 sjS"߫VBD>p/)JW_a| 7fWskkr[LЬ^1͏W/,,`Lj% Pg;WׯBVYv3Xb"vrk3@iI=0vxV;8qz뻊@?;@kGiƄKqɸz# +ƌ9 g-1p@Y&Ջh:q&PH3A6Ew؁ѣGSNm1fWp{"JtYơ} @Ff>rO(M  7a⟰vf °g>4hC}0^}b0 ,^-@C^ݮ[(Xpg3=ʚj7O"҈ ;wy~Ϟ=<5aL´Ip 3'O%+q%֮ڀpz29%70=zaulYCut5њO.|k lVDeic~ӇX3W,-K~-KeR=Q4_Vmkڤ,2_.*efyەƔzO>_o{?W~?.c}"&>|zQ*W3WDoߙ}-f|<y)˵; u&5t3 k>+u3;}ރnC^~+MK~?-_o ڛx2iJWիSvM|;>+|W􋯙)eVgTfTVK˫?il}nmǟ~ѧ>fG??}_|?Ri}^:6 _@PL\Ӟjyqh;SC.$ȥ[LÄIS9:Z u)3h)i1}^sV2klw߿yEW=_?;??N./ybǎ]Nٺuw]VڲTnko۶[nc'Xv=-[`/__/ʟo?|i}'G vL<7<Մ=[1k@Ee!͚Ap` څi3fNhK1c 8[,y Gk D6m:.~jB|5r:gGzN=G}^:4s5f%XDwGeEkv70'ӛg,+rݺcϞ04ӥMq׮XfF?χ|=xdwd(^:9& NWpE=%ȱ1o47WNcx 0uN\9}BqA:n߉{6qXafg .`8lo`8u AQI!O 95z b]67vS+'GcZt{XPgS)jpnơpشgG̾3>u!k|0Mxw/~P;wή썛ͮ>7=G1zOc"gܑқ5aa~k莵p{ ` }az Xq7\ѡ}9|(yql\mmlz@cc#057k|HK`g 7 5=SC`BBBM"lfpVڔ|hZ>yZʆG7OV/vvaƌYz!"_݇Ͱ  {R}|HܬkS d gc:in< qE~8~$n=3)cѦظh2 &O||1A;&ISq8*a 5I>/kBBBMVV2@[کaZSv*^z3'^ s_|~[}^|LS;-^#|G:qG=X+@wF]0kRj2U߹56IƆz^4RY'ݻcÆ 8z(;%n6}}nW[n>թ}|˗/#)) W\koT%Zo~~>ZKh|PP\+ꃃ% u64kC > ;@YeGc;|:/성Y/`>*cL=>*t?9s&(_X2{n;h+++$''&{{{\~p|||xv1222pU9raaa|\[[[T???NNN3E'Zoiiݻw>"uq~|=$Z))sssyyy1+**PRRP^x.>77 = sS?N @k,k<(;M7?o~V/>l;L2 Q!yxbmXz(X̃hS}~w õk 444̙3z EP)rM`IL@Ms٢">.Ec߿/C@J)M¡G8v iii<"mbbV}@$`߷oVF$Ѻh5uкu߸qFFFSYHHrb(ڝ6v_<=?7ʟ M?>O`5Pn߾'L`vqfݧ/c/A\ ~0ݾzɁ D)+N:ţ@IJS*)KQ_o>S"$BRt`URhJ LK෪C3Ax\\fl/A3EϟϧsPYAK.ɽӧytq߻w//(NOyf MwP[>c) Ͽ? Yt3z(cZ/>~fz Lm~ hx`Ԩ1FMwt? |Z M DUY1nV!>:UO L ppy @]WS܂ק¬d'ɥuO Is!G)ٕ"UkJLSV[[ե1B > 1H,EoGS(δi2RDI8e:mD}h&mq)zgx֫nzm}ک@BBB&hApJi@?K~ᥦO 0ch@rx|{򷼣(???_=vu?mav'ahr ѿhWK'{ ,,&1& ϝ\lq װn~f){ 6ug&"$&6غZʉm{;@gFl v(IC@CA,@jIyyy裈bii)^$J( -Osz x3AE3@w*?(/҄Kpb4sρ~ɧ_p|ۧB+E~~^xA營Wk6i*d@LJ{?>D9kdE8fg@?qՌ mlk b2oEk%DW|Y2`=q߄)sdƇ3f 7afe ˓~nHZ}Wk@ӴKScccaccçPHh+FO?+ni@w9%%8!#۝M @ N-Z7.0#pժi,ⷸoo \Z 6/]ϾK_=h H@38HF ^1*r+yW:6_h4|(΄([!Vvn 5 O# /Wؚ9k[ħ;Msdi.ytWa5@S)rM?S!4sa-$$UeX&;tP^np~慗>SU|wxo3|o[w+Lnݺ k֮ö;껲mہ|y7<{~$䣡_mC i.fyIj b6fc#f䢪#QYSh&u (gU()+G>h@Nf MMGtm6&)!&p&HF.h*2D>+++sn tmvl4~ۧQ"TO}:@ uI-Oِ!Yl?v맟 /5~E7WgW  ݋O%>doy~ޓlWw\d߰XͼS#"as.7Y-" ?ݩ):M@N;@ uYYPX6k@YS%gO>¢*s/Ϯ㟳2`x}osG~кwpE¶eeȝYp 5,{E?3/b)])ֽ#}>h0/Kk|5k_YI@/^X^Z{ngBBBMJ<Ͼ{ϙϏ>ǯ|MM#mn [k@7ot̮k_YI!CzD~M]yAv-$$tъyBU g_cO>}?> ~A!/8zy< oNxw{rʬ.~G~za2I??z/'ٕ 2 ?4_~:tlz?OwbBBBMVujfRC>_|/H&g2^kx?Ob9"1u ^;v O.#Я6{AZm3g>ҝk,--y5;sG/wʕ+8r0uTL2ƍ={6K8pG 0~x>P]L5*h"j믱pB/15z(iAG-XӧO۾}0sLkMMMyj?\јUO{;4Szڴiop)tlq3XhD_/!У*1c<܅Cfi.0C@@?sgx/)&ӟ AfWG_~Mt[N2=Մ=DFQ%B]k5prh֫ q Av^#<:)HCqE5J ]+5ĥJs\SqK*yqv_T`h/1~!{g6]Z9 ?i۶m%&p&QD4G*Mjj'222pC?\iÇ3Stu-џiJEi0]@~P ~C8/#O._@Ӵ"4;4Ճ~H72Bc =|sehZi6u ~r~hf:v@WhNfpE׾z8~&ۖ` 7s `x$& t3,XSFdxZVΆKd*}| kDT=SsvZGrnZuk`,]"j֬W[4Vߝ>6@3Nlmѷoo'b#o)3RFHZ߆TKtYݶ~ʟ? @Otl9.A!Z6v (c4ٻ Kq9\]zW^Bno}Kq޽[-MܹSEE`]FSشii~[\ bxu⾮>}:}vuL'][}m&בQkoǎ;s")۶Uا[]o2rlG\V^' z~ܚkiliXÇ$^m8wʏis+"ڊ#;VG/:c Nۉ‘8oo"u)ޢۧߎ;'}ylNަ9}u6>GitXtc"Gږ{vø8=GrB>qkܼr/í+dΟ: о8w߭tn<gOigqN8ǩ{p^Ͷ'%EtA/9,Y{ԑ}=̱8k/җmؿ[>灝Rw/?WON壸u6-mֹqY_9;}I2l"A߶DڈXJ:wK?=c Ft 0I WJ0+6A\`$¼1ر-쉥Sbui'lY[RbNؼwnغ,Jؐ Syau9 e5p>]Lhਯ\jy_R90Slȑk9 r~-r|䲭򴤩5eol*g;:C@aѫwQg>=j!"%+dL"y<?~EB| Gç#p#{a}W^ux|log+յ;:q^<#.WPMֵSںûqh^9Yqewځ+ ,ϢkdCŁ2dZd1ԶkH[h:tf͚Mz_eLvssSK>rB;;;&&& n y²>Ds_WN&hѢ \l9M`&E@a*@ :xh-Wr hE>>);tqGΰwTKU(o(2Ҋ}5KԮ] xk +*obl9ٲ eM'ΑKը-jR2-ΉI:#0k4߿ܼk1lx\9fsi̟:AJ ̞2)a98/-s5 ݏFȹ+6m[ٳ3z֤#1ahl߼ nq3L8;4vv.r-:U,Pż<*FmGcwtn扆[G ij VjCYW1Dn=gc{UAXO_ {"]SYYC϶h_Ю:Z ̣2Z4Xi7:J^ -"ءCtM]1}X+,:aXXt~N1:b̟S[B F@V}LJj)o=H~uےv?,Q-:nA2~ :*_/ZF,$hZֻ%&~A>71m>vƚذk'f-XqCՓXr7]܍pFS7i2޾scڤ!غ{/:a}8]س; pD)7L?U3&`8y.v_z]|\k/ƍp%MRҕ۴F m48  2q4Mz_޻wNi?r_rZQM$ ̜[bE%Rm"4dOZ :6EpֵAh=h8oB9 hh3~,}w~b&@& {cGa8r0N<)/)d'6_Dw-hɧk< *(7Ƶ m}u@].: tzt#b^6%J(A5fe17FwuDUD?&ªZ~̏RKNa.&=Cj #ʶ4XT l J]Z xJw~!] yV$b(VTJ e]@ / Qp 쇈^gἨMݪU%g@˦I33D7g:jU{5 .V1[;)whr.6(S(?,*% Ga"匦 vm6 ;W * {լz,_e`cP %~>aT$,vQ)~ P:E!ejtk ڛ!2>aU4D&HFo}q-pi4N8SޚA ) ӞD6@s_3gά{f…Imr7(2kT9?̙S=<ύNϴχ)h>`}}}?)B6m4̝;O3g?NH"wwn5~Az+߮wlߍ]æq^t q =/4zƀ)kq:r={ ׯ_@ӵRnhUgZQt4Cp2ͥyΝ;#00P}m!q$99YljaӧqUnOY: 2Lq/_iUK0ο#qLM4,o^!̱/-L }|/}tPc#y @=K&ܖ=ADټq3֭YQÆc꤉Xb9֭^I]Æ HUhih HA]O} .adV GaQ**ߴ *UO&J ȧ ˝WAtBŰS**!p,YT I".a[@˧,Қv:-0'  @07DWxZGUim҅]!ܠPq̋PV8 71w]MQ4O(S0?*Uh\ E'cbҰ%/ o)$l 6h[nFPU>I͗uaVCԂ@i4(-;rB|M|Q`ntئ^6.!u0qX8 e Fkq'o« ⚸ Pwă;wƻӂzat7ZO=W{W_}>Ut 倩'R>䱿SH7?3|r|p 魸8<~X{˽p7o@e#mnE3sD!Ńw?}fӀBmoo={`x}[==ĭ[7PJ4- Kz_>p/'-O#8sM uX'5u,m͗"0M`g3E>mhl.קUL/־ ,Y*_ŢW((r Є^Kv>9Ф:xUZ tuؖ)cGZ1reZx6O෭⬵>Ӻ`Ō>ㄦ\&2,P:(/YH~:mx-kh Fp2B<ߣ^5[++v"P]% -ɸ8L$iyTs0Cvߺ =j޴eָ88ݻwW B3  h"|)DGܛ)S Н;'(<<'hs_ KfWxNjWOa>Ѝme200GƑS_ĺ:u/@޾ضm+؋H^Z݊ Mпw^]wVCܾ.gOcXl ߋTY Я ert1})%VpKKkX GyEWTUuD (,<(&F5vG{g4tE=«١Qu{D;c㚕]bjWBhzU0'T$ocA@XD  »BQXh%<ϣq!;G#Oԭ7zkou+Ynh\FwAV 愠Uifu6!UQ5 )GBgeDyWEhJ);`PXh};Ei "䳅֯:W5-}0W ؁;)fii#[a֘89ǶƊ]1O8h0ehcLF`UrfƸqxwJh8k!9L*7H_ tպ>6I.g>8sip`@H4ZXte>$i[WO ]8g 0cK+A^N0"r}]*W '>}(hE4-Ν LwQy7@p0t@L3CzĈL(zԩSq2O &_A0-:M"_281Svhֹ M# B5c|?82""!,w\#LL-`jn-}}%$uK¡Z {07@m9|^ʮ^ukAv1r0DF)h>yΟ;Gɻg̎P]@."օ4fkK;4x6Pe4g:-AgEQTThZR-Ln!e3E}[3Գ6)Xj!L jY##"@ZYb0G m"0+TEZ/lkJ' 4s7ƘX LӨH1.Ea,ۦEØ*"eR"Ԥr @o K&uAe2S+[R^r*Fy7__!)Z4tE5ȝ-:璘/w.!7}Dͪ Ja2,rEP ՝LaeTKAɂ0*]DiT,Y@<ɺ,7능lm!})2QOp0-v@L#_4 @ƾha}bDz!ؽ|kc~طf :b[ݛa^f?mhW6kFȞ=hs-9:wV U'io_=.];@_;{'Oì)T}3gҎϹf|̙7;Fзa/Oǯ4gClsEe2MiAK4ted-ᙢ;h_iA>E89t(ZsZ ߴϚ5룁~mZM%QJ^DͬUh!w5e"r%[[v6\2}ԵEO®;{ qY,HGG9ZMֶW^.6VpuqKhB6NIA eP"1բ˕PEu1 j ,ӅOW N&d4? * 6tzEkk .hZRE0*ib @MWMTGH (+p6pT92ILِ)s&d dl2VlRDfc~VQ6Üvf93f"ɾ(羉qiԫ툜IÆ`B8'?E}z iyřpĉʅ3ge˖JuUZM6ULqe_ L@9uY)"NZ lM6\znhxAn&'RA,lތvԱ#oh0XEި 0kd`K u+YL,UuŚUj:\tAuTqr\Nε*;J-lBt\dTY -+߬< BQb(wu)J,lv.(0 "4Yĉ{22Bt(!ï Ǹ8o hIC12#b|e/55@قE1)>LD5`!/mdM~C_rPsCќTHx7(M̋xIX2| fJ믐d;@jԬb[blhn! )SvӬj p|ʟd9x\?{Uq/Iuʕ.\v3i! +8}a.ejgl[DgtŃwƻ]8߬dRzT. u}!hD%gI>,>WݼyS'C9c @-QNNE>+~f&M?UK`erV/k^biزj)N\{bزwYӱl>̘D!#1izW;oDlزQ=opL g`Ahm(^ᥜ@jp7@|g3'q!_8Ij\$cj[1}t̺ьp¹0 J n2!-#ӒLևIl_~5QVi~GGNCJ&VG;zn:"<(ͣcUk }{je%ol HyC%?4/^eǏO3ZBtjS (r2/#@-mka1IW@2eQHq_LrpWa*V@.0 U*Dڕɸ%RĖ1c*9>nŖ'1mXlڼg/_a#p~L;su3'`/pe$O] e9xRf`ߎUXn// <-ųG.i2qD 4aMP{'_`̿?'A3M *HiF+ :N96ĉt+a4'yM8!c#'rX&F ŋrs8sV-[ƍQ[M4 \VCN+ʏu07DScG{'`YFA'N<Ç޾3uA={yvZj@(q @.fekRQ4_bw3#$z \T:ʕ TuЬb?@WOWv gbe vF%5@o%K'\ץ޽ӗo腛vΟSH|6}Uνunuޜ\CPZmq}?C7nP \ΛV:Wa?^«W/q )^< ^ߞybыc|;~{׮xpɹ>7o+_> O?q#&Wx@qc<~w|=?-/@Vdp\c:N"ϟ7\@K3CB ˿7pjh}'Rsw9$۷Ν[0,4S8u(N?Cc׎m#&%b iS9KD3 *j&V 1:oZ5*vU {AݳgϜ$ +Wv-lQũu sr/ژ{ 6΂>e(;N#@G:_@ \nKڣ Ղi rQZSeDn@f P@z7~n%8]\ʣ@svyptqi/_\-2XiHT GVAZH (7[*Raxc:/W&PIn)lQI$ֲX4+1R< M2KHkM}Wh큡]1,1_e!nҬ\.2-:vX7{(UT^D~΍ordC̙+Kf|=;rʾT4 Tl?{Մ*]r鷯/+K#F@xlKƶÚi 0+Ǵ’qmxlkܹvV{g;ZAVAZߍCu}LuCu ᚖxL.u]z+_Ʋ}q-l=~ Wnob8u閂ds9g؅[u:yƎװS3/T7?h7nV ZC3cΟ>ϫ#rZXNO,םǶ؎}~{:M"D{x+fX5Hw} ?Id9 kR@3z_XFQc _9p+իb =Zg:FӠ3#k0qr3Ѵ8jghNd2֣eDB4,}Cj6p Uiؿ#ڹ A yqXѣGԋCUBzʭ6 +kAUh&֪Q K-9}sfFׄ\ESu6V6psG}O)xl߫D#YltCP<\@QhG̍]ٲ4Y?'Kl-p'#XEe94w5بژ 5**_fG)w 19c@]TʢZbYcG: t 8"ۛGe]% C!)u>&適гYVj`c∱@%@oۄ )di(A֌Y3μLj-uNZ5PumPPUrYx}DVC`JplzΖ5鄦AdCvRZaoREu*] Ǻm1wLhyct\Kn;IE ]-XN ~[(w?{n/i_"@O| v] 'cږX_h (Yxep[]byKlՁ+ 0Q?nqCB4-! 3- |@qMvN<"@E+dJ>{,vڅ3g(?EN:Ī|ʣB\,_Qe"7g Y^8o!qyq3AϠj㎱n~UQ6EӽC9h#w˕DG7 @; gr(_k+T\Q<_a-{k\RUy.;!ѳ:Ԯ@[sX( ?hdgN"6/8$MJ/XR.@3 X8i_ Ẅ ka*Z7 }bv\3FFS_4C=E1>C#B;(_YY9Nk`1*K΋KF~8bc+KGCb~8fӬtWIMX~?I.D ~]=}؝Y.`k/7-: 뷱U>uCY ,9tEY gFK}i|}D=u8A6:Ig>!hB;ӵA@%Psts y>A.M`n٪ Zɀ5Z2,В2$ZćC@:z_ :zA1TH2 8U^T4 1>: ߙLefZH ,#Db:ʄI Slj\Nc<4-ִh3XmvS[adh ?_lݸׯ#řS'px~tMX hS] V^ ƍ6={qdܪՄѿO?l<y>;{eoXYkFUgWtfDe''[4 奥R\O/,@Q.2;*2feނy>*TrLc"RX(+,q 'c=9F$?囬Αk\[V`LVנvpQIZe&ʝ_/RH>X7ʽ+x.('}8ЛwŚ9PhfeQSI}B<% ^s(]8BF5i6U+ c&U\Fvdi 9hnHaؓ®ŽUSXo(.:P~˼N-$Z@0t4}7Dw Aӵe5/^|y,-׋*?r출sx>_%.Y:q閪v;> Z ú Zum2p}Jst@2a6s[R]-[]*X) t@gJo4]ȯi)/@%̉{c+a,={L .B%P3r6x.hZ dȅQtiZ>ӝi&tgϙ3GY'OgZC'c]`_Y34076^u+b߮qN h" ǷΛq] _^롉3z LCz:Nk ڲbi4UhNDؿ)1sH3,KƶB)5‘qi@C]Q>-ΈjD6ZķJ!]4]8Qt@%DZ{_?Å4hܹbykХbРA f###ѦMe#dt044T+ ɴ,sm3teۄhyg^n!r"'Y:۶~[+x66@\t,,_Vc8|p.;[Aj[lZ[="ECN2a"&Nd,VvJCEȩ*lo?v͛a ǀ~(m D:PvthN" 4+eJ HϨ$ íL1%%ҢL%ϝE~̇* BaV(C.]e (;utVh.b\q1.*pΉiޢR֠|`P˱_a[rZӺ- ʹ> t߈t$k|]Wg%KCYrvd Ԫǵ]Thpʙu:ZȋGݪVhMhjB1 #oL1, 5%ۻ-9rdFN9(K'Cv?DV?@t'8: Мƾ3tw3}-?&ZzӂOY:&07o2Uhtyt fߗ+e'jПFpy|`)^A 4EhF7nXEqZƎ)Z yQhwttT<3^;}yn5`یp F4sBǴ@Ӆ012C8L5{n&;w#ejx6=GaA9\]ݰXx5‹.;b1Rrʄc1yxk ǍSM7˖ӠnnjC5Q;CЧ{"{`؀>o~.rV%Jµ|i24) ?RmPEs<5.6E vYmM* _\?\_, BQ?0J/ Ӣ*ePKh,[ ʕKE%O˳2ZQqUpB`*/teF9e_8,J!JH"eQіTcX>i|Ӏ7vCce*Vۜx*Ocngfnn&~U^Tض`R\h% ]NlW9!ܫaȦX<-fkEcZcΐXSFoV,,-s*@WHo.h; ꤳ@Mnn:n<qZ(qn`jtON д@s" c/iRmL_B-LNNVViƈfzN{p!'!r!!@}ڇH77СCU$ЊMׁN n3ske%Keg]ؼ~m܈k`/k$ ]/Μ t9eM-QKg]"Lw떨,αqSE^?R@y} !pSEb)hnDMH,mFưQV]X\Põr8r 9V%l,RBCM+>[P76wpw@-yJ/7F|mg=WNƕSqQ~Nt$n\8[%+gqVWε w"_7.xp2޺*ɩ5˗!WvL\:jZ;2Mqm&gwƻ@(O Wҟj(thZƌ~&\j֯_?,Vb \/?|:-~|fe1qu/2P˟颡LotݔY:u롢 J7hIqқz;!d .Teٟ˸âs2wƍCbq\زDߞ=(kmn &J8W(-0(F}zZUWxyz FF0|(NLD&H3I]gRwVpR~ '0^WY۠ym=uxI|H/c4/?1וk "RW0[jOJۄ\zξijSmmj_+ݶ_&۬úko?MqtVl3ZP76ṛ>Mt(w$Y.ǥ~>VES/;4򲆋}yhfő&_ 6-Kq ݰivWX[†ْu3~Vwƻ~7LS tZ0}81pHI1qڵj|ꕪOgsf8K.)+%ER #1J>tӟK7eqi 1"%˛DpyoW@=\+>whFb?seaRZNMqA5f`\56iDo4-ʴ2Aẅt̹FoE߄`Lg4-|)bۄu֩5Igt4p̧ռ1h[J0Q/My"%V,+"@=xP,JY-abd&V(廍a ]Qdh`YӧaĠھ9#th D`ֶF 7n(cOhբ9&Y]UcBCP l~joPn+5 HI9|Ku+v*D+ؖv‹mX5VbcNx&XMk2Z2adUPrόqM?cG>Ȅfp,7o'NzrlNdB0WCW6Fha޸qm4XWNuˇ,5]ߗo>/>@o{;'Uqm{;)sgo.H3Mm[1}4Uh֬D//QVMQ +i+/~ 6&DQƎ% 0/kF8ʋ%LM,PRewAUgh gڨbbg]2n=R`O-j*coJ`8 ƚm˗ߵLAב#]}#ƭb٣E {h߮X -t#~*w~ʵRFo]Mp^]?Kzg-_L eu}ǁn|}%mMlcnm#6CF`Rh,w=Ԣ|9N* k2 @gCX4=6i5Z`L=qX?gMD8SZ`Yu>:NW.oj<)4 HK6!1F Ltc3}Aֱ/˞={|˗ߙ/hq*rotN1E5ȭVХ+߸pl_.iX禘N,Nv B0{ `+zUWxy#a,9#&Eq9={@DaՈ+ЮIP*@?{N*wjݪGJ'}FЅMc9}i-Uq Ʉm4YM aRk}v邿Z0#a=NY *Ş]y8 Hg)ig BF K 'lzA} h8g H;qǎp*m[FҮ- G`XzZK~aw0hʕa!880HM\|9=HhdWLrU]\ѯO?xM7Q!}ÂY^su,]܉514'~pSM[=(+ k ~W"{8pUA7Ubqlfw||Uт3VZKKZ~& h^L=7 f @:Z \X=w4@9~&B?(wt{% sGݱQ>=ZJ=t:3JYYɕrfˌFX0 v.I/saŬX<R7ǦIX8*DV]241O7U/&΍]Md|oNͿtdTj .*}(@sIsO:Ǧt\x(.ziaUpY-۴GUOH< C.0{(4o|1hƚ7zΘo1zd6lՖeR[`9#q}i M/\ gZ τXB+}un4zu/]:If=2sjFXFm-քoZi/uZ"f,h,atU3E>8zZ QG6?"SGTٷg:gde}x~m ++[AeEM8̟Q#+06266[k eH9 Mh[h%$ymp4Mei[fͺշ/="qk(I=fо/Ɓ Щph~玞#6)"  ?@^FG-X@Lx%~Ǵ }~m/Aa ޺RӂMhi&Pi u9:i:Jp~}4޶]@[Y١cԫ@tTFֹ3&[6}rpR8L3'f&u@Eٮ&P^SVjQÆ`|?}z%Eue|K^ɓk,9j WnU]}tF[:nnhk,O^8} 3_EkZՂY+eq&qm?]D긮ھk\B*MB_2:\t"y0 , f* qT#} !inTsFwŔX4!4gD<NMĴA͐뇬 fJ [D ߾g1IDAT_ds2#gLQ@;w_#wΜ:G6|= :4%#/@ mA~Y;b2(6@kQnGD!QZFVCХ*eD߸pwr4nO<_峸7<[`(I{(0?n^z[q1 OW:}_TǏMߌ;V_ĉa&?|2?/@傾ĄhZ9鏾t y'g sB ]4(2lVl,׬G7ޛ'8ӝYF7B i?$9/9GDh)T*N%ch5jI྽Rp'Š#9r֖v XVT0s>/ezC%5 EϤ$ܾ Gջ7< !D9;VjŠI9qڶFo`[#5郫Z~ '>{v6[RR5 mmhhAT3@-e7v wP^X5/zu X)֫geGp= wNn U}B:gZ9k]8$)@M(%r`< @7Qٲj"mPqT#}nUliu :kptih光[sSd52s+xy'@ͨY2eFbf.U}\fp/lo_NotY~kAtVk__ʦDПshBam( #Sj @* tb`& J_eПbzg MfZu9 M?c0PM7 N&d- u'1/ͺeuLX&2n4pǙNk4uMk4szi՚oZ tk A}`5' N жf07rTź5kkN,_ 6RG&*|&1-lanbÊF060= 0ʶ,MѴq46[! ClQ Ν9+W.aUpwP0Puv@aʨ8{Oڧ@#޽ԇ P ׏mǦp`A50) ±r@9b炉:o OYwsahZ)-L9UQ}fHLnuP ζg7 Sݬhgţ82Gv X55t w,lq51*&6H=plT<{Iys2cѢ@o3Y2 rܓʎ9ma\*_O($j Y% @*\\ V4Z#WgfrM*G:G|e6#zR"s6M8^kڴl1tjʩ%B4 85E kaմܻqV{g;e&4Yөet c@g04"UaZfmn.sS)?NK淕uiOVaVЍn:7 1#dpф(ZYN\ptի )GJ:?i6քp^~,p *Z9f GG'TJN u!467D'gU~eGԫŋąѰ:wV1.董$*S&ӧpeNApSJ⌰@ڻ W.3'1O^R m_OehZ|_0sɃ8{ -&a^>.HNukۢ] kuBg8l> G-±_`qX9/֎U07:7/aCR4wBhej澵}p3?{g1;ďs{:b#TȐX֯-Zoc8y _uLe~+6f \AZ^[6SVh]+ŪYX574 FN,4Ar,%8S;ƅƴ@ :ɾb8؛Yeʔ9Ze.L6GV)"S.i`߲*2EѲ?rH?5,9ћ㷦jy?5^up6D!Ms[;ռXhi@f$BLq[c}|8KC#*4ܴ>-)?Nz7@k4F It:xhSS eJ. <rN$E0V0cx: Xpd\n@4 [jZ2:C̉Xd1֬Zesʨ\JP~8skW/aެthkC|zK %YsPҺm]G~8v.O܁IѢ{S}gĻtvR]9s-T2EEsHH FhU%nJc53 rHyk`Z &T_ pNaB?^WOkXYѵ!䳼zy)x5>~>6Zb쑨de&,Ev#MƽO뒻c>4~:bB`cR]Ȍp.]|)4 u-Ax%@n٧uWK|Y-2ÒVgkSm3ԧ2K=Sk,坚b6gNX߆dik6t1'.:,,!!z CUZΰ@3}~.K43Ξ8i<ޝ۱u{!:nh>,:__~RyKmɿ;хCVvvqZZ ZJllQfNtqs5x@2.A~~ @Dh]V wƍС}aca'Aȁ8wM~畖,@U''898 [}U8S:.Ǫe킧wՓx@]eOQ t@5 H'D^<{[Nb9=jmu+V6FcT|Bc0$4k2<k KMy kv1+msۖzY}>/.K.CA:X7}: @3 ~X=g(j;+`&`Ձ~9@`ٌ.X5+뇔]aZrfxBq6y^Vdt.0*_ ٲnBf,r \gp%KКL58s@B'zCr]pZ`Pj;+ϗ㬣rBps/05sHy.;4@ʸqxwz$B}ևi*,5=Csٹ > -[!T:(a]VS>.Yp0c'NǍз_?vWs#L1ykp,_ >Sl*ܗϻijl{®[pe޼LŖ9PZ)?/@/:p ǯ=ŻOqox쥼͇ͧz]*u?ad?9Z~^>hPj֨n5PZux~THcMZ&f051WVh S8آIDCbϮ]ʚIΕAo̟+ǛjUPY@tz8ow8w.Znlu/_˧5zFk40 w|TJ/19$4~g0{4;SB#OtFp4򃟳3Lо2/Q8Կ1Nk3ү{=mB|P@\9D6 ѕLqxVkǠq(_ 5ʗ,RE0OK,A :k F^pv?}O~DEPܘ3U5ydU EakZft>T,UJU0*YyalXE E|"ҿ:&ⅾE?GU,fY=ۄ=l-Mage@hb:cP \ ^mxԮUl`kÒak/O"&8nmfKs} G@mnB:4A!"D 2:5}V%v1ae8m@?F1nHl9pR- yNfؾz1v>E&aϾ=ƌoҿ@r@9~swġ+#sw^᫏p#l~r/W EnK.zA @&@j_ 5M6.ynGZ ǀfQb0)> 5іxgZ[r5(²4PJA2Wyx즔K\ RMƎ&[fV, 1}0WG IJitZznP !r#c4TdZǕic, d0 @rA\Y3\8T'0Dy(]O^&@.ñKzQ6'ϞÅ'0a\<Ϻ}U s`R_ُ9vyմRzX5Nn6q%BT㶗ՊuQQQj%;%ey+},=9g`Ԇ+~z͔,|s݇>w¯H ǯ?ދrɹgl7hӶ!lk[;Tx%,M Ԯ K eY61@3:n] WQ:]:9*HjeSbلR?&&/|Ӕ@uq.Y*|ESpϝ;/_ ęo/S>Z`ߴ9?t\35|4Fegt.}VhI g3/ڿqz=ಀe+pe> ˗˪Uظ}?ݹsWx`ڵtC h=Hpj7:v߳sͅ(ZhVFLLZj? x pp9o. <ζBBBL`m2DW39 FP1a85䱚5k[ n?UGrC>vWwx&4[P&x}cX{:ڃq3 g켮93H۷2I5lmTY.^fʢ ZѮjTg)72M7S.pJ*E-PXPB"@/kgZHZ/_&S1劅vpwTVB m .}q^<ŝxqi|pVZ`WG\f[TtЕFhM`jeX1< ;@hMW$Fcbۥ5\E8: µ2F`y ~,ո[(>a^)PH&,S E<;OOw{X37b $5! YUD *gN˖%'ˑ 5Ǽߠ rȂ9F./H%[f}@B?X CߎqYQ5J-W$Oh^ϴYC Y@g,U)b}ХyYMZ/XϣzEa.2-rSzQg˄ aX: Li#aa4\! .NW!o.0t`x"=q'&q+e=eթ1-L ϥe_$ɠ\銋,\Pcb5.u떊S2BÇ6 0>~Ո]J]@e@gkwAڵk\oM6۷/J1OZ 6҄bZy$&&VddbӳgOծ?;E=X T`[ G.I$4 UW;*xB<xpum*%+թ jɹ͜dheI*KelW8;;#?UG.8r|<=aT;oޭlmif Km/w>@X"f%Z=g pis2.o^˛uڿ%W.U9~}k mKƵRi.)dپ@rvע LõBYtAH@h.Y!8-]Jxqmn<, /;<;!|r(ώW=Kd"<ڹw'Ѯ<ܳEc8} VO!IPc FnvV%0_GdPΔ9JsfEtthAVk3 Ӫ۰>6.Jș%&4ڣ6-= ]9 1(_}ΔC-lJ"Ao(qo@,gvwƻ΅rՅC -&1/%iۃ˽P1F9ur3fPMf7iD} πIʇ>} e_d ~<ƅ,蘘Xe}Cd~  8}- mi 4 rL%+慆*y:}6-ά߹sg0 S0}Ot s^k^Q֘ߧ#fhy#o,tÆ;Žq}|_;& q``9ġYCptpJ&ʩ9c\+kd05m>51ezC(]kî\԰%{DWwBU5=J/}d\<>DOv'dCxw ^_ƃ-`,8EOǭ_ٸa&l&Go/`̡0(UDAf"PqFHΒ YfWfK"ث ;YYy IL O'et(](?V)Ú 1[%@oX&vWho6-OVϱ9QCǣ@[{dXf˦>pef@\shܹ>ЯEX~mV⶞}hM_|9sJ|RceI*U 'GփhAWh@_tnt`9 ׄ[jupzCT鉺uszYX9}u.r7a<@`6zk~z+?,yBub./hxX/yv_"b0׮^]{96/s6Mw ߻cܑQ_d?)=k<&un][bf6ӣ$ Ukv{ahӘ^2vNCph0X0 gUSqi = MУ}toD_xTvB]CRr߷o> !vft~5Gy& 8Mx:n O[f晸m.]Gd<>Wr X֯t-476*r8uc\ |çlHEΚYq0tYB;B6H+ {'iI'ѲD'pQ҄aZ,l6t۠}#iF5y`Nk5'}~ >A"vtjS6wŒw&Z3&]LxoP@= ww'Yy4v1OG,Ku0W{A@3 L3O+w!X=֌H– }Msr9 G)s+bjbĕp$Y5Wą8h$./k&کr.,S)q&e .,)#qzHL^4Z~,.3`NBDٔ훱qZwlKe2P:$+8U%2j\ 2)~ڡAMb'_0t6|u.|}.d) N)˔1Fݿ1˩iLZ`12jۯrYl hAqK})0jW6@VdfϞ]@^>w/Oy+S!Zԟ@Ӆoz5z\ʕMa"sy>]8hܓ>@xzwoxxxVpTYE @׭NIub̘4u=Bbzk&|O]82)?,q$'E~ק/qɋɔ\G>yO'd$&^^yM6?{HhӲl0},vmlhw,kGĪX;6u{z)giľ9e LмQ$eƼA<:5Ԅ8LnMa\F4 Gu`W^ =4 AmK 8+;1d ]8WVLąein&. +/3quL\Y; MĹpR18< á#p$y4fuF#k*/cJ RX9>xKOWb΄_CELKpo@~}Zk`Z3I{3} ;FqVeS M54K)* mZ6:h-љy\dxM(Ύ/:aRi @e{+!Lr~'k`Jϒ+xꓰ@*!Pٳ4COٟK'OGRROxyA7ę@10[:>p$߉3?#zK> z  ğhJ3ft+?,1wpo8y \z"\{LA.ߺpV9v/b`k/CΏ@X ?:Kjx-ֽznJvx=F59z1~9~Wf҅d_ž q^mm49m1 j,b  S޴Dw]zxw,ucbāX'Ƀop9\t-chh78`r9[ar昚)`ΐգvOBP=л}Gtk=w@H}xWq¾pk]yT^4Fy<.+\湸u>X{;ya:vzx=T}bxC7cп5  bsL8E]%ѢOZ`̅~ɭP/ݦFЩ}cĺ10XKq.WR&0.Ǿ2Z5@VdVl~z*W.Y2Y0}DO//r"yTf+/ 9 \gΜS: h3;aT ,+זHO`?+,m#mIXӲO\#>"2 <|?Q4C% Q8\a՚ww=,2,Oz4%kZ2#0&9re1ε~fM"tŸ '=g{,x^9! sEGqfsYyA5cT2 }B2i{N,~U동 yy4j};a^.ѽ=%`a.X* fd,uW.։Sah7{`㤁R~ Ľs5=4jP~Q` xVG'կQRUT6AC)Ѧ5F냁 1:!ubÔ 82Z=>'0}f8mpyT\[=/Y碴q}l阝-M NA1uH{lK##'Tah[:=AcXvٳg fݻwU,W0#rlٲEE@>r0' g> tƗ&814E ?pR15#stX<9lDNL$hSNc cӦMsq{n5R?@/TΈ͔'/@Р2Q@XєτԇE~O~}"eJ r5ǝbv.t_5KP>\Az:kТkcsk/mM>: t-&u#pôM+bX[& fc{ PwǦYu lxnQB&M0wXL Q!7F¥dn:Pj;8øDԪF1ҺvDBЮ-jD-++1 YplF?='¹%Ϣsb84SL<:o('Ecpsଡݹ ­4.%bL/{%`e\QJ` Ţ)0aPzq3c ВˠB@2:A>Η^M>}cr㧐ަeѨư1վwn9};cVգ=ݰx`W,(۝dp7ņ}nL,ƥʡ9Z֫э}:z ׁ97 G0{&)C߷:6Gh<1#]ct`+bpdz_Gu84L#lH\Y5ޱd(X>. Min; 3bXLk-" .i3wBy} %Sz$o| vkD6BBvh͚bX8 \Yvƽ+p{\Y?W- pz)[o n+xg \?< 9mv/7Ņ5hxl1^/-pv߳'Mpnِ c$N:\G_|&P\ׯz\p}\'~}C΅f 'a޽fh9=cQ}0Og ɳ_g#e@3ۦèQ@qfib0Fy}1VʠI`tQ`(=VG釄p0q?="5m j8|" yjp96 C;`΢N9. pS Fj7g >CqbhLolE1k4& laŤ^Y 2Q2q߰ByL10OK,QX +j,a֘?% 03a!Z@o֬YXSpLT:\K‘+{f$,rAo|VKԸx0uN9SJ_^K{Њ8z(o&,M`R.]AЗvU*^AE3;Y$3gΨ#FP@KhͺuD t@Ih@L0PKȤM7 ]Ue<>-&ӺLmL/P^Lm,u&;`L@6}&Znm G:VjT^V6vz Pey2R,ļek%dZbSx#Pï KKz*>y, !ϊi0dj<yc%H3E8qL^Ě}Xw=wnqqC^.^M}OIZ29dulQ0|-uk{@tcpptcqhB" ڢME45+^.xv숕=Ⱖw<6[ceRK,kz’n1X' 7òN85./+z=G.ѽ3VnsР6XӓqTt˓'~7?\w~xONiy_+UuuJ/6>0m4h$x Z`ZVju`ANH zX9, Qb_ S]0KK焦ЭI(BUX(,IZs\ B5 ߿ T%J/rǶQ~p;2V l wN艽`C"wLĩp,y Gab|CYj&NĖ%qpH:UluYY?gX4vƀ&*.)5bK̃i<- -,㪔BSCT[Vk|3 6&}91YжUf)o՗w@45r9سJ}'͖#< l=_X5%~(8Z8ˤts7ns? 4]c [Gh4A`x~lS c`nJotppԪnRUMdim:uF]t~KyU2&NiK^{BѼekx4Ħmt? ݚ+֮]EG2%"8*a>3oZ/]Ӭ쑨aӦX1g*;WÔ ꋐ@QbxnoCд͊Ks%qlc;Z>etg._ RvVmm6xzq%BZ )Ր}䪇gDD*gW+*T[k+ \9a5J/;Kl1G . &D8\_7 gęXߣ59(C56覍1];t<- P`n42p|\[wV%cqiTշ`=3 sK3o;`ǘ"棁# +C޼y,KߦrΝ Y]9#s6]:>QЦe^= !|(b1yZpvvF&%T~/s{!ݰbHW.Ih6D:ha0kT5BE( ZjUC\-WDՐ>T 2@'lhby XɉqXտm_ L'r5L}rOn> 2{ Vq9qݨ4!qPz\X KŨػdzhDA 0^^gYh9Ή|K0#o$On\ݝtJ!( Xn?~E{ٙ9spu{K ydk{~hZ;CƦ9hRO М͐})Wt*]zh.(/@3we FQ`.(_չL뉧> UЬ ttރm[KR eg![dKjo @7(Т|)@\4.+Rk͢IW?cl\W,߼ [#&.wŁU0| x4Ǵ:r0ZšAQ+-Q53(OċK:ۿ7u, Ŝnͱ(>nMAPW2-C<& Kb,Žbml(7+xaEQV01scl|?4jG%:r<ש:Sey؇Б Yݑe޳T+TBT8/v ĩ\\Gdžcߘꏡ-_4CJぶZprdw, k6ʠnUm}׼%ƆРN_8MXҵ%< ƪ>]psp<8Sz! ^ ؠp}(3)@긱j4fwB ǚ5[Z~tuu%XTZ__:88uXwFUB݊+sȹN%DT3;.cTTINʵ@7ݱf Sb#0#1KÂA}*=&*zaPvH"Ft ѓz & ;Q[| (]-S +ABUkjj6@T.[E_Q[x9q|9b`ɀhl}M)}yd,'y(q?[Rb^X?)žDlۛøhK>@@>Y<N돀>h.r=lM0i` cGhB,Λw!(U~AQlxX'ᬂKD:v8'捋B~*΅Gh'_Zl џܷzTb$*,,p|nyEM繿Ch;OlE)D@u:~蜲p4iӹs@kW2tkqó wUXZJ7(5寳p0R:[L0ZKk9vQbiȬBmFfW_W]WvDV`~Cb~B<\+U@o[m&c^`r0AHj}ZK=tV5`fZaQh]eJ6EWh 1Έv0jiUFOOgj9 Q쀺x636TAn1Zƥ®^4L@"*WD+Tg|>d> 0mmmQF ià~ OϩJ?O9M nYј/J怞߿pc`\Lw CR@0cZ|L |x0XU3tr(%e P* `.WJl_^-%ǕǗEY>>*' ñpt@={& 1=G88k$O*;T2'b?IC0%V*N#@c^8n,MG "x?MhqIv(L_GSe&('0/^T4s2 p]L1y „0$HHq"r#~DJyNhɛ#r>WYG.$:.C~?=_n iţ;݋tA2֑#^eQkgTUʲ dj5M|>9Ja6W%9=,lT et/̀/'AY{pu fQH Dj&([\"Q[4CTꙛóJ%,Q t$7DRe_ t~̍N[ ^9\NF(@$m^͑Ԯ=a۠nx1+%{9݊I8w#pZZZ(_U3+W,+O%9z9I PjrBAGGGb?zW 19 zj󓆇xA@,YVúL/dXۘTس98q-~ȝ[hlk cݪzr{hKN?ƸK?+zJ"y8&KlYHeZR}=uZczv] 4j&:V u$@l'-n5=Dx5l``fhQ4=%-[*@?rg `?*EKC4ΏSCQS$JXMȹ&/[.b )PesGP|/X~Nӻ0]4tne>+ aHK_ h .} qfTeS2c"paF#{bӰh의=mty&g_|%>{S6ihۈӇ1+DP*_T*TU .PR8ɃቭhlVcP*YKFum*?¢X+j~Xbښ 7?Fan'?lMŸvn+ƌƝ[Z8OmǻN8: !QQJ1̒/oN̵pB9 .}ÇYg[̂L3?t1 OsmyܠA$L=ܭ3zv."G`BLwL! re`,+ ): ܄p E{/7jU x.Wʺ!mQt%ϴp\XyTmз'7X? žkL_cÈؘͣa^X5 `xl^ԯD7}:~ ֋&ti޿#ByFr8œ`pL9 xR]錕31O fAhR c;XuߋyrCxHc,UKEf_8WOmzKod5Ltxr?T*SXB>GAqсH o|dAf\sٱ UV-bq&V XE*@Tޥ*h*Ybe43Jȸ?c =Y;+~'א|+оGgORe(в|K*bܕw2[֯ҍcJY ר]9_üSiRY˱q \9{kW.V{l!G©cشzh{O死qZLS'@3xMᦆ*@ ; .LT+ 0,ZUCrF;c<;fbxQQv Ym>- W;GT-+kz\X6"qSav Ȧ-eJ .[ՙq~o/]o}[0c? *dyIhfjKfla0=Єm%#hgzZϚ5KٳR&\S{M'@T~i iqQ'czu~1֯X#.sŬ^ 1wwnrPg իôrUTA ҨXV0҅)"bbP]sbc{cːX50[FƦ2& HHM)q&[<7l*5D,Q\2fOU Xn\gW V. &c8c=&͘#7҃8}%%9K 54EVCR KE|(,JWY)R F)=d৻<^w(- Њ́y*T@*Em&D>h" 03Y=XG5002mІA3>j9f9i,b+ 9s f"H%zxxfpM0 2&Mǐ ̈)1);Ƌ <ϊ¸о:uGΨea sj0\ Bjjnʺ06iU}ԇ[b -!maRHs0DbyR8 %`C`Ab'YD b>X6 P'({8fLup,I±~@^3 _/ - D_Cc0ipw]A{GҒ!r^c 0!ʗG DRpL#Gx-/O^ta#_.B|Z*?tGLXsy<'D! sz׷$f;Z zh5}*i%,g/~2>_bHVje_NY8Xy͸uo.W@իWcٲe>}:N8!޽{Fvu֭[ Ogu%y7n3fHx6mT Z)N>vnz؞L}\O>ȑ#* >Pxο}2/ݬy }wt$M-`j-ꕛǯa6:-;z+-^]NV7/ũkwlX7ž KD񳗱hT,? R#U fa8x䤪1g,,Y45!>'ƍ/qJ7j{a[**XrDP6o T[VWhjKT|p6{7׻;X7>Uy M ͪTFe`V(?JɃwBJ|xsOBLs9Y3~ 0_2YkWՑxq/قc'c34[ Ѵn{;?9hO öa*'N//dzΟ?/oT.oƛMx2@&&$$Gd{|ʝ|8 9E0>v ֭['M#4EKA:>:{9bpN`tt7 #w^]1""@/LN}0W$w }c@5oh⊆No K[ PFR˦ѦkMh偾k_б9&E ϡA13"0JuKƪ>6 bEroq#DA%dž #qx, ZO=1O&낣aL w{ +QKA|U8zx#bwnjXIޭL PȗGSg#j!:uÄA]1g{$E`քЫR+bu0~h?ĄΘ<:̝ ?Qt^,J QA-렑 ZԱE63,t% J ]L|B8Ut P¬Y¡iLYZ8Ti5qT9* @/ƍQ9s"2?kC0+M1e}M.Q E@9\ f qN|MƳYI:5A5 +#qZ7jvFmag5cۀ&6<;G%aqRB4} @WZUNnB{gQB 9HB 0!VX8f6oPafqfwww93SM9i }~!So})N)nlvNVټb[5Bjv׺1o/ck I!E{nVGPUuZk"hj?m^lw冎u<ޤ"AxGFdz[1MjOk[sJ\O+L >X3{t-#1*c.W|.1'#R:`L @\}Z~;ba{bNDc>;mc;bhާ=ukb/#⣂eEvȤ(^!>\c"H G_@(א^ [`c Gk#cTc;`ް19)hI>X8,!)> ʱaX<-֌yClb MnΤ~2>_t:7ca† p]i8w}?Uϟȴ rf`9"Otx^Kf`Ѝ4fOLCh:{D].|X/W#Pޜz;1{\'|t]*_:ÇgddOދwto~ jbSt8uhsRhbE=|T̪TÁUб {`\9SM5h޾M?:2A\XTELkL k1K@I [;U3CSZ=x>b)6|3C eF]#:Y/mc}MtLm-eh%|MBo&53m55۾Mktu0Dw{;>ho%ڙ눾tT,MAl-ڲ zx,pd(,c1ch7}1v`wL&|$`ׂسp0O'wkq!7 #Cѷ{+m1~PWl?͓1X2Vðal;lCcXLi#&b6.+l/KIX> -pP FÛS?/ <9}=mt*1oBgSePYvJdvx Q㦰wt"RE 442y jhZ @gO C.MbڭXlT DFFJ@TJ[5_!!!8\٧~4A}(DZO%Ak*@?rú+L꣱~ DN*X=j`@Mk l# %tBRXM7mUǩ༩fh&- gm" ETGkKCZZh6_lg$YcutH'cD9fW"m hc(Gkqzh#Ols<+a,[t v/C"plOJ!зkK Tҳ`ì!X35 öqxDLII1_Aҧ# d:!1I동#{60Z1Hb*~a-D`Y#bPkGòmfL+FĚ~X=`N8.sBpp~- DZ"cN6= S?/TpL Y\@: sW]#Ghg:gPݷovGF!@;8VDe:@ۢ5=% lEU*|BZh7cn\Of{B2'x=o p8ֱoϟY%UJ<_Kd\Y=Te=g3"Űc ִǦX?Z=u `[[ǽ9Lj+*CV"a0k *(*/w'4un bT⨘ =ʶ>E`ٰI5̝"(_a`]==+!8?;g;@^}N嬃,ҧ̶WOƄa  (ȹ5븟K!?jFsˌlC;:aӌ 1<믿fZ[|nzIW?OӖ*V " apQ4S<}Fi6o)2쌌ݰQ9hPa :m*o @cǹG&˘V@s`/Pڪx`=*!=\SG}L@f= 4̿"cy)#ѤILRf[*TW*@_8z}gV5Q߶.,Pqܱ[8YbIT4tlð65acZ Q  q//T DX_ԳpnI- @3brt% n }UsE|̨8v%5ZVSaqpwj'(h2!"iӦ򁊟#R g:$`A`fv \W`!@y~7 yMXdq׳uM' ZeRVJV@紅՞ˑ|ʦ*/65-Zd+Q`Sae=CTQE܇ރw|1@Ӻw HXͬ+˿ZW%!3k1&"}t.Ԭ\-jء7jQX+\! 8t#gVyK¨jD {W0S5s$ Co߶h,GM-`_ [*,B?.6vAz4.n cm!?*%¦:炏~%8Ow4RղOyU8e퉿8З6?}}} |@x~sp; gEqcp89`9VVw<33r[/<DžRks~-uuxg9#T/ן~;Y\I#&e|wy1urnvCY)RJQ7_7YIZQL]OfjeX漅D @)~IqR3G3Sb=Qao%2Lf֟D:@k,,Ξ:@3!xYYae42, g]oBJcpX8zo:.cr+AH%Td|%ENjڦ;;F k]U P>AJDRp.S7V5ѻ:éR~uR0/V͘YHKF-ݲXڥd EU@ˁ_#蹧ߙ{9h;;;y!H釦(-%T9s𡍳kM,lB7nA YeYuNћqO8/X?v5_|!AoywsEϿc瘱>cv]~C7^m3/s`p[=T!`z՗Xw)ΊMɛ_: MC/*_ϕ'u7^cёGr}xx_`y|@9✻E;B\*;q^^?g?uɾY^ڲ%Z 0.͍"]-ܠW0.ulpo$ 7V=;JvKKK9x>eAKL͇.Ą^06mT 0XHK_csαA<'C8'+,3FNW?Շ_% | ˏ?n̄~C.gxSw bo$X/a0@J㎄R+ @:{,xӹgX#j^IB~ Lz{?C MiBKYeg2 Zt4vAUY}.2tݺaie#7o;Oh8( ]`_ jT ,+UyT±to'kxXg8ҲA̦\Gy彅i MUlzWE-V`Mfh0rp>C98pJ6j6Up_ @gVu,TE): jQ^*ƗpKq鹴e|?QΙYxՖj'MHVeDY8i%2t:uaaa 0K=C6xeR6~~epZ ,D 4ᘃc\vMN@1&^~ӧOmCB nn&hf \9^97ߌӣr˗l`j8itz| 8pPO`m ƙT?t]YHxVj5yeRV~7f @?}_~A@\vNpc36\ƀw0wI^*8y"-wAߋ#c`Oo.oe^Ly&_<ŞzY, BFFV* GN{&sJ^`K<(d:`kx\4T4n (Fd] X;0K QS,ڷC|:XaELv FggmCC@OLq;`6u1]CA a6U&b5DCmjt , JU ࡧ@D9T)Q \B*\ vUXif+T sIuK 35 Tl|_8 /gϞi ~401}@K\+fhf'؏迱y|C*I_ @i%| T~9pY`Ğ'_-8x!=`pI?~q$4aСCN@7Np&r)LMHj ToSF_+f2JxFUa`h"u$@XkZ/Ϣ 6@xo p7}~ =#Lmptq^+?#{x3ƯzE9j8 |d}+Mbɻs!;}4 .^Nq/Mi {Fbnވm#ѻY[tnu ;4H86q0mua[b_cD̋Fu~֦0`luE]4aVv(S~maw(ttCJ+P e IC\Ԫn23v NNNbن)$q&0S}LT ڜ3t""ϩx\h4mTygj5h^Cf)&8W T:;o. MZZUTc SE^/Y^ϥ+GEm-p 7/ouzUֈ_%@˕ıă<x`m_-ڴ! ZG|DWm`(^n-}Զ CU] nT'dhZTUUuն+GfAKۗ Y"xjJ}n`ѷ S3*@KUY #Nmy@X(@l T[&YGء% TNř?4ɒ%َ?Qfni|' һZ;#QǨ% }0DŽpp j& 1[8b6C[sCtqF/WwAd-{z."ց.a^$6 ^pF}-A#cT,RFuQ|8±jUyzG0*Sa&ҾL_G0vmN ~3Iu0[{(0somy<7ra`MV ]#9iڠbP* RiVe+TmxH"2x臊pMpYM/Oi}]KlO׃Tkի ;Rbxx{QoZ^=g\ e k;[3EKD q=UEn0.S0R*j rʺ،R)4+lU̩Kqϝ~~UW@Z%¡fuE:}=}@J:P`\*Ev*o3@: x^#cSB =uop/tN)¤2GHasRiۄiٜ&tMX~x_3g{l5(k'_Gl@ 1#aS 10c0F~qX[DR =5 DA{ѭ^ JZhmmw0ghh^vDc Cʰ26mcsчGucd8r Y3`,Sf:)C>xoۙL3J4?ՔbC]慦C哿ᐳrP"!%8SarA3r Wц=*WӖۄ䡃#|}QV@2Uh*\֩&iq\RKx&`nrI&j mFM7M%`n-nRgTi`FfҮAejt?ϜQuy|UG--]яeEԱ,*`gPEtܽBC}~j?ZcU;.NJNn.}.i?2/h%,+ъ:F.r65ey6$TztDN4*GLJ=&W%" : Tŋ~#m[NÅsp}2Y8ߴVĺ鳱i8 ;/ġKpfJ_֮‰pml7M~S$aV@l8 C{ŢD qѫc($ǀ(LM؞c*@3}0K`0BzGv)cc iS1h*Y1Ztu$8FQq0:WR*Єdf^jf2l"tWW*ofᯫgB8UMM0% V7L`*}iӦ1QeL&tb!Ừ +hWKYZDD[7hSN!2[}fV҆9,TZ6nU~ht%-AQ=v8X԰kEF>^6, *D5,]-%d搚1F b#ae iz̾ e;S!S`Njm r, O&уnzT*n=3(Ξq T߶l" ĜrJ =4eB3 'kvhhT3;UČ_ A3M=3_O/"?C49rDCZhˣ%>857=H86=؎fcM~)& +Tcu.ݺ N]Uj!mijY/B*@*:04곢JX&X|JNmT3ԇb{g6i'Si&tTFMgyLU=]8V~E8Txua' \*^Po<+T! Jsz;+#&vzuX˘BU]lF 2' QMZ*@7wˏ>s1oMxe1fUG qjqˎA7vt}9@3R g"dF.T>eY|Ios7p_~Y'r @+_=ϴlp !Au13Hтx5 MaXj3ZQ+ !v%´T9.Gp J RnM?VePI'.cT,TU ˩ HKiZTo;2+ڠ~hcghoM }O=ƪw1dm<c }\+qێG>@$ ]~pctO`ɱGr1& D% /ŋp2 *S~ %zSǞ+/žg8~ 戶Dl>D0@ %>1q̟|Oe۵X'>y,a:em'ɿyʼn8{< Z2 q ǔ\AGAZ6837Qu~\gUf`p^Xt\>ƦMd9N٦Or?sΕK_@ `jq%Z8KH&oaݺH%˕Kȥ ,ꮢ >U[IM%|+>MLwnFСR  fF Gn]U[@~xթ -] T[mxos9R&Tw @2`=ѫcq 5)~KW4+RӗJdNmy/p3>ZׇVN*%UUt f6NYhf&])MGnZT"NU5M:v"~Ml_kƮ/0jml?rI`y\@6.a{)ma빧HXy ?6q{`?G+uJ|w(X EVzǥR^}%;`K@V? !_z^0ۭ?T[b}U@|؄k#ϵXNqQb\*UJlC5hѢȕ+{5"}{*@I8%VA$(+,SUxolW @T 0z#YU X۟~Jb9uG„i^q }y^^ޫ/o,``BhzBpNŚ*)'q {g`S-SakjM_ ENt 'TuYB3U [G2ڷ0R]zx g0M1Yҫ `ʕ|"jz֔s3,Ź-̤oTf֖p`Owof} 5ᵰZAHB6mT-n] MHk, .=*3:f/Tp? eV&cXY;pb,2Щk\4+@:k/ :\*BV+@9MFSS@hf z*?QdWY$j77jAi87SOE΁| ~_.Bj9S%_~<M#$, kaB+03Μʹh>eBC±XZ=ӦO5pά޵@[K2Xz+&fal[p.N[AB՜ix}`Ҥ([ʋ㼽=0 &ݚc`D+\92XfPA+s Цק+cRP#~PգG 0 @o?@W+iiZh:{"#@AXzA B 9 ,A8KvOSOBN[=Ƣne!z[*v*Tن 9aHy$B<}ϤO%mPO}  ٛE;B9m1kz!'FC̾dJ݅ݰL8*t ;vGMe.mcKV \xVBff+YL C0UeuQA= g^ d'74m[{TbeD_ԾUoN ;M֨skUs E#K ÕsAB}z:~}z$[8ŊCZ;#X cvZp(NTPN[PU4S{)œcǎ-9˜ 9*8`81caV N>98/R8}|K%#@; <@YjZ @gOdhN%,ȉ'{>Τt޼y@9 Z5Ż_1j 3ߗ@Y车5VZvc gCe}P&Rfed[r|Qn=uMexB8-wߓ\\{_@2jZVEW믥?4-MWw7i R)6#RH58cS\R%60l*T0 $JY!::cAڿT3ZC[^ D-v22-GSY7 ,$F偀ѢuKiC!p;ޘ$:>OhPTadlK"O°b'v}z^VzzJ?ʶz}}O߯A逜 ѩX8<ѩ_@Gr4ӧcr1r3ᘐ1w&G>}y19… %L_pAed׮]q L)~Y)H<ޙ&3Ee_sЎKϳzhSlM[{u鿘p6 uf(,lm`mo'Uh*3@+K*r`*ۊ##~qez-}w_V2˖A1̞!g!,WV,(>fq~t`;&cp<@)YNZ8,&r0 zRaNIIp}ϴ(i] ]ûYrd a76q,8>WĥCD6gN#bش46-c05%FG’lrCrc3Ipé8W of]]aVbk# lK;ǤC8nP6ot*(?fe)žu_@Wg=пg=J?={Jc Toݺ%-III2 5% fK; h+qofMY[<8P>S~E!3 /gx9ݾa>~m-*%mT%N34SֱE&0/SZ^b )+֙C֗Bd^7JoOo/ P™:i<2;!C##wt7 w; 1nH74 f&`Ɍ8,/tp*.S+Bl<`pcDy6L*h_sw~8K]KV9shVBOZ@+ʶЩ"@C-A:+%c\Wk+3*jU1jZS| ,372aTN{3K-E3)8X>fMQh.ꕢ厦$@`9HN,x U^Z&t #^uiS ~o^ƻW#Gy k֬B` 8 me+VY_8Ē 僡LMT^~=Yo6m`@L' 6¥D Z3un˷~m䬄iNMˈhs"f@$(zn34SױĻWopmV[ܻs?[Դv,^X3mQҧ\A]t 0M3!6mZi.ݺv:ةXUOWB<}50]N.xM;5iA1zա@SL_Zվ#[ǢSl%c`kѪY-,3{&E#7i U`T840X/UƦ*jhfp- } 1*x &תJԿ%-& pAf.ZвA_F,?}wwطw7>z7obQptsV~I7c6_ MԱ#{DIۧ7F3qo+_,S}L"",e*;Z8ky ZjׯACtŐ $tjݲ2i.6G$uBM'K pv4f]R]ՒsG@>jT@B=c opQ?ZY@S:<'X^.MW`X>+%qb [geYp+9P<@[<@3 S924'M!p2 @9BCCeǏNf`fR~:,Y"n39رC-+W%˟*TF> &dw֔ @z֔`L%PˉOܽ=ۦLPy&$Ϙ9]vK1h.I7qQsG   gLOt;wDHXBܹCzo\4k[ww-tE;q* M3S3 Yb@FR߭ {c`X\=e}LlZ:X*˫%b!Wst=FΨeF.8m*V@/]_}cu8K̈́ *UhE>7[4aYr•c{``rŃ{ 0ͶuUHhuP'g"̊#]@XNSI6 Z=4hfrgBv%""oߖ3уiuo߾BB*zsE>8'=8wU.]'4E`HT4CH֩J$&3Lg#x{8>:x}b <87xudjp]ăby/z 1/oG$eGy6ώ X07.1{I; sbH̛g 1sL4f%aZJ$6,`$̟EaȞݿ`X|(RbpvHA0&&Ƙ$I12>blS;g%T.5e %gtpqDMm09>VyUiIfxo߼uk`QⳜ|Pá: `[6ë7jOΟ1wA.Ypdu:06o+ GjQbbb$D3 BUzƌნ]`(Ahf:1͙ i [*|bo2g5y>hU*LKbs.W b]PAN[ % @q]P^^gϧ ^7㗫f6}'.mśRc-2ވx}gmx{mˋ =#btV53&5Ũ0.t~Zbrr{< +fq:7',Ɓm3j~2n Gaصa*kbƸ~J2 cL3ad,5r>&&&bFr(ڣG;tomFnR!-еyutnfڨj, Z0q1 putC=]Es!C&dNlg9 `%>Cb[FbR"ce:w/k穇RN:Xd16 ⺴u.iNn΂u,Vb }[`Ȑdl\]30f96mm8?G\:u( (SSn(lY}Rг[+]?RjΨU._vh >u$@늃.tJg2ETvҎ6E+GK3nn@G-*1Mqe L7x&| sH_8VΥ2>;5*5Z %m-8MNU՗ j1G̉N]vICJH2qdh*G͜ЄhqsoPmf̿A>c=u&@[ɔuKKmƖ֔-Й_`nMJ!`v;!B@鷷ssn ZlTAҨTꛘ`h`[qwA2Q:{5۝Wダ7xk(n||(w؁mQP=܂w6xwg=^7Ve8>{ [ ^gwΙ]~b9 w މ{؞MxzyĤ)nuly g#i`<6FعkS'ųx9:t ""*=zFr떭}ݫ̟-Ֆm|` ܼxPs9\#ľp|+*l=N[#G4Ud "8C aaZQa=1&-Uiw,g`eF Yc[%@??'rP3gc'a:;]`;Uׇc XIk7Ju[KOW5yNBkp<1XnwqBQ}[##9Z lefcxbo1=%% =ֲ:]Ѷo{\ք#>!6Ho7vJ q,Y: s}pT τ9Z7,$-f) .:@f D.wy *9 bPBiu`d?Ps3+ (f, D(3.|oQhŒm uxҟN\,5)V4mDV?_?O7-f 0 }g݁ww[phx45ևUⰫ BEथ%]*,:ug NB|Ph/d?^wD ^_ۄ|P7}q,DG"p~oƻ›K_1 \ƣsy ^~p_+/­)'#88cǎAN1~8`d̞=ׯŠA/*Qd8xpFYch׵[zDG 4fϙŋԩc!u]РN8GxݿnΥY?J0\JE4hPz9Qr6?*7s!AL3m9-]DĂ 0yDy̓}vx>} ףNUh 8wvgM9 AQq<~l2WUdtzT,m(2Ttp긄8ڹ&h-m׺Я_ i}Ѐ 5*WFzΨUM{س=NcD$--/z:!S,%O7Cs5 aBk osw;z刡MdPQN̲gp ͜e`@>{֍~[Nhg&JyRltiaLrT%:oT8d"T0VE}V4*hZSdpXڨXR(V"=6hM;Mɞo#?~/Ýxw;ފxs_]~̏@0,V+r0/WCYe FK'7ķl=dj$ ,2L Lo7WCGk@ Q:ΛT!~x(<^/Z5xh>x}3طn*=^x'<{Í'0,9 °qø1B 5: {' G0vCb\8"žn!ꏘҩ :Fm15:"S;89[d| c=y齧u]89Tn MZNDbd'%R)UEr`= մ%N8Gm[Ա @'H;yRm_ql!32{sq=[z=*2עRdgV NvAL78y0 m[{+D4FDSkՀau.ܚΖ.AFƘE,ܾRݝ0ulB1aX% )&Hkszr;[hjG<1@PbiGq-^#A L*G-]*6у?6#!9!)CѠ="C[w{ 0F.UsC]LC Yh Հ9u=U*Z_maneRe .J-i0TZ@kJ @onoLpމww_ luMQYem,QHQ,Q B5QgU2 ˖Ny"!pb(*Bh~̌j \ZUW p^+B@7'SITeEkuxd5>\-zZmx!KGꉀ/<v]@ A֍׼4kMkE3oԭ툆]d sS-XYWqE闇PJ)W/w8Uv)T,_QPlFyQD>(JVT\\8ُgx~zS֣^4aUP_@f2--:XI}.nr JVeH4(j4͜5ǏC0|p vN8v8޼|)>op)ffʠu.Ӏ^ˊ́ʶ:@uN!ֵ t%Pû^ԩ_O[},Ѿ=q4XNO!:G O, c =0OLI͌Gp?@ ÞmXe @sCM-ڼ8TG=J2?t=hm] lp.=y6:4^Lж^Zۣ5tEP[s:4n5-r]{L]d",Y֯ 2L*5d(RUA4՗+KSA4jeo9\!; -XeMctƙ;@Y³rp^@_ O4iFH޷a$fHGˇ`nػr0vBĿrҮq1&B ƶ4Xf?۪iZݐF&8 ӃE_(S8h

F6, T@OPPA*\Ps]pqVNJ&1 JG FU o?n x |W<0f?=ތS'Tt5^=Y%o%^\mx!n\ÝOTTqU N -[FzR}n -Em/gDnpu jXLl[ݵ\ֹ"un2l8M+.hCb߉xnp!ܼy ǝ7qM\rZ97l=# Z#sԬJl +H-}J4˭ZI>~(-`k̤1y$[C+V,Y 83I[^6 (+u/zY myfA|V7iB"5ej tFp3ptX,33 DBn's18:5 L7,(S17Uiچ8X1ȴ"[VU#wD:ܨfo"v*ЊϷX`çZa]'4">֩"C#2f`nZ]@wT(ʳTDj(iuK_@OyInj+?*~txV¡gl*⃨hMɎo#Џ.| po⧫kq`/ڢfU+ @hQ/^eBBe,TE*X4,#zcFD8#_(Qĕ#xG S>t~~$V-2~@-YGxl ^ S/>œqeܾ>ijc 7\G|l 4C6>hޞNK҅jb\岅2֮Zu"֯^+D%Xh>VXKgcѼYX<F~Db/:TġCq;qyܾ~/Å31 at#W4'Jf,LYQM0ep^`pXHh) XkLw5CPpF Oc媁˧~pܺqoţ=q=\pC;PODmOW$J ^t)/^"֗X kV2u{l-V.]sg DCʰ!(]}b%rAG˗k!@ n_Kg?9Neɨ1aXiP*Sq lρ|+WnjVWfψKǎm[i P5jΝ5kR5-RI44 wrw׃ae'ciQ>& zUѽss9QQ2יi2dF<&!]`J`qSe_*Yh.6!9Pr*$s&0aVMo?2ePpOY9L/ZcД*6{olěӫ Da]*JVe(XxLx*^zz0(S VMCF[-ٮno 8x^\YWË-遈E/v;|~ቀgT_X/Ƞy6*wúɸ}K;xx6ܻ#T/(:}Amե5u;b"ڢVM;>r]*bȺCW Tezv*4tex_{| pQP|1-o} @kJhk԰w󂫇7Dׂcjd/7==_7/D" HGǡix("Ys> B颅QBYUrW,+Aj+(V$/*/'ε\j{ow;]X<߁UAj<Z@?(cX:9ׯí@۷p]ܾ~ϟpƵezu$@Gvc|B(/N%Xz5 [l)V,]% xVy Tͦ<&Gb\/Ʈh bsBŒغUдqpW.'p9VAj+UB6-ѴES8t4U3ԗezM,Dm8:KGTt$|q68W[K.!8t ^)S'#1%kh5ei0(`(T+ОsPyV)ߡXJT*/Umm}mZ! mx?{\:y0̸^; *mms=!B܅(! ABBp00cG߿V$0Lp]ϳꮮVѣk"%M)!L+Ggcݢtx1C1N-kp`89'vM5:XWM(χ3ths\88cPK G}}[RB!Mg,T^{M:+1h=9^ k(Ď^yiLc1bUx,jj"eKKt`H8\bXj.V̙P;a4<^诌E){|llw·bX#*m۴AVѱM+hZG.z%U?=$@ ڼ`J JWכ៟ÿ>;}yqcGϏTߟs}m/l7_}J0>|>|hkaw < 1<?`Ic(x݋s b51O$zlpgq*n g!_bW[3J(Я'yS/,97C+8xK$κm0:ze c|;5:JBK(:uUѿf'XHƹޫo#zeTm3}=qND OOZx=z |M1t,~7? m@vnJV+- Йy8pb'؁çwؿ}9ٍ6Зi .WD[O67/7⛯96o?ƺ86WJ 3O(lP` /C$x01wG:t an倮d:X3'e6|~e> <8|ziGjI<v_<:'78>w ;n 37x'$6er9^(%h]LlX5+VLVuJ3҅l|,?G@931w ̨h 4E]=5۷n޼n&qq }cmЩ+>7݁Wd 脰08{ eKn|1cFjdfScQX>"]!XEߡCg!,G}ODz dfg!-+]ySX=u03L+IiIIgg%Us ;Ŋ^~>ФF5 :_E*8xUuӃ T(YC.A,;oPW̩fNw`Xǃ`|LySO6FMKgQLY6n+q%Hf`-E{c'KS Njh#XA`x]. s w]N=a0xSL}%܅70lX{ν?PMt]rG~ 1}3Ƙ)֞D@_{c_}ҹ>|.{·XAתP&eup"V+g)842 XiiMNNJ+m&8[n>Xa)/_N/O$0[x1ΝΝ;#99111X\\z聒 Ɂ cWWV/WSq}@|sNm oՊYVmڡcְP lTìcXh™` ^SUi85o2vO[)a6[珎_\?:}qz|r}x}p w/ޅ-.m| >z!>| O=ͫ1q(D 5P.hhpY+ ͊ fr:38˱p,iΓgO,3 ('B@4t@h0:u^h@z 1nL2hԊyDT\4#*&VUU=p=rEFV"`hO!B( ˬ:3L˪ μ#e}n`K5B(T D@h`-ZjH S 0D2v%Хk,Z!lˆP] gbHѝΗ 65h_32@+ (2<7嗷%x&~+hGؾf(e tk#+u /`GsN@ Y / ^uc,!lC6z[>S< ҧXr}0\y|'.6zLsij§{.4t$@7 V|,-ZZ=Bt$wAjN !&%qp{? 0m4#??_tdd$ƌZuuu"v-D_z+[kGZ^QEq0V}j*HVv& '[hiB]Ɔ8.%A;)ӧ+N5{`p7Ο #`!84ŝݣx,>{H=Ϟ\'ݳXpV{Ww<} ~/`:` /i '{S%Gcyˋ~ l& .lhpe詓&@iRC@嫸t?_8 NC7вɵbPS 9-1 }ٳ:ePTRҞ(,7m؈Q ݫWuzw>O>CN^4@J2J }ᙃsYfp <|NM  , >?VЖqqDmUWtˉAyi v[`߆1(ɋoȬ1gR9&kط,9m[:8z+2b͓cn#D=08 #A"~ h~Mv(ίG4"~@qܴׄ =ȇ`DpA'H@,+֬(|3ü\u[1ܣ>|lq9W5'] `5>wwQs)8>zxt?}m׷%^m _hVeSPiivrw< (T9W\,ܽ^=rH̜9S(ΣG둙)t SL0l7/^|n]7N_~^ <0B0+FC}e;N@YաҾ:矱'. 1VypwGf47[kKO.*BWUvźY`Ør\޾_p 9Syqept|0ĬY30i$qlԯe{{vCSb`Ibt-J{wCL8ڬ2+(s6,{k184?X +3x>e FWY8C藣iGgwI ⡙qrXD%/9 ϭRe޹noqK0gr?{eY8gג=b[fy!wi,^2?pV]ц{ !Y8|aah%@+K˔hg7/ oB"n.l?p}+EETT y޼yرc~ v>}^ډ |6||ֺͩXܿj$k=J"BͅK6jYwl@[jg C/Y% =eѝ qPO,gbl#{c͐lZ98o_ `5+x8>{|ߋw8޼|ۖɣxL G?G~J.] /RIQ"Bg?y3Q3R/Y8ƼYت1`&OcFalHQm~vmm?'p8_9O|,N?F-5tЈp/lo+1 cAXr9֬YqP7f40W `HuϘݻv`? .cEv2V^E֡z&N@$O?QSMc0s ,/"Iё`rnoXqP[\(l 64S`hf`}S?Ç )9d{D1y_ӸzTش5#YynI(-Њ|l]9JO[j?`,ò.Y4fYEYLnj`n:+7i+S x@ϭ%BVN"T)- Ў  OPBѯr8@Rf\:K皷|_߿d 6||&||z2fЬ8$8bPr%EIʫGFeH)9Ыl2T:CS,`n +p{DahR4XZVbI#~ 肥#p~|*> nٳ[a}.=w +pR|>?f~|GQ?e gGcZ s\٘;s:TqYS1{1c*1{z cj1nH蚗.&6%rоС-tGB[x#™ 8w .93'ԱzJDTQ51j8&P_ڗ-]fc۶-Xt1|[Fl/^А nj1O  ©=h >h;bxE,0O<'_٣'xH#\<{A4+F0SGPoaɘ3c"T)+F;0jD%'աowô\7r 9Atjk@t/)  _|ɣ'q>~sKy/^<cb =S&`=H,_ w&\r GJ4IL)D?_ST ,a9;~||'>wOXlw m3,V,X!hE>h ~ E< va+<&Dn TM+`11^lgkGI^,a*+zռJL>%:6,/_di\ɢIǸf͵p8~KV"|>fN7|b_9f`qѲ(^~ ̓x"_<>r:Yx]xc|@Ej7<ߺ"E <߹nO9O|9mc"hnJV(-nhNND~h}lxdl_}Jj;=pV3<5jyIB6^Cy()u5kX zF$35H:7{uQ/=#;NN{qN۾{™}qb?څ6g?iS$s:}:c˙6{aϑen ߯! 55K]@" RF Tdn MJۍm,m]G}<CU\p<2 K!0 ʍDRgCtLo) P̠)BO-h{7М]h-Ȯ1x},= G|%&d7@l`3@Y?֝>"=Ilw<y7"F;"3g1Ll}bIDAT%&#;Ž'62W xX f\'?k<4=e&nն=E~51@K,-S^'@+7">ހo?%d-h-{"V:pA D W wE=B,ae={ ./t `bjOm}SXؤY`LV $n6[E .Em޸x,ه;yr+[g٥xtc+>xt<ڈ{w68^x s#0O>x*w>.9E %3 zM@{و,3cΌi-:B #3-*Ы{WjH1Օ}i?B=%35C'"**m`,l۸ ُ m3@'z1>oۄ=w`X|1ƌBFV:\<> z!c3^0 8d;n`-ȅ#V^7_a=5fۯ|&_#RX 48yysq {!~K,Rf0FcqVOg/.`^uϯ;X!sG_Dj}Z>Ї/1fpnh@;B*JV,J~7 >_?[o?^G0=وf`D^|oni1p|r*xt@ٓg@'Ćn. &5&OD.pڟ⎑##sT:Ң.ݳe=%ښ`kJP]AO  t;iokcώ=ppnޱ{wlFa{wbIw2qZzBeeXsF3r@͙;Ef :.4}6i|pR*)2{B,G9hH,8aXG掚fm 8.Q*,M-`jh{3+ :6ߗAvc P}|9 n 6)+Ȱ+Ocy[n۸f[t}iIq?M_\-pRe~ 7I eoz̩6] ?FՖXIc-yG>2@3(j{dV9WtGO`Ͱ4*v\\&t?8&_u߯+/;p0rZXqdmKݦYmWhQo>—7G+*;9Vob:bǶ%G!\?r;7/`:<&}@#ܿun}gO‚ĤA/C,4`e@tK-tBW-LԑE}Ro}Zdh"Agѱ sVG Xr5mA{nÎM)V8-!g0Laliѐ/ZYPPcgOŊW._-"kW>eG8ZBmnR џΓak>.qHh߁T mds_Ff𔶛ˀ_Ӑ^#E|Lsؗv=/rm6C[b`~i̹ؗٚ0r*1ymVy!bbUk:Gm  \짮|_0/uWX9WJwk>UuV&\`KG\~ ³hV`n ]*ZYZ(݈~> z=?Y{I-rbjP'[8MG%boo@Uz!f~M6?}L)Jęͻu^AFEz>Jή+n|h>?ދ3W!8AĖ>q 쓅wo >ܸk[8{HC@'ZNZ F↊~EpsvC@f&05V~{bmVt&4Ͷ ?<@ޣhFIbXn#6߂-W#39OEah&*~yr C5,l&"tp.}Cehnc2Цԗn;sׄmˣAf(?N_4w҄Dm0 IeL ۊP'f`~|-k71-:tsCq!+R/hcJ%{Gڗa;֘^<~Q~$"Vfx!TDQG6|pމ ari!&vA:AUih'w9G q PhGB[?:72EMQ!Ϛ.Ɖ۱g:qw\IJ螔`$]sxa[ӆx`>>SjKN.m1p<8Jw\x ō1~*߄kbܙ"sh+|agKsMh):#-Z`=A`hw&<3L3t`bXhh<^qۋB0kL<}j2/9ľkm|3 +Ҳv@YYڢ}{- Ḿ/$ìb?/osi&,W/)%s~RNg{E ?ҊJ2Z$@b ߍ% v;=a1!`+7۴ }w>"-ͱkL|{o\k֨4G 3AIr4Vg.UŹgp}5[w`Ĺx#vN%˧򾕘VS('dEwB/fN8;s >y f͘ck0iHپ[66ΛhDtFp3\L`ok sd'ajJ` 갶Ԇ+j?B`P *ݖ C !C@QlŪKQU9*€7 Ь.J} W 42f:NJFLe8e{gGܾMx9 {yZ;؉ѽz`izNAXe >UA]MJK}%Hq-rF3XH`m`f ;; qiH+Ln<ִߋ5m11@َ!ð>knrӴn-yǣyNq#47 &W?bwJK2^s nN'~|u,bGgG&ڦ ,)];8*">C[0TB3I}3kM]V"?2ʱf9έ=+[.Ȃ=8>o+vM\%ԭ& u^Fضغ&Cێ8޺qbղEXhOżظfOu, 2`7D{#-9 ݋s1x@9C6Vlch(Ɇ94ZlW: u? {_v֬FH=( :b@/AO \l1y1No Ymfلx"kB(&Beq02H BcɒE8H-Vۋ>l0ڻ:ST-E>87X7CR U )dfǖ@6~!U燔h$yٓ/dS|>_R[#75 Y}%uYڗ ㊬6ye xx?B4=K5=to@d2%@2@[_3?uv@8C>JV(-!!!x1rHO˷_~/WwԖX9i$|oW3hwl=wM+Vexvvއ(hCW蕕(Ouä^ f Gm'@{'t N+pv9\]} ¦)3~ϝy8j!\8W BmLJ\ǖuaRlݲ7EѧG/,]H˲{aȠ>BNJCȦUaX@`( J !AG2t/B\L iPSL.LL4a` CCj@C5:t3mk@ <]ѿwcߜ;fVEXƒjjK &B)Ka?32@s0(Gϡb@1P*C!;/^G1.ӘlhiV/[HJz.uh~<퇂 3Fml#tˍx_aEnkݤh__b1`2*~g"s񆉵theiw# }1&x~~f^a KMvh8u1n_kf5ul=S;6-I} 1<}PECa[Zl 9(-¼8u ʌ~+C`xt/q9X+pyTٰ{uGzFYظN fRl\&7{:Ս<)"*Jcjk5(開ːN."/C@ԤH$uBiq&O,@I͙:LUafN?[7XZhTEE#5<ȾgS#QY1@S;gYENO1q)̜1 K.Bib ];・wQ3rD f5ǓRI4o bylζx:Ȫ37!vwE#Cd/O/hcEHCE uYpP[I9 ?| ѼЎJZYZ(݈fOŽ#8c)V5ƣ>Vݑ`¢1ոu`ۀkV O';GpqJ:ajyW%p<0k *;adQ* .a:};N.8cp,=ݧPݵFDG!ݣoP[ 7ƍ'a~_X ˗ŋ1}xlٰAybL;ƍ!( ѻWXapxyiQ#D% 爥mLNEh;L5@K[A`dmDThUu@6i#bN^qGaD7zLhE-ok~i^LEi>t|&F YCMGM6</ |Lx}|LpL̓W}ǐK} OMMAq43)nQ ;t&!ڙڊZ@+K %@\/S<,6L0$&Mqf <<م;Gu<M~X\Q)0k$Cmd/_ #<c+`ƖɅs#%3*`|ZKYYsq`,z`ݲ-}t׍ðr ZoI1_WUbt HO$*VUA<sgNʥ~r^4F stle"&*@:C/kX};c? F01T6\M>ڷ/jo 9̅K 4"/3P ,XUU+0|0ܾϟõ+aZ]fXrpL>U|큎acVE61YP]6eV;I<']up mdl5}7':聖ZX!h6?d\ ѷܯ1$Xn|8_ԲbrY/s? ]=7 К?@3@;::&?&f orn㖖/7d4^.㿆`~O¡,-XnDs/Ͽ7N8b/5C186Yn]\ cp^"N/7pbQlK`dq(&{ްڶG[l=sTƒPe8%Wt88F"AY7 ֍UChX6 Wz6r,>=vgNc `Z9ӽe"%)qaLDྨ\5¾1yBwG~NPblU=k*& J^g6 "*ZC_JǶgzNF062Gc{L@+T*Y.IMϕocC@xq@ǚ@܏G: -4,Q4h3;W @١CdFQKюEmmZ+K!о\ޗk  63fSt^DPPbccT eHԩhOOO?rssA 5cǎł йsg1#""X\7w14'?6Dֲheiw# _}nmKplL;+DMz Rэ>Oǁճ:|qvn- 1o0yavwW/tAYt55;M[[ .^(MFnD&~3B|k K=شjf!3!ljeiAX0~ҒKnm:8>s gU~(Y{mޝP7z4fNǃp5Z*FN4zaUG=a(]ʾ5nV wx; 8Ifb g01bsvѪˡ: sS]9zHo~,@$s 1a}b⠙4NH)^%Am~\9P<"!)1q$1XmaS|+|gHL4ۇdϐZ@ZN_NJ6 MQAMZB|]>&rAc6RzGKyK I > Td+77m`_՗%e`&nc PVP-K  }K0-_EhfE}s ,;=K2\4uF倾4Ƣ0tHLCFZgZK31mX8ٛxmz"j s}?CdHIA ꅡU}ډȎzsa`&DdxglmŢ)<)W"+PKP͡G̞5{w{6# >+?·_}/>D7ѡ؎A ܿ1O"lqWa/Qܗ{shLϵm 2D7*۸Sn28Ǥ}Qp%ϕ<Ѵߋ&* Z2m287 w ;2 r#BM!j*WU>sxy[%a7++K`ܸqI<9!x|D;7o]]nnc`W^~ zĉZ1sYfe}SYW dQ@o 旸}91 r^^۳HEX[m#cB8鷅ٶ55ԐHs􉧿%"`4ұF[4Z|@3 öblicc ^ @y?o_}sF<W[صm+FTFtàޘ0fJ wÔ1_OY}z""iɱL&N>`g ~}(-̢Xb#m!fOȈ`x9[a԰3o `hFУ: Hx4+ U3լJ*PgϞC8vƎ8^{˭I ٛO[͖aMio>f@Knoi}APUɠHj_#F"Ķt_ aO[7%Սa4 5Z8ZphEp6]vYO)Z_/t`u5Ї*"ڟBZ2@*,7-·ܥKa`5$٪Vhqvyl,b+퇁TG5ha(\'C`H mծ#4f8g/BaiFj@vXmyaX'˫J/Ϛץ1-n1o,Z+,Òsqul۴ y,N=7@W Ǹ#1yXP^5@:yIJ¸+C׼.(Ԥh8ڙ!)+rNgn.þڒY:XɎ2y<M`km k3SVX8dlPi>9~2JM6#%7=r nȍ2Dsݘ1#5\hsS`~$·i t6PUаoooX%_Dx"ԝ[ 3`y2|3@lPF˅п'pV_GQo [Ue<:qFeT̞G+1kH g|oz!^ іPaPD0 ##hvj{Ow@!Jl1>M @?= â-ccTFaVQgfb<R#0w*̝ͳؼSunܼ\-[p ,7ڕ+0mD}˺vP6 뜬T 3-e=!C+b`rۅ[(a)/Rj҃.c"tׂLT/C5tJ )TYTNQG-5éV ­$hvؿg'1x`,[y9b<yAWOwܦ)@W("'Ygd2Dsv dZκ!4P yuBm Md7& 2 C*+Α鹍t_e1Et/oK/)<>Gj%*`1~ Z t{0+!ء=tф4cFOr7fJ,V55E@+)@h%@+5%@\ppJ^VeGѓǸw֯[T;eej3-C,0Xru9#gaՙVaxfu\f0f;pŹ0:B ܏8ǵ)Ld/&uo~Dxm)oc*TF#h3rV+YR Ҷ6X *WGs=/@MxݦjSӇ$rnPۤ`:66.NR[;)[4ŶTp y/]ZNJV-J~7ӧؽl.l?Np:cp|n,=j`Bl>] 410|ԑdNd@諠FVѶM;PmwFc\\0xJܶ#j&CAiT7G'SC9[`Tpy9FĴ30yWePOúb S;7p @/aC0jxXv(EH+|]0gTc6&cF , lCbasq1\15JBb"f%0 B(zfhe]b_;c T9Be  ʓ!X^DmFn,Vz&d IJ'3&a)N\}ք*tTUp~&?J n7h~Ff暡W_buƐY>W{i]qNc4O |,ݜ4v ƜzNc{=:C۬sshSrT0S7+TKvy9}(s9jΦ!uZZȢ:93y=ϜiO-*nP<`heiw# w^qz9tM*G<,=+a 8|d231n6* Bw =OhMܺm{צP0@s+#ԁ\Ց{]$e{VG#S2,'"ʈ@)F`2 +-Řq:aA{e0,]2ĭ+q`>̨v4vgO%ur`?o^z@3DvGyBt 0aL螥b¢];8ڙEa%Â}do F ,vzI!>be(@$ -833Jpڨ7\ɂMK1JjjFOotɆ8-M+ †tN}+ͬB3|}0d3p VL* t3}ĘSDǯ!~Z&?JV)J~7}u$&cVt^sG> pp56LŹ=KP5?FMD^dG ]f*1oT>JM@ tEIQL UпO.Pֻ1$ Cz7/qz|eo-GCih ϊco@`Mwȧ'ĤxnIҒ"$'/x!RS`o} NP޾B&p ̚5 XhȦ tϞ=2(363gϐ9388+Ej3s;^xqgNvaak?xvJZYZ(݈'`PEwj =4vv410Jp \?/U8Wƅucp~]OZߺZj֭#hnCmҁLP mo lfS4~;8CUGZh~+諫J[ fC.B-an) HL'"B:/(ܾ-KF8{"ܿ#̑SطsΜKj,ňXh>)CI(Q1k0j -Cjt/.@NTƣIQXd!wn(C TTBFz<,- (''R̔Nlb(,@7xVEZ P2anf [k!63z@͈a9c˗b8LO.ü1 ] /ϯ@s9Vl9+8/3>5;XYmV;j 6mjac}*[ ck}l TSQmv*蚜V 7~-]ÎUpl.:v/]]{ol޸&eKcXtX{D̞>uF 7;ci\9%(--B]Hܼ,DF!77Иsfbĩ&Ly F9ѳ>/Ȧc30{4mi蚛Xǽ<\|=_|E˫??1/oW6d/o~WrEhYqJm=4[W[mL ؾ-ڰb6yYXl MA+P_?ʕKx<م#GabRas&* Fn B6+5_OQwl#hr9lZRK&aj^:k`@Wt@tܜ`k_9L^bimg;#v6ztN{XkƊ sw5.Ђ⠃)Tۿ{CZG@hMu^X=`t.FX n.W?W(/qɷ8~9 ǿpWwXs = 8@Oه?eÅO_}g<,:]>w>u_{ѽΫ~x_)oտ=2(Mۤ~o^D zAڞSefatHL<FII7TT l?w .^8/ICUK Я/d!hw(ZYZ(݈Hؼ˧_큲<,^(N@I7wC~.HHdp+DZ#0jA@ć#.kpoF +1`x&!%ʜ,`dkTm@oP]OcO=j쌃O3 KN>p9nC??S筗?S,1N}5T>w k;]˟QDaAP!=@,|}o8EoOXԧ->-UZHKM` @KY9sE׷=`@E_ 59 9Y^=ϲ%8|v؆ӧOb"`JhWEKK4gQ~Y?%TOE+ T2󿈚[|)[oRy@"`8h*`*eK[8}`ncAز|ÎUcf^5V3b1X5gM}+z޹f<9zc3oJ at j/u}mwL `aXkUbشt6,EU=VA6RY@ ?&\,|W,YQf> n~͗> .ͅg`VL{m( 4+*{2>3}()a׵ga=\;h"43` HK Λ ϥѱC;!r@a5r sTge##= :fϚ!1q8q/T__-+L,-VZR3C*~b?]XtS#{/uV >.2} PE6+p D Q <Ŀ#zcswBm\ucq=#S'_C}bNWCO?e' xaSY^_a˅m\E>4ث >oS۔߯_SYf/Gxn)ut QU{߿Necshi6[IyC1S& :!shHЫgw.l3ME2<Қj?¡ M-Yh {%@+K˖WѲ?_ 2H\gN|$>k*uxF,+f82gSW_^.]{o{V Y>k;%p>,+s-/>F)2-fE-56-5%Fi ^Mˏ1Xv;N[z()SVZu h+/s1Zt[ƖbR4/ w7a(**H߯/p8;%T!HNJcGq:ze_N-Z? Ж ¡,-Sn/hVP3SV4:[*X5f'CjڞhVO=Ʃj'}4*lXs\"40flY)ξZҿeCGs lYfOrsa6rTԢ;~(`u^._Bg*/W=X_ץ߶D<$7F )8t /YD&& qr[vޏs"cFX$&# %=S@obQ֧z %qbcY6ux(16Kq9$'< R(:fd#5! s2г  Ѹ~!=%)9节/AQڥEy)FfzWd|Ꟈ4Ꟛl$ %)qY.Biq9r~:bҐ)YTg"'d#&yt.H@KJE"ODD:IOd#.&uA|L:c3Mפ}>7'Ɖ~ ︤\\~S 5 mhh uY}8@Y8f?g` ;tvlIskKx{Ǎ) ]XX Cix})'`PLQ͵p\ TUͦ\./t^Pէ 9Uq~0`0gc ~hXpȞߥ h>.\300v%~=/|"~͂:V5P) КR"ͭ-zj >Tcīy5?-}^ۀU |W5PR,m` 1td t 5*Z訩U- i,Mxy*":w/O MEYAXzۼz}W%4@!uf ;]Xj‰1s#Lao c{ ofs# cjw- mc}]KX;fx>pw[q[GLDAv1 m]-s O-:WvwFvosG j}sc[X9Ҷ t5ҵyр47r5k=khkR>fC={h9FҶ{ A4&;qER%f%Z-ؒ-iX0VX&&Κ9 kRՀ>DT@SCn CXSCix?z){h]CYZ(݈7ge%4NJ|'u5a + ijkNPT8ħC(ItoО}Pl8ك}.p-[QXr||MՅ}1|3|?3Hsa Ɋ<3[ex >'ccj `ϊ#h Ғ2 @ϛ?'MKk)%徢ZC@J@84SєW^AaptGRZPC%?Y9]JKvx|.'}ڈ6>A> 6Nb|WLÕ΍ЭB nH]45h }5k#"H6!5"dH51dX}k(;9x:1gq`X׆x !6f]##yS& #u"2j4cgcѶ01զwq+lanh wO8YHIMZСp"&tT{ڻ;Ҿ 6xѾ-m`Oc8;иffMp'N+>?^d Njj2}NCtAV<@¾y t/_i\.CEȢ'.Y";TTz>А`nJҡȼKA Я14.5RXQ/+7>^@(-OXdf7/&W. ˬ޲efVy%\b[VYv.<-3bag [.x|3\fr7Ov >0l`+k$K~͹lnafeͥA=1ǔ7AeZ8(|![c`u`V(߭&f%ml0Nc#6!0!0fuqCC/_u-vihg7@ I_ho 8Cv05r&`_Vh tagEng#ِ@ֈ ZW.Q3BwRg! ĥMAR4vflt/P309,S}+i7lk|_@{9IzݺVNjp%P3w+FloH` Ҷ\ mamj7k 2fZs0Ds^Vp4b -MMЗ*291!>0{n.066JGtAq*=hx_WW>X?-)zeiw#guT=7*Ni\OHiؾ|a䯩0@3̲^ªAm5j0p̜9S1c0#N`5nmzJ.[0vhd;e%ksvfGwW,+KddwAt\,wPmŸ:tMds[k"yY "bb14CB ЙžTWG+Bi4 SLD(l*=kryp-<|fޏHL.c"\g3hțCW:fӴ9lFmOښfVХ܇W[Ӓj hk{J@V>a - : :N(4)Xn![ xV9qExepne֔& PR,M_MUF˅@ g,-Z^@F;,c/D\JF} ˿rOFc-op^^=lYTex",z*PJePݷ?IS& [4,:(y|>7X8Ъ#..1{Y-1xH%O ٿI~V 5]-1P0r0B`ig# lqЄ=1QZ{:vV03v!X9I5 Pk-z=[N,BNl\:y2UC1 7=6? UO Gec&y)C6ppf!<,f-u1uttlۢnhouqNL_W 4_֒mdhei5;8?Ÿ1cj} {Aؙp z?/S]]]ڃ鉈Z4>YN2O:q3Ha/.C5g;osUvafdDiU^'W֦ee{1 .'Z\(,B)~ ֎nd[|ŗ˯P1p"ce?t@pܼ=v*BP{dBN'by]m4o;ĹZ"O;LabD5 LFEXQ@# lafÇ#;֖4_P9"4ɩSHW>""';,җ٥eQQ%eUJJѿb0* O6l،%#Կ35-Ž͜58#5durAL0=oPjGcL7.YSMmhhY5p, ,Z/ly訨(dffay?,, puuiiipqqyh~> b`ХKyMUZ ҂:[/>~FMmYx3,ݑ### /`ĉA}}Hݶl2رc*^d z!>/k_@n5/ t^]ːڵP7kIvf.b_9e2A(ǬL3D3&aU608K A4gXhd;xlWbP栠[!r}n^D}WSu^]b/? ݳ"Pϊ4uƆbreÐR+.f6/í\ &HhVy `WXUWO>J3 o3]yaC\G,&!S(*  BI jF !95a`j(cBUi*FKkym;B@ڈیlEmo {S܍6=}aOlc]5mPD0/3H@Gaf4LB*zZRZ; UC10sC`m̌\̙s8O4LƌIb#`gaOzv TUz}gmjPհ{k5_R_jMgRߢ}EN":zhDGG/IWgUUUoPX1ׯhW'{eShh]%@+K˔=uTcQUB"88ПuYf%@ݽ{wߣsAcF;w,/ӦcBW]]<$uMafĽ􆭓hsY-yO R>g`넙8!'aɀ G5kFaDc8x|NG*sF,DDEK,y,n3Kf`{srsHX623F||8GU`θ~' ce@_9Hag']CZffa} ǏSw:T4PUq~3n/ڍ{r*MsD^ dC׶sV5cXX8h e ZKJ5鸎Ⱥf M5sq+Qw4ThLUs:5ᄠZ54 E_/ o횕iM /SeR>s`%g~occcŗ{^^jVV~?T%nT͡hei]ٵf.ۏ1wa _srK}-~GASmb}>,y„ B`kڏ٢~ +. ~PՖaGkKk^vZt+&3=sk$> mVy 9eZ%S6}o ;'{3s.h^eUL>dU v.t=lnx>όjC7&"CQ[sW$#z 0%h6mdy!Ӷ-43v`8P@.zG#ڷ6fPmu k\jMfyW 2Ir47@3@2ˆA /S/ ~,΀c>h9*lPTiyvw|QTI9dbMPX9^uϿD(c3QN WTZ ?T9T^k<9SqY᷃*}Ip;5w[GRc pv@8_= nyyf`66B RWĔ)@:A]e/ե+^gיl{ &'AVz:'Tq:fĭ77ϱ}RάEYX_:Tb\3 AqV#ѯp.ƨA1Dt|!Ak ڦP%&P7$x<fORU(Jg U ԦbAIJM\dI28}@y߳p(펗h RV,尵w$hv }ue4/ey݅YB*2{e@eF^=W޷ȯ̓L93=gSykVe4OZ`VNͫS_}q]W'a`Ul`۪(hiC>ΰ,l$ a܏ƌM˅s;'/W;W<f07ugNYgj +sXQX;Ly o o/-afvwmW_bX05Q}0-g<Z}w!&De|"98!p {H<#Fj*̨_^iiQ8G@3Ρ phc:NLc , fۊh-ǏR|2~0@,ˠT"46IJh9~FBt:G"ԄX%!=)i1HTOKN@b\gi?ѢNI 1t>MIAjR,Ei o)?# rhey݅a!\\VW/OX{lj ̜ٚsM28 \ks943sO  ƄQ7N3k4x|xu5̬[KU皮 8X}7<}{qz'DNF">{CyE?S4~90>==D3؎E૥iAZ8KE N5YaNOe jP}u :F6l i~pV@-o@+__[hPOWzX L֨ .ڭ&f: :cti,uŐ H a9!b{Xn8ûcL4c[ ///%@hey݅:$"L w^~ (ݷb"jAU^Te; f1CXiV9hd@effES;vTL(tvl`n ;H3x y O{WYƎAGw/7Lُ # hc" !mKyK HK{;@z:⥷ͱpr|3,Æ08m,’z93P19Q51^w΃yj8 K 0 !CWp9J 5A\ ҜBZ  :vPynvs?}T7 @m<пl2("au(l)!n/}mD0E#*PgaNa_a@ 1,GcnU6f~3u=b1*ks1: b^bc$7?oO8'~m %@hey݅g ;kD$å{vAeuBDdCN yP7# X={vƕ ǐKDD 88HL /^˛j 7} 5E@;99)5SeyTC_G( <9/08mqW{WgkL.XqϜ? 16mwo KNHvnA>劬,88 @~|]y|H"+ ͹ }ĤPRvP)G^-mm&<Ϙg#{H,>3ô.:TkPrwD#6;r. f06pG+leC` ]]8x pM*m{wp J]I3 S C mSB)6Ahix3s0h-$:؇͐.||z 5K/7iqhG)mòFNB}扃k =s^K0ҵ-EIppHF2 DhelC[z ’82d\%Y\S_hzwKN5p g T ^9 uYLT52{9¡jUC1iY@͹ U6:qxs&I0Hs7 m=vA3_\*soÜۢv\rKqI.\ Wyg0@G`uM֌e{aŤPUޙ(ICAz8/Ĭq`B.ÄlL*}0ep>‚2LuEL=ctjd_q c>3;p*/S'/>M0v@2 Jh|'Ms űK9+7`R^" ǮUӧ|n]8& %@p(. l`O1+ М9>9A(<fmC=XI+ 22@h})3Fͨ~o;}Nc˃#/(@@:fu y {y#_GRa`j,' μ g{ ħ$Z JrZ2F(تnXʢxs03IBq"}bV@jAW^X;48 ƧnrB[8hCh׺5ڵj,\!Vcc0a+"]ӑ0*U}O S7?N^)PlL0~h )Wd f3A1Sg46C6OB0:[8uiͳpHP*444we=w^ ; :̾v3̫pXxX|Ç"/~VSsr! Jީ7`}-Xi7caBlL.E=Awn4,u0qh&n/\/,n߹SXe'-_KN0cL;/PWODx S' 3g0m}^[>4}@3PGb%a'b86:fЮ#T;h}v ̝ac܉1|- X\PSуwp z^-~lKj8 E|xM^gf-NcGpũA=34GaB(|./Bjj"݉l/:J\y _EPA z77TCw͈]Pj:Uk}P/ԸN8Vaݬ\wKcDz"2ʈ NV vF4dD%~Nv@nr0Wcvm)jc@W^z>rqypEP` \.C~MPJV]d4+ l`噁A ,<ԟZ>'1NsL<_~9׿ɣGBmf5҅ }&%1@?8У's"A VX+Wv Ff.(+녚5ӷKPح+lkG;a #!6[ݵ݊ Q;;FToN$%V9&{ hCWgڧvS#u0wAzc3a:΁۩ P5ZG}hMǚ ZB`%Z9K*QU7ZR̠Bp}/r(7}Q-]+ a1ښDtldͨ12g9f".nVꛋ>y1փ M_Z蠺OWteAb'oL_y_s(%@+. =*~N8""D6xb]@jaTH <*20 +O" "P~!Μ9N||`bi4nG Cp߰3gFoߺ1cA"H4^Ȥ# ۏ Ot姟kTORj ]]˶dOuǀ$ JsÐLOܺtƎ0 hf0V C{g)3+ә]qd)D<8S$ Z*:޵&o[q>`ZlLL#]PW1!&x&H ZNY9تʳXPꚜƎkNi,<Ь(3$˵&Hng9$8@3@)_[ZK7 1A<8?MNOb(E"2bQ {3=؛#9:Y r#h=1bpo 5 ctԡ%6CzpҾؽَ= ^8sTcaȌ1upLK_:J~7B Pt-a  32l3P7F(C33+n."F@H ۰gc@crۡc6~FΊtn<^푒*&$EC3SW*v9,?&ͬ,Ĥ$Ԏuݻ8rur Lqt Fun]>Mm/V4!65/m'2nZ׮\GEyp "٣pX;g@O fu˃[+=vnԬ¹spl.X1ɡp/lp M aC^8|r,}3H; /* 5h"8T 5DNic fbo^yP]ےXX7T8gh&@3KPաO{]Ew'h@7Ё>n*a=pb_8T/W: =: ~( G+8;ZŠ`€jPnvS Ř5L^ zVMFu ݻ1~V^Ccr[D}]'xfàK1axf,]C0ؾk7#hv= Taض~]^ߍP͓e6S{DDG f/$7ﳍ $&zy 4{njÐJܽ}{:qBwNHNk^cYf|\ uYa1l 11:m*VT#&!9UA.^#, >ȓ 9q:`EX4~G5nTQNE00tG V,(̉8wR4 ='ŷ?쩯YLLD^v+Gj3pAP$N#ps:ōDFjO"N(@'v[m3 Bw 44[9xAUn0|vsz3UKlNDcں=xٶMB {ת *#!Mmc:\0'-q۟K9_ı;v?F+{]ulG{˙35=õDi ZĵhʢJeXvpi9HHKFxL4Ӊ:\:s}vldz< ΣqV#=ڙ䛲-'t *st&L#; G%/AhdJ]9h4Ak'P. -euy %@ 6=thO!cҺu#+99Iطa!tAV|0k+f"' V93 qX uYX^}[wG `5pxx} ޽/= ?.^Uoǩ#G{ۻmƆ}pmճO Ecx!Цh:%$U(]$4*L;shch zgWO6q%K[k>un;xZ6v1FLȈL^y"(a1͈cƳx{!I wa%eړR,`va13o"&uvN$%Kڗ6V!"H4g*IRB*@u ,zIZ_Juih/81vфHlؠ% SҰؓEdz2w:7Ϲ$_uޱ|\a~*;f0sUwXRqc,k@(1+KظDKe;G58a0v5\V}WSb#^UuXTEsA[c*^? 9uż*D1LkDke.!ڊy~:fPFi ZĵhgG`EgmʗIIrv9: 4ɳ.eWtu|:# E\R<}GA]}-r ru̴}݋/|.~L43t⫕fkfYYh4ǓSIχnvpvwe"YEQ討pPh:ox{L5+Q؂mHz=bDǒPnAhTb 2z4 x3Zu?(^_tSfV%,eRޅuj[<v,2!W!"uHk6l߱ (IU'RJTg2AGpd8.(<%e%|97${쑇I;9w73t_i<+M!gIemVH,TJ{>DS̵͡Tڍ0E. S! o e@S9}.UlDxDv""u#`;UZz FG9 ,B"HZŸ܋ɧ٧xn/~S s@31O]n8:?x&X`"s sI:2[n-1vrsP74z"#)e AV-m$/`EV@R| Bjϱb 7W7_Oz_|1&O]_>|WxN0AO?09=p{)IhyG>Ôas]uk&ō\{|ͿqFtfz+c\Zt* 308AM-JR58e19>wnj,hB|=qp6ac[ /Ʋe;mq/ QfR[QY/MK:HrIh 'Wr~"}NY|[92enji®;#6)>|CedP{z#-+/$(K/T(@ZAM%'8 ;s4c]S*3j˿fW@N< 텓[nG.Ĥ.CD FXl;CXXNDT5 Ӧ7^ɈJ^fGbٖiϣxpfr/ژb OKKV w.EmI'>x" ,3rcݐuXVw^{.~Afi'~ v)K@D*nA`8{!ՈL\28• O:0ħŲݏ'=bI~4cR!Kpv/p:Squ;]yoOtkc}r$A@nJ>29;WMa%3e:_PѬwo:~4>#+[67e G8^%Oa uIL? uyyNЂc؜q|]9-Тy|ʛgNϱ\JB*<} tj\ǣ=;s1'%UnXS˦ԮeXZ˱;3fbIM6-ǁsyLl_ 8aϒ2,C!Цh:hK,\lv\6&)9d?jkIQY X ]Dl& L"#NCc͘yڵx7Mvݍ7^{7gB=]:Y9Y eįBȯXޡC.S[2K##fspqm[8g$7_չh.L<ѽ|3Ғԑ ІШpuŔVw$?=ZFamki /ĆVxl }{Z -v|J, ۖncOL~j8R n=în8# |}r !Î1 Pִ+i 7L[&$Sls/j?rb5vpdz0t-W“HŶ|kx_=RG#k.-dr8Nezrc~n%ҳpZv O`QNS=@w YcW@3pd|vj v4`miVay~ VFaWsvbGG3ϫyeڒ Jòd,,Ҋ$lUTlhdRݒ]sr6ASߡh@kh)c+ $$Hi"i ^ B KtWcђE=g6kW#:.;s~[歷pq;x3.t. ]BPh "4I=&[L%Tk8OcǺq7g0ǴpTXbW0`cBczx&BǙ(QD(snGK1SI<yqzt - q i+lD!=g{[&ְl ;)|[=W.ϔyv& Ǧ[Ǐvƈ lW /L-'N&S`n/Uer\ŁX>AX5 'ش(7,CZT)}`k{ۊv㑮Ӹ pw%gW~ Lٹ֋j_'/ &xSi[;6Nruhybk 24N<2ЖL},Ӡ/ms tBX fF`e~,j2+Ұ0*IeB5|,,Kœ/I@[~4/.Ibҝ$X1=K cQ~C!Цh:%XZLk Pl L>":TVx{}{v4jDsk3*guuɂ`?q ?(z <<wAӬ&-  "Lb,geIN"l$Zk鱻y{uٔ9@'&ۦ̫->ҶoN\]f1M:D$AX\'-F A`T=z0!waǸ=.&Hԡ9"B?"Bb1}gj!)sFV^+6,AJ//&*W`Krj%牘ps elv%sWVdęɃvSr4N2Mݴ9ʄ}\qV/Ⱥ heY./擴-.=;)*S#$Dkk@phiiA`P9T`DEk0Di4C\\rQPPR!55IHHL@,OJJbe,CBhر 1cjgDUef԰RqQ͑||Ϡ` 4,3  eFrvTF dGr4tjRyD#,T8F!;\2X=LM,R51c\E#",g%~~ XxW_ B̞ nm2l?͕t]ωCiV0#meuZf/ ^@In>L/PI @ Qa8ܵ__\_Hq0:>xHCbi&bb(++۱xb.:t|lժUؽ{7>}}a6Xp!obǎhkkfv,Yٳ= B7pٺu+_Üor /isc P,orӦM3;%U;a2_tz,h.K8 ɳУM`zӖKKzoL_ۗ+e-7zSCvR0e}:>c ?%ǘtP3nMDWB,k^!TC@b:#0/c}o3&1@&')1-6ML(-BJ:271>6n+1%ܟ,TpLq9>|999s;ӧcN| .$gϞEzz:^OEn ]OIƍz,XKUgg'D?nԎ&# 6MoOq2 $%&3kzI8 =tK8@rB'u=9Y/jySR`k+ΰ/ ոQuq7]sɤ$|+PSc0ߔ؞Q('DHJƌpʯ؄LF@h493CQ +$hZª!AXXX/;8',H9mGR[oᮻ‘#Gg<+Iɏ>! q&!&2~y.eMw^~ ڵkqQ>2ط~;^x!?1۶m-ҥK -驫q4W6Bee%eVCP}h&{hmCIExj:q21m߁ .͏v,} Qni/W?'^WGhr6ΕW/]]84n|̑Ll}]77R< [^C#dHDSV86¥lRg6 9HsC ZZ(s2+JZ@z"%3'bnU4sWP;*$bژX+o/!xo2>qCki:C?p>K9hk:͛7%4&V?-mcwpYX3ނg@8֋* ?~;z!.x~n|s)@Fg6 A/m7~˅OMىWT?u}x{Q!&Bhh<0y2NnrF=7hžuxkS^ y\Z?:o{9b{.~{eaTޯCFqG]/u]yqZV?P0K8J~r)G I~zڽ:(3~GGև@јi֓q9_)N{z@sv?»'/gWލ| >?_?QO1$vX}G-=FQ7}분C/7Lz=fOx&ffڒ1zX ~P uwϝ2'NrѥH 4Gi<b>B㿇mov薶(=*L# 잏} sВ7cyض7Ǝ]ӅvHϗ'F:@ժ+j(Z)Ηc\gogG haɥyoĈQ6ng( Z/h:q' g23y7~Jh&}YϕдLLFFkGD OT\9Kom"'cZy-!" E)ؗq+x gawu c}n vΩC<^8U^X()yID2v٠2Akigڶ6gsdhL,XYgӸ\J}6w ȱL]@SFysO⢬6G ͗eی73Bʕ8m'Ǿ_سRSدwzUpgnB>/Z'#ţ~O3ih'<c*Bkq"GĢBM X$(ofd9qXON>&K2g g"qN"-+ﭮ8\6%S+1 Ӓ09pڍN(KHH0IF}^h<~:a wq KDQB7d$Ky` ,Qs^֌hI1@녹lh@D 9&5g"Le1fF5֭- 1 I*hx9+p.;KRΙJM\yu|GtycA(Ux205fְLSvMud8HW*[W˗/{;(t:-ܗ9X.؇[Ŧaql9B PDY!2}l.+@-&5@K1FFg3$ZDSWˊ(_H욝ZX;η'3(cߌ,tllm|Ңeb{u^GZ"UJ7On@i_+ Ii`ꆆm+ R̙3\:5]qP,$et>wax7^Wz˜}Oc}`0>mcH?>3,kERX2!k6a15c׆_U`m:eg~.mk#Z߻g`4||?܈Wgum m; *Gg=xrop* <+[yb/j7?7]l̙I/Sy֬Y: 1Zi:%rd3ׂG?n(\O rnw~RyoO|W}OT?3JhR뻍h{.Cg ȑc0jձB;w jc8&vΪ桌kh!*<^ps=-Yt^;܀ f{,! \=k:Y!"ϚcI.>?;FK'|!8TLOJPeՑayG߃:n{ot|cSKrru*}SI>3h.+jXN~=vH=vpr2=jtk~՝g789ҊGD$w7/0e@_@p$W[]ơ{Y*pn _]çw7e1+o]Keۅ2bZO#'p R_Aնhys4ޅ9GFwu:4?j@1n_W 4d--VvNNs#C@ $Lqn~[(^(..EQQ z#F$n@  BMACB*$jx,]_8Em|X3Tb„PhW/n=M&-p `ٕLi[Uێ ԌrT m]PEaۈd`\,E\ aٵh*R /¤zvnA+ e\ ga#nX|&5nEPl\!JVge[=z=)PK,/Lccd1K @Nv'8z#!BDEi[歷#2{&=`/DFrd/] 7fų1!=69U?vy혱j[[Q}3gT5 |a֠{ C`M,4$!,B WK8 ̗(@51j]N 8 L]L8n*λ@`DK1K-12 ',mNŏ~$Dw6bSS6N4Lp OdUc:4'U}s ,LN.UR>|b2hf6ח4 5X3mMpd_hAA--ENc JQV')Ư@[Zۏ;47Gl|KLq&b_=rXP[&n 󉓤6;Ҏcƌyi1aL!!ڄ \lq| -Bia`{">{p Kik\|Zm|zjJ߯@ h"Ӭj^[=icƷ[ 4:R.S.^n~ Fbᢥd" _P8$G"h:4LcXG"BxMQ_Sb|C4|  BUh!2*YQOL-ոke:ޞ/d Ř6 S=Mnuj9;!W qKc0tzjJ:A7AcI'keZVU)[X536,:gboi8C]L1c/ FBCK!9- !1P&ȡQqL:*:&Q1KBt|2KATl"ƙa7qp?I(qU!4-JXmO9v]gظ XZ#D)6L]L9#8, L%Ar 2q*#M+Ȓ$rI+AvA)KSXܢr#:&~\De8l?4Ie?&do I;pm_,y6׍[NM1bJf_Gg+0L0@Rj&2g!3P2#p:g_\ @*NIy5J+j_NVSbĈQW4?#A!Ci Z  6Z| eT@Ow5\i&La\\ H+Q临 e3jPQz̨f4rf*kg6SAEzRhZGf# BUh@  * kqkL@-KhN@ddf>yQcJ'C$԰"-db܀ .UͨũoEMC+jg s0k. m.hBǺ=V.+Ҿ"D H6 }qN ޻IwSyFLOUr8:w΢<6' *xR\V%q&/QO`q !ˏ#x8< ؜]:hB4/'DZ]c\ ->g 44 Ǝᱩsa; Ѩوz&ȴG,v4eZ磹mf1g#*:njS=SքLlsEJ^>m( ⓏKSYLY%'s{F ?wo㫻T9?N&kᅦ3\)Ԏ9=˻ѽ~e}r/}/sc\a$FG&8~Xqkkr",@ju7_|Ŷ7&ɦtgNt[jsWsH۳I\Վ6 b^rD{}H!t SĚxh~;~{.h22XNl\n۱mڷX!ЦS_<ĥL^&_K1'>g]K$1%6rв0uE80gރ" xv&.տ\Νm0.ܨv 964 BXеe̦݇gos[~.E@x ]=0cƎ6w9cǍmh0xÜb5v-mj]FjZ"I6 Uۇc 1|g7NO׎řl\%{vL*8[Q:xf&\t,:KFsMU  EgS{bcT X-R{@1wن%nYf$d#"6 ~QLּa3T`> 'L1~"]1*}CO0S<#ƛOw骂ʹ^/$>!b̿3)ڷRkBM@K H}Gb#HzL~Gu꿣 G6UHwbK|"%`5 R hNoH*}(8*kO0x#(2a1KG0 <!G'(w!%%|\m%cb+uho8>K+FVM6*>~N%qvLy,=*% =./BtZ gLqqzIZZ;ZXٍOzE!4P @  t}-$_țF=nx'ON흟DoƙOaD+[bTL+[GX9aTgXMu֩tdLqa=+e&=qJzXҁ?n+;fLf4ߎ}sR"=F1LbuBoேvm{0d[qc6G&?3ۑc6jfk'YO 9|<{ưN}E!4- 嶩qco?X۰#CG]Yȳ$@B`P,)R to!K,jm erL_ P6&rNoPQ)j}+K xo!Ϲ<"~m I%) \W -!Bh333 t8hQ889أ`㨧ѡ8)t *{(kN=q"ו}ZJ UQ^R-z\wQ 7=e\u{CcWtw7NG~ir=8pWz9j%Y)їF"D\@-6Z~e Wgp 挸<#11R[Hr4F2FцV}c00x9 *LQj-T2( `?,uMϘ-e6Rf[2O_6\WNҢ_%Je2ZDEo1KLꓥ:-Y‚-Xcڶ\:/I}mI I"~Y(x@+ ,h<˨@e5l(вk(I"G/JS9fVS=l<_xۧ*Z&B(Z!|9 4Ka $յ,\Y &½.2j|)3&a<_VI"X 13 Dę`r{%"2,u0+JfFE$q6P%aVsOi֏B2,/aKmcjYd 2ϛ)EYk-o$=1R߯lKJ~L)ju㹗crl&hd2,6h*'**,Ҙ$\_!<󶕢.Kb)K,}Ju%s\W+1WΗ1'%Wh"D ,Z_`bIV y֖$Ά2-gFl^&_D_)Z3вL 5c^ɴ,r\*%Z@DkZBj2- ,^$2L daVygmY\Y (Ɛ$˥e>cdfuYI%k1I~e6by=yI* }~CzʪryJy\Y?fy-B!нA,hB!F.V/E&_l4\ʲ1L94&ʽ!ge)Jzf{i&rYiUy!aK@+Ź?h&A,J6+ZhD3La6CVʳTgjԧ>GHR(eڸ.b,EYjI~*g.KM^JC٘$:iy<_<61}_0n\:{^BEa!7Lu tZ]x$A,Zk/& 3+G*t`B辋,By$Lfe] I3Lu"YhLf%ZD,JJ ,2ԧe^'1BEa! %KiQEhIM3 KhDA gmZ>_@36F]PKhYUpiڬ&W,$ͺJ}6?0ٽ8+ZAJ%Z'rZ'ڶ4ʳ@&c$u.2L,Rz~,1j+tj^/m)c$|HcŷIlق}]KzmWfͺdʾrErkٳD[[[?(s\2̝;`y 3 , {e¾A[j,6,,Y¡b.+`ٲe?)zo,_O۶mýދsna֭;;;ʹ_="DalPExqKWWwV=. 1]v@*֭[wU__lذአu}eӦMW͛ؗC_Wۯ;v\;w3v"hyՕ@'_ ?OW Ⱦ8p曯[nЉpUqС}/{o`FfwHW"D (//u6mmmIޘ7oހ0Up«fѢEWŋ͒%KҥKͲe //_oVX/:;; A/V\o ø߰7U~aryհ=`['ov+F"D!B"D!B"D!B"D!B"D!B"D!B"D!B"D!Bnbe>@x(sӍgPR'x`W7) pTHmD.YjL&|Utrg7 s.Vf.vɋo ;Rv>>'uVr?S+Z_W%^/kڹn8̶|m*uAۖVarm:>#Yhn&d 9)/0rֽf2ᾳ"62\:gUe* gᣊ*#)ͷ<8ͦK.1tSYڻ'sUѸ3zӷ—-掫r{m3TC|x^^U2ڍg$Y-)^uL޸{$+0[GTۛu=ow,kgsnSk_:N6Bix*U 4^0s={wey*ϗ>Ȭ蝬Y0Ӟf7O"k}|%|8Zz\:P9ϗ?\v״*^2clNsg{uo;Zڶ<:~6: уi/SEM˅k O=9_v钦{<9}ՒY*r8.SG;_2]rrꍫ> tr:N}y孫Nzp8$/c<5dt`oPJ^vzc-)7 ws=NzG.f`y[9]:>Ǫ9ml=;1J׆|㛶K.-8isG֭oip}^;g/kYeSk*/ո:7v]:9_+gtR+O)+Ak}4m5pc91YS|?&u7ЩQ&bY:[Җ~gzi=/[jڷ9zRލ[L:} 3 M8qu]"i #|-[G>SSh^.ǵGMmBW:qt޵;Ƚuf1~,ouVfpw޷VI3Nnn]w۳jv8&֪W7T>_8=;GB_[NcF\NNͩt98{T3=ffMLYxrtso'vZfI-]V/[f!/]"ykKk5!jNWNI8O;k힖nVWwV-Y'p;\[.2o3OVsVwFa9p8-![$XD%hFji3U,m˥`"4N%ap s` L Ī1" 6ap5CT)7ɨ Ɔj64luCk:$˔0d VҡF tq 8%\A쉘Xk'ȆELkLbXDR@ ґasuw ;YuOaM+_8y~[?JRx]sDOVއxds& 6,9smdt j)1`bz԰d:ĔGYUil5o[=/ǣl$2@Gà6n #9[voMǡ0[[o:4}|zLy)}6&Cwz[w޵ϿT |+E rELGE;p{tZŶd,5:aInֽj~ ~gմD{]fsyx=cAfI y_Of'o_â57\+>Orr48z.NmzW'/hc6ap<㌶iM[S4*umY{6ף+SzO"}x XNdwsVFR=ķ Sãf 5nSuy{WBbsَԛ^@Yύ-{NH~xz@)T^LP^g˯7!o*e931]lMVmhM_a\#{l@}j&69A  OF y;TVI>}sLa-Vi~I_mw˞wwE+'CVűyYNRqOK۔ֶBM-2rXzAmO/O$Ƹt[/96ق~mt:4rdC*נy4K$]6}-z4l.`>O}2m#ɥ_ xͫ_,|2s@DȰysWZZ-]&)16åqQdS~i>zvչ$'8mM54^%dZaq|ҷD8fVL6qeG~{M_8Y>)mxin͊i [kYI92\CLϧAėo|íbDsDt"^7t#6?LƵͲD ČL6s+Җӌn{iSӿ˜ߡAZB_ǡg-cNM~+ZyDW'JUgx&Ljn"Sv+yo3?9<)q/DqD%mh{83>ϻcܺ4>xoZ>Lϒ莽=/P,99ap8ϯNrwեt 럳@w VOWWһwWW^#9_Qҕm42chDZpZo)8I 5|3U/pΨMrš9aqڼO

8n1Ƕ;nJףLR+yꏞs{S z#6vDFrtYĴH "ՆJz#d+s^u'Hc=~F9/hkf ־Casn{gLr^ջfK>t|k gl}krj ˗EUDh,ê4& L1J8ig5^H}-9QT?RN!\$P8NLf%u܉L:*:b GqgQ) [7']0>ll்گѓ[}-eiڳˀ11[}3`% z,qIn5 "i2ԵzFVՊt-|.b8He#if#[MC;s ̾AڊI<q50+D͢YUd%zmbr'5ilϡMEԚi]#I9M-,ӝȉ"$4 b7/eE)2Ɩ)٧q]b(VEE" E6X M!m[ F#-[jKa8eFS+C!Zq:+Q;oD&N ıҮ|#oK~wrIuBJmȑDF($/^KDJs妬z uv+ļ9΃RS0#Dx$(q Hj 1%!cbce#d=ܜtyK舍0RljtFS(xL5#ωQ%*y[8ְ=։zmqki022sĀ9`ؔC _૤m$ Ze) QNZiɲPt-I9"c&_BJss! #~:t0AN@ E}pu Ep ڤk2R2^>q]H p"4)N `;A ܭ9|V1Nпֿnv/=r9S;$(0ڑ&CE_7=^ь0랽-!3WΙƌ4lI^0tdd`GpXԒFFF0Qq<@ B G5;(iV&I2=++ffwj9`>Gb{u˘"ߪN^M䓨-x&3kmв9g5!Wm]+\&;#Uqӝs<$=HNF<A⎓шc-\"+JyQ_lPf\uю1:QUtiauJdPpcšJGrc#SZ [Z8/0 kk$j@ ; _ jI zV`5>o~U1/tO5W+ב$]GAVD )eglF/ n9cW:S:/[9srqq$x,!yW6_#%hq 1y{2jHs^IWsX$Qɹ4Sc=#P< i-:KY#%jeOiYl&Ψ۵wGj V-&pCY V\č*vG#Z1;,{BnWsZ)XVFW[PZ2ӒmT53ȹt%o>"5oM㐒5y94la)ו1,[P!vD$ q& h=n!A\G[Nv1"ۢg9BωwE6c5/][O U Lޘܮ$+Iߠ!& ƍZuώպ~? j؍;Xhjy 1Qa+jZx_p}-Pp ̛:XK!c֥'Ddƾ2!ZKo v׺<=_V]O/a~0qDQMLnܩtyMa++P)q."˥wsI~yjtv9 1"h.Vׯyng 0P I$Nw`2:J%Fu}O<R)$l6U}1g P/B1lrY 0= gp@ H(UO!âW߬@u V|B&vʔ]`US9S< vXΑg&P!c:2tU j|qrlf͍c=U1+_,W[GPzurA`iktEv$*߾"ڮŎlͺt׬1GVjK 1^=;8rv;k;@"$wȹ+:a p #i|pMXK/fV r:X2qP6lwAQuӜurK"d%d.V/s\y.&tIώOiI.J8>0hZOڊ]ʼn [kJq#f{^k))UHWJ+WVyiggSc JyrKz&r9QTo^{:'ӉMN;5,В|lӥN5rc6 8}u&=xu6{]>E쵈fĮYF"Qu@6#su@r nB|Ҿ0NAm2N vp[( ˴`jb_Jx?bӻ*gK+'Wyε*.T< q 5μ7T+XE=! CY(iFum_象UF-zUǞi<{/ǔEg9S#@mfP-TS I %{?Ph& pjDsM+%4:]UJ<Ӭ9zᓩR}.^W+9¦3"x'ֿ7UW LB)?Q—dAlY5 8Sۅ"lE$% +[ua6L:GtxO5C"  a~rdyIU\VAOx'#6y'rq̷J2"1m$]KYk OI#I=%!'ﮙ=E[ZfSw*^&TDl@4O$5D6h2wQ+بCi5$QJj5@gN+["#-c#;I;+:;T֏b(ƢێLM\p`P#Xwߛr1f5{5^1N4V7Aq Y;=EFpv;q8X%pI4@8Yg " 0á6?B\Nd4lF>5:b@hҘ^kHlCpgEdWdC="^E\HO Ievt9z4R,sq%g`x7Ec<4bd(oc c8n:hF;G9Qn1`^; p,DB~N#cSD>XG]p ] H~E0W?vF8&pbg0y< xO@ _O8v#3DMÏ&s\8=l' !W::::::::I~D2/wyX}kO~ҝTBnZug~KVc~rrogNtⷫHkqһ7vnٻwfݛ7vnٻwfݛ7vnٻwfݛ7vnٻwfݛ7vnٻwfݛ7vnٻwfݛ7vnٻwfݛ7vnٻɝ/ΗK~t:]'7.>(u$mjouU7cꥯ'{4jqO]č:^ 5ޥiN%i X$di&L {S9}N{S9uN{S};Gkfz57M空k=MYnStz7r=2p` MUX,yE)F.oS4z8=EQ4_Q]9ӱQꎘTtz31GLg:c=QꎘME_ b.xkVFsrםꒅE.YGtQ04bt=Taa!L;ږi*3P\i]12TY2^ZaLiO1;WTNC=Thn#ía$D uXiȿR̘dKktG 7+K);gc,tFD ?)IȎ_ܸkpK/=߫ugIIUdcPy`w VClPvՎj^F]6%}ײjMx]K[eq6u8ZU5c-$GH0(F D3cк$i=J cq&6~ё#Kc+qzXJ3VldY9oIb]I3n>DqIA r8/Tx- tsF׬n_/sszn3F)W-b?S2#+.\4$\_\\pj?fNá۩i[cW7' ƅmyVQQ?o-yN uai19Wbo}Ƀ1ַ?Nzi{},Ǫ& lq$ I!+dDjl 2 | VM\uA:+/HCE/.5Z\'M_V¬@mVq: FQZҶa㏦~ӺGVVU=KE}j1aVS?PG?^k_˅Gw#N@sli"|m'\_倘QϬd*mGq.egT!ƀ1R(XBY,3 ۵P^,[+'qIMˍ־W~VGN~ p 0ʊQ'TFu0 # k{Y>k!Y]6TKsQdH#Sc4m4όMap4oB ߩd([i\.MyrM O15-'q/kC>.R{iB$h&c BٽiRi)\K/SJ_ЉϷf-G&SA ]_k:ĥLœ.%Ng-y!,"\TƹK{ t:d/ro$F-I/GC8[r)7Kw 1O[zZ+""`(W5EԎ7JW?^\6>5Z\'75qDX0_-W-A(izt1xӬ 5S!#p.,dsU}5rTAMg`a)1kO7S-ރG0Uf="5ݐi7אiP5(]\eK06iC\_jKG8ڒ&,L|$ L1"Y5,M|SAݒeudVĩe4Tw3ą5YG<lAʉ,xƿ_+_d}]ev#b;` r+q\ZYZ 4sQ[&]V{Mo#_}^|k%Or:*7xlEᵞ{d1BYh)pȸ^k"켯uw2YV{+%嫺W݀~%?e*.Ӆ?+Y]ewV9ݖz7eYgV9ݎz7eU'z.H%`w)]ֈ{I}gݞ~E eN轚9x3d/,s[-kY碖XL™T+,J=D+,J=D+,J8i7a6Pdl;.fc ЊCXɳWi4c}Ǥ)R dMOKo&LE^͝9@{Il#<gȓ'ɱRf$hȶ%{bgHldӭ Ьe_;3,;dz7zF%{si,l~E3GIV*J^g+ӑ_wyIU$V1U孜_$S6-5*fܶ߫Y.*Wv_l?-~DV{V{F" &\"=0\j{ 'eLl$kIVFюCsTv֚) PinŧnOX4Ox-/׎MQZ\ߧ7ίwhX-eU]Gkj>*N{[~1b=3|NiTQ;ӑ-ƟFJ(oR:ڸ[[GS-zP~0nҏyJH˓"{z'h?^TECxpMȟ^mѲ|qo:+r!6콅jKGnR?W*.1ߚ vV|V~0yq^+\l N$*cFh)F]|Uݿ_ȼnXx,T+7I(Ta St: dMiv[4O߫ "WRZZL|[@5+2woua$>E`Ezz{ۜ']M6*筆s}HDm?%Uj"(`` _(N l#E~S<[r2E왊vr WB;Pa691RMęˍ_W;/PkuՄư"Nc)w)왯zLF\߹)E#ֵF]qL?ܹ?>ة3/i][t9R.j~٨ynM-Ԑ$@< L p,rL5Z2=fO#Z{IS2. %X6'3۱djIv)eˍ_􅛡#H&9x+d=I" ˂e+kJ08D̩욅~VʒUZQB߫F!m~q}w:U;%2=OY~L Mk v 詎D3)"{qq~UR}:{j$"ƒKw 93?W7aievH&xgMOiMi0&"\j Q#NѲS9GNHRo\M_˼YqIcD7$xkǫ@Gt3X.+'^@~PȪt2lWyߖuooQb~٩ՐP%:m*G r"LʐsTvcxwjuH5}C1׍5Vclˍ_y,I(Ƭme<6z\ 3%ůlYѧ7孙ժdiNGH!Sc>a4sY.FO]YzRp<GmK۫Q GYDP8QsSlέݤWz.WLcT&4vsM0#:K)1e$NSuxxXG'^wvIORVA5"HV U*^Un&\6w:[99'>xj"bef\NJxχU] 8o\> rz[q.(ᆠT=D@^l^^l^h]1:g"wevxdM1!)+SX^ѯlY&zDif[TtsE{ Wrv~8jb7~S,¿)[,vDmv9 zVųObPL-ԉ3QFBʏhfX,ox:ЗJ9ߎRÊGQIXNj=݌DFǼk#%]FԍvG:ۆ%זjMD%|쥬 ;/Q}?RۮnbKu@Ii~݌;M60݈o2"Hrqnݶ j5? mƔn^hb7zX@#AB&t&vۊ*4k{w?Wb`"Nq~W6y l\/h1N;fٶm6ͳl6mc~GV"Ej |ntt7QsߖΔΆ57~B'T::O)))G>Ky3̑YLӴ6{Xؖr*CGH -%`3͑&S#'|͒, #͑:B%uEdeHiYXKXiJ5jq:Rgj|9cKŗ1*|?s7[Y*c[53RP2a}3=3bgLfC-4Dk$IFZ܁g3[9mҗi/=+e/g3#[In%`e}VJm-s2SkIi,.:[%_i,<8Y}3=3=3=3=1pscm%ga%ggg~ s<+ޗJĔȇ7]Ya 7uBvuA.V>&ۙpk #BهfFnMfr^6dnlZV]aJ2UTcT ui[115\DH˝s.tDnjF{ #a({ ۣL!1A "2Q0@a#3PRq$4B`Cb?b`8-bżJ<]$r(t!RԣzrSGQ*\;lK%6N{X&WOV[(Wa2c򣨟7빻|؃4\ z1dQVDSw1EJRZi`q$)eRݥ {XkZVU!iK$E+WȾxyqW#J"q۝TW'`ɧ{"ns#N:E7.ZIsm oUv%ةϻ!ߝOI ˗Z[UbKc̭Z4E rmՉKT'اe{6+dt*_q"Vgk~OS#]FJlsRI{IY"/"εDM6]B<ƴ"L{ ZV&2*I][B:ZS~dTx$+NIZ%C(luZ%ܣcDm$R{7THjN9̫JتMHGShbhԖK,޼9M&bh/NoR+T2V܎ܩz[lH {sw'ȴuqə|eK7N׳..+w*%&Iv_:Y{)0!ӌV+_q#JKʫIjd:Br}Cql̼ǹD梆MH𶽅g"webݣ%qGؚ1z8ntt'O 슐yй_eo啉&SqZ"qܷ/mRߕ/_p88MχW|=ǯXާ~^n%;bI'BbH{8)h)DM~T~9; |UB+b\|D?VKn9ꥲ:ègU%C8<]κ:c:ؽ?bRw*Tr;Cu)eo6__v.|v/+*zt~tj~tjBt*BtKTKhccm$쎕OΜca'^§7:U?kZf/س\fX7!\+l+=ޣ i,b2\?C85(A.Vuܧu!\ťCOةt&A]bJZVܔ"(Ėv"Jx_B/}bħɴN)Iܫ|CڕN+N8ve.#r>%MJV[81Tܕk\ţZ7q_QzxqܽE?x̵ƴ*V0sbQ77ñf~c[C!,;dߙ.T3ٍɕ"# /U1y^.OJ-CEEyDq2ŽDh,5r'ҧ-ʿq_wvb\>^8I'cRT*qi_iPNoSȕJ$Ĕu*VQZEiU cWR9UȒQnyvB=eg*5KWq۔'QP+'TY:ČlKVoé؄iׂub81m(Xb vkF8BG㷉xgUCB>gs3[w++D)Rpn8I.%s8IZ%Nj)hcF)~ETNIBj&Oqmvh|ϊU榴>ӂ]bz#riFXW@Tkfqu8}G)MKFVGv-UmdI/bk$EhB8,W<(ȗ Iz^|n)S JSio$˹+ZLsHZ+SOR3qz?m-jQ֝_!ا%Ni /QEH^̺4{Xѻ]BKVm=o8JV$~mjQ(T,qa{V˜v[xYEr~Oȼ\?kN&o 'K#j?,,OE_&Sv`֦-X=]u,WTq"TjkfY%o)-JQSٝQWbr1^㌊P)pBP*7?R=O."8npzE^IKPn(q8nut#OHQ^ W1OQVq;#d i(u8xAݜ+Ng 5Rr{j\ţS/NNeE܄o؃imԌ#(2(:>^8 3)BJ^(+lp22(u*EaM?Lbynph/ޒd)!FlTT֧^V' e.ڲRT*qzYGrCI9>J~Ĥe'(q_wCkRH㕢oKօP;hFRqaNQҶevJP-ޤ*TZx\|ߗTԧ--n՟،?4:P8GQ+y#"/_Eܧܭ*pZ^^M<D|ld5kH5q:֝ )Iu;l~tzot*p|F(F^r~quY,y+#$d2FH#%.cAqV.9 OOS<x4osOĭ𬻖[DE_Xk˽Os=\Vku|sCeoV7Kc(Tߚ1жK۔LYHԞ'Ź(M*×ܿ%oB4l>Q.nw41݇dW\5-P~Ikr{^2%%ɓ7jL;O֢V5'cZ d4噋!n+^w44nOƷ:bOoNJĞ&iVLə2Rrܥ?UYI]AhJ#bژL$\ȸ˱._H*%GMenS^*d)nw%VV$\|%+q9o8!1 "AQ02@a#3BPRq`$Sbp?fViC;؃nljz=qۥ{^~e˧ʴI*^)'oZJ AiNud5-:mQ<:Y|-%zv;j2M<^RCQhqIjf)ũ1ei3&r|uR,Ye_ЃMhxM1b}`F؛\2.3,oԅ\~e܅-L_˲RP}8JnV͒98;%c[;TbnKH#-ItpFeȱ*!Q*uKqN4[%)M,q3;43%icIѩ7QG߱y!R+ݔ~}HLpD돒pkdYb=fy.| SG7M.[!En({3?^G$MuE9REJ>r#%'40/WJ}66]F6$Wifr/C|iQ5dcpGG+t<4Ejt,z2%f'ڎMر#6.'\HadO22PJ8\_0RX$kԇULUS'X4-+bzw!)dȜ;<_&X 4f{B?O:D(x_c\YJ#w,k#ívc;kN!-76_?kb/TK[%,<1dW$pgd47՘9rǒ ܖH3iTWd3j0NÑN Œ1#.#qbߑBHT,=&? (-\mBO1EGhE% )!C8Ģ&܎F)1piG:[F1NtsVH .T&LJqC4uo%#M>Pl%vǥLIKӱ=~2両l%?BFꗨm%IмLGsIc?j/R10{%L) 8O 3&?ؔ$Tש3j6J#CghQRo]˂X"Sprc"Q(fH]-UOW4jBџHxt8N>N>N>N>N>N>N>N>N>H3tkFk܉܉܏Ȥ[ȚE,#K4C||_vjhGq;XR8oū=< `U8>:jImQFq b:4KP)Hqf^9<,ΆufP+D$LP?a|o NėۮrDܿ뤨T="nr˫u*^F:1T=85VOVŠLTY=3}'/IE/>e%+<7/2[CdI+ !t>FbaCE,mޡތxE5慒!>zf RR[}<4gqCk%6n45850l?㥋d|E|).o٦_\ il5*\u\urUF/~HO}ؐȭA?J*oդYN~#>ΘTf|%ho{1B^r/S$(}4!ى1ƈ!",5/eZG7~E;#\).:f:FN;1e%c1dG<7/3pE+6bil;04/Vf73[7B߂K#hںf gjk4~'g~%d+/Y>IBrTX6qԨ ,MvYge~VWDp|}?n{WG u#T~Mq;;_/_w;Kk}hZ-EhԼEeEw72Hc ʊr#澈}#\T.Lny}܎ܚ$g9:.rWd;V)/qMo#^FGkb~QQ|? 4F@ti t.{[֦Jy%3a%L:!O5{xa N]Ffvj؝1II2Ts2EقSe$'Gz'X<3ͻ%5Ėk1|"q# yeH=?5Fg15H('m3C\+Gd9WG-qIZ"%C~i;TjnJOMi%-q7&(в%mּ&ψ_,Pc2CZeL'J f ؆ 2l73$62N~"z!ᣉ9To|oq3t9eH,Xy"IWGѲ/mJl=e\K&B:sR!e^&%걢'Uy_F?]#U WKɊ/ HԣDfi$ё/+z*{ Y#br"0IFTv%rPM-IDEu{D߬h}Fh{)8 -y)iVKRkzd?y|2,U&Gݲ3&%U~YT$dz.|߬(q4!A7ۉ$}Wv^ģ:Nȩɋ CLu(86,:W&Z/^(z!:)9;C#.9 N?a.OO^^QYXZ(]\YT,;,~ҿ!y,hsbQrF씜mp7ཕ_z,cu=cy_ҿ]> e27HsOo5(Jr4d,}.輋˟=~'&8>-ݱTESȾGoaq}WG墟FGb篬stl35ĴʼsQH>TK𨡫(}cH&;"S(Q_IQBQ]N ;">_}">*[J꾋UV !1"AQ24aq#35 0BRbrst@$CScT`d%Pp?핸E hu(&gMpTiJ&SJd썘T*c1l, ;-nWh^Ou懴*B2G>`vɥc,vm(\֦t 5Msq[ZR:R:MB)"+]εj5^ z-VN ,ϊ?:0hvmTK6rJe]NJ;3 *4PuO ؛-V!,uƝVޛ@[+U9dmтXW.#i5vWpWgמ:Qe0fJ7_3ܚ.rV|NGV_ZB(v2[C~Mp›b-ga>(M{MHQZ z@N~أLʋs[šKe{oI%@G%Y1~WckUQS\ږ/_Ktm= [KB2*#M=-=װD,H`2=UصtwR9mWv$l%&L\E֭Ȝ4[<) dc&.[,]v$OuIv$Qο׈FQ!.-Cmx+Odz)2~f^ 2blHDYNVU@Ip;ZMU4YWUXaGT\եpol }ޫ4`ТkdF}F\Z]B0n "9R¸DHj)O r,aզKHto+ӠݛTBQ£qY{#d~8nRMz!y}0V|z3t^Lu*~/U 䣁rZkE-KC o xЮ')F"m})Z(>Vk~qF[=]DTawZ v]dFaE#Gu0eէchゃ+jX|E9fg.qJT5#LN#S X\F<2~)1&Q{tnM9qʬ|ϭ +D:`] ̘G4Vn:!TKw_"GPY۞>eh1CTTeDu\*jqMqm+/YwH#fI2?pQ{+*BE!ƌõq1q40{һG:JC\zxDt;*sNSLj*tVɠ렗ժsX@ZǍۼ*r_SZ>dm]zIK4Q3w'j3T80R mjhe$vבJ @ߒPwұ}:%EnVOZ1?Wq$  ޺*=Jo. 60$&ؤpU'ɥ\]%wStCwxةW/o* T9J!= r:0Jq[PCN\*2 $.8em)yuJM,Ѷ䧵3JCruڴ3qQũs KS)؋oFv@6V!>5S5[V?'ܕͩW8w Քg246+j'ݏ9n$];$6y9}Ƶ俩vV$oML,mwaKa:ijY`V`*j*Jdn{KZP$pk4GL`/(՜Y_Ce\Qys@>SF]*Fŝ̺Ѥ(Y/q;Bto)f!>%])5B> J70&W?+ e~#O%΁ƽ)|&hPOiD E0%3~KWZج}Αګ5ġ/~jfl/I pwa#f #nH60Ow#c Jc1 ,XaC ȼڅ[Qh]$$+Dm jDmt]6&޵$mXQKvJ|~eu+F 3Sg.Z v%tUIq7V‚c j6(4HelW1kkxL5SX#q Ɖ &+IіD;ͦehp86"PD '@DMՒ #<;g5'4i9nkdтtfbì.AS6'EXέzכ3* P"vݪd5G(Rk(ZWj첑bsԌN7Zն(]Zcl¡ܯ 44pwe(qQ=rZ4Pu2A0ڵZ1| kDI .c#~06Te#f-"KLcBm.q S9҃Av +DMjNerKC_nb5NsɵKJ.Eā{h` ۏ@ȡu%k:NGRή4P'݌UD5W8S(ډvlʴ jrA}#$ FVpHVw/M,f@.a+4j`li%( !6!{M0^{#r+wFXX]}QDMMhmpܡ׿YҭR>G.k4T4ޠƈ|~.xTս6r?i'~I4 tٰ ;B1؋\@4B[u|Gy%Q崘ciM /6Gv&n.k14 i #hp* YJ^v] gyc}ۧZEJaxya4VwM̩\QTTΙ-F7Q&V;ϖ\YZH$1DZ]yM:xsL(hzn.! v ;ӻ>UiKMP|:G^ uv2IrЙNppUv7s(k8 nl»2HCh[{aPRyhh[_;cmSn̼j71^͞#J\4捒TTdɮc)n2OmZ\^ cvMC֝GKТ h(uN$hZv-kEz֚\Z ׼㎱V$-&Zv+\蝤^8vt( n:BH@Mi Mv k|&in5|iFjN p67Rspy0Ju%2 j22 *9XS!u c7 &IkmxϥvIu΀t-h]/hb!왜wY!u3lE!qxӦ0<޵0ոk7bl7MQ26j]m62HΫEN&&>lA>)[\~ Bɫ-3_7l찶&aݵY"-*߳+@\1w'$`=H3]pG@0W#XQtMڨ\*xP.PGXaTni,bZֺtRWmUSCc+Sy07 Sy0IMcJW wlJKM`Qm]uFX[m}FDF]yĽ9̹䇭s0FmkW]sRKeM }5]j}wULFՇMkRМ{2WKE)[`'-b[|p 3P|Pv0#2 l(4ۤ583C>HNe72{]asy0p4f P#mBRݺok>.UU<]0Djp:T-7e;nLQvkq=*c1bhO%7Q$԰ ;*ƼIOΘ5sAЃ“0hK0 &DG N"qf(DHiQaip*vC7\Q-n`eTqvc\mZ 3~1`]ݝWuw]%]c, i޵md&[4@#u#6}ӻk`$= oOר"Ne|jGfk;Ѯ57Jk pƉ"`84r +W A\ SEw*+Ezvǭ>6zk]锌k5+`f]޿}(]cS'eVfQ cZcGl)B)S;nH+ .@F!H("`cAeYy^ueYy^ueYy^ueYy^ueYy^ueYy^ueYy^ueYy^ueYy^ueYy^ueYy^ueYy^ueYy^ueYy^ueYy^ueXaYYYYYYYYʞ=J,+g %ƀm(9 \Pdkʇ?~}kNۣygѓ]=\-={w$V ܭViz>I\;hEcC D/m\lg{9$kefm,ύDk^6èvCiix4}'FClc qiq njhfdf4.rf"Q֧ڥmLW-ƾanC{Xj_AL>;C htu Ვ 0_iW0`YckoKVkwz? BPЏR(IQR6t3x#W:pe1iۢ V ~\'kV#aIXGFil~IZf ݽ/I~ȳk{\lTq.H:%{~xFxdjMf#)WBy )ֱF({n.qq^ wnњuc^)Rv]zYJ>dэ oxzW]NkFtDHXE;U1l~{}cGT^Vp?^$i߭5n'5;jc:^#.] s %`tN]I֢fB09I'Ήq{jGT*kvI4(Z_Vq.e~;iYX!u\/٤k8цF n K[ϔZic f c:_wvǟk qkDE]L|2+<1)A1^ dHڋkBC!R곷t`qO3+]cnj7R>22={0Y.GDV'5i}WY-cPd,)DA׆-ln/uVeg[B)UW%P!j-Z׶k1O>OzܷmƸLܛuIgL뮨ƒg`q;hFFyս#ML. _AZӴ9߸*ARDHl?ˢi(7Svldm։~$4'eGW? I%DܚFZ\wY C#+$W%[{jDz\7nS ҩkwJc٥;p*cRA#%u7`n>$-h;}($~Q-q T?4qO i. ߬Q^FY#_W:JhY̸Xk7 7Q+LJYÎZ:Y!dUfďm!kCpǧbO>VLn:0.j{]5Fzi/u1?RWt[S>`/ :0ntzpiƧ߷eqW$QHn1BVˤ~iqe/cNt;n+b]ђїؕW(5CGcһ6jV;!9_Me[ h@c}h9Rp.:];UA, LN8A7YV ,3G.kLXKME@\!vZ%:WT[`u:l6iZzxSϠqCPQ7eB[riʂ eVZ;,0An&֭P*oi?8Ÿj`֡u2yTJ|{8ѹHs^`븃xܦ&`.4ԁq"nބkz'OhG11֦dfO7Y0UɻMTeI] on4NךۺU% `!]$;yw/:h" ̨<.Z &cY]lJLM+V0׺#>9^F Q\`pv(͕59eNngNp oJuiI|9N4> c2kv% Aߪp:QÔ8'f2j8~*}⟬zx5 Ke> e9`w),;,qYN-e[Cz)٢;40 'V `š-i6["R)iy˜bdEG K"^ ݪ>_9M6δ !LHd0ʎ {ûK;QQ{V1-jglĻF.Վ8T9,hWkK;_:qTA.:QߕQ:JvyǐxHS R8YWU`)Oh_!u7kBRs^MhkZZ7'lm)ivf yrKMkM$z>FrsZUia;p+,Uc+F95<>q[ĉc oPFV+ 8[XQ(rF㞱er)Q)_~3IۤK@Mq=)c%P8#Wkc^L#{0[o\{;u@ݘpn{TU.71T tle68 ə})t4TQS'GP^$W\%l찾(%׈#ĭƎ^vUNvF$7vaV>sQ|E% ܟOG95ϣ˒\U{r8uv1VYlכomBm.m#`Kd,n #I-M6NJZ^[ȁQLuc~@믇"dL(p '۠kۊutUjG=!Z\{̟q@>ۀT+52 JmphQiݍc;C=îj\r}#{<N{?ւӈB1_sO≍4TJ#{rxNйlQM-<ѵێ!8F .u_ޛYhW ିFGcMn"c \i[OHs44xm\*.3_ZG(.7,.9_̭F=<|)8YbF|S? bفgMWca˺? bٝ@c()8 8ZJ neߪ87ץ>Ku/~cVB 4PkK(;Vtvh; : I"Zk"ypT1<܎u:ۦTz:aNDMy.=\*qش?ISW]x)J6FE\$'&IkhD`hsWOs^6bݏ)Gc;C?f] [MbF4#qZ14oau<&K\ v+5xlAٱD z!Y\oiM`moN5iM#ߍiqO=>4bNXlֆ8ZK6eKCU4eX^:X 3U[ Y$kIJ#ۦzrŠˤ~d`8$;8{lKE۷mUb=#H|<\[q4&Ed€7m2:z)6l#;:O)=UqYQS \ӨH&(*:v81Φ.\:09ι>uaZaMF]eMc-vFdKUSt&1AS>IY {inCk+D$Wkש˝ؼO\zv/;x݋^'wb=W^'*u Z|PxZ 49r,tp%*'Kj+t Gs25fp5_BIed `YY 5i4i҇ wl;*}X[ޥww^\^'zkMXwYT^·N8,y̘JLD~k?ԹKԹKO.e/R_.e/R_.e/Rh9_:̓Xʹ?7lmQ}=ˤbA;O£d\mGz_h:UTk@\5D!uqh~'5oCOƧBE$sp(}n0s_[J01xbgџO'ӂ$Z6pR٥Uxeܨ5kp1݄RAsc.ہ[sRA||_"_2y6;@R+jv?]JVf:Z j{fC/pC˼.֪dz.uܳVW5Ncui>ؕpu 42D\ v(σt{vxPu;R;'O{$gҀhVf-~J`V֡ L;ꢕNԭ7*\ -ѹ+tyXV;-\Sj DJqh%vzHjd\ y@P.s E}_l,$arh:\P ZjdVCu˷ֻb᾵[ Xxow}k?]v<-aak ؿxg9[d7!:.<5or\\\YY3Mke}ON:gTQ"CAʆGxwcow/zg_c9C 6WHթau9^  #nhOQӯb.׼Os@Z<L mIJ+G[}(9FEPF@mrVeP@%>&Ӷ}+CpC.)EhO]ӛN6B]U7Z4b&yJi!}nW C[6]n7{Q6wݕW|e'*(OoĘ28~dWkX-NLl~(^̦c _]ֹRs%>GC8eԄddnR cEsP*C?f7۹w,W!VW-i nc(]XB?0qZi}+4uo_\4Y.'El1qk }5 #$Z`DdPCh, e*K0ž4dlHu@5ROl u4 Yk(u hFEu*rlw4X1۽H)k66a;/Ҳ[ң;w^HsZj7݆Ina 惪kMjck^jIB'=+5~H{ݨ|MMU8V ZGS5i ENq:0.+зh{}*G8v#pvaF15<rPh9yo^eƒqv\@gUUØfjO\2z! ^ڛĬIsl1Zʑ+EƔ\[ـjLJwl+LsVLJǞlqh|WB6xaE| bO V1*IdZ{+"o䦐an ?jh2_p7hm6xUJ.;֮z\v?2(-rWЅ3E(Npk^ru9)vW}cG4Q "xx5TrnC<=A#BT8ևbtom ň$']2EU;X맕4dS#kUNkxeKr⟭&ЧI6b܆å5!<|E+٠c\6+M1e~-39nxx[Qx٤4jtM1+w?5w.F?M5GJ{q1Ba5!xࣻiowT dd|18 tFH:{;4B3M1̾*/ mmC#ZۆlR9\ܛZ4oN7hH6c9Ih- s*+]ŦF!v'119n\!lp!epʠhVy]mPڢMxLFräK kdKVc_{!SS%pty% jhy:CK}caiD2$%k^/Pp$,|K̘#''e cLVgw:һaWCҜ*{OcƸ &qLjƸtW Ʉ1'jR.K%3{bvO#j=qWЪֱŔ`̫WyV>ÌR Bx׌aw4VqSW_J28IS~%`NV;[mxqkR2\4=u6 ekcZ}1`nā\7#,Hvt4$,R܊R]]l2f#ۢlFoO>GevƗvpLZZ^µEt&Y.] k^^ 6TVMzB&*Q] =(1Y"`T8)UXF W?܎UBe%pyA)_wSJfmpMv܏s| E{;Z' DNR. ?AL~ܓ8riuvk+[INWbV93U&pw3?XAEnMمT%Ap(8bxD=*U9dk&i+_u0M_jVnώƝ FyUG<ohc$tC-itK0sdmS]Ģ(uR!:Qi-p1R\dξ;r{%D]r* 1ȜH1uAdrF 1#ֽeK+ݩZP~m#::j?{mVlᾕ~hύÇ~BqL핪xt%.)w8xSzFz8oH5+j-?[zxϼ1?W".ƴؚևMTlyRI0eYu 쐽(u@WծS?Z*uI:װ1m-7jN+7Wxi C &n-= !o׺Аܒl7m⅀JRZ$#K&dd]n;rl~q6juH8kW"W|va|jm4:J/v.<|!l"vy&m}v IeF%G^9cQAjSMxwty CG {ꤽ~ '5@G<댈RC%kKT;i|:WZXH[B^1Ƹ N3Y6ʅ?,QcZBBy`^X+ IMk=:omUtl?:lv'Isx\/,7ʅQ t*J2~,lP2ZֶF(4ˋ/:rBYwL2.K䇭w_$=kR1]F=k2y1]ɏZLz>@T}^ k>ha$pzyfo y`zE%jj >+&gfu5v{=VZ9pmjE嚹^YD2:zg E嚹^YE嚹^Y 6&ۮ=Ų +(wx3ewlnpkZ]NK0tHh65k" miw= +" )ҁ-P>x 59G k_B -W8dx^ )snA yi\K_:}IZ[Jfw%m/4ay5C%q<mEN=Rh}XkDC}C :/{)׬/{(H5"#Ovlc^3؄΂H@>t6TϢh? .Z)u.So:Qvc t]ڡ-qjcU `l.'3Ur[JS)患xfIvx}BoiBr!v^R" why 4#e}G$1Ϩ;AR^憚AJӣ:nJGz֋_f55w|$6|~i?tB:IWW,7~UD<=V8PimӬU'ƌnJ UL/{I|NcN̞4/{*Fh1.KVUmSdpzcg|b1vH'74cB8ˆMj мW l/)%|rPy/+KJh~\_P.?,s7)f»lq/(7gT_6DHWvݤE8=ܰ7g%es~@DW860HW;yRTu?+M sBW:?JSBTw?+O SBPu7(˟ʕ4TTNsP8vT!XڧV\*W;ʕ^6U6PvTP;.*W:ikyRN>s\o(W:Φs\*UjʕNȘUifsCPjW;yR[T Ҕ+kWs\o(SSa\*W;ʕU˝J?+TRPu?*O v*W;yRݣʕTq/WcekC]K,꙰je~+x?%xxя 2(q%Ҩx6]4s»Eg[<6I l`V[k0;OF#b9[!s)䳱L5vYZڛ`s ocVyYpuH Yxm2L+Dn ng 78Y+ {*i+oR\WOXfnOZN/G +rĪ. s5ɡcDG堩+@~'0;MSܹ'IJ#/ 8GĈ|J/Ϝ5D:+GU3 ᷊D+$*{t},rWT =+!1AQaq 0@P`?!ok9+3^__FsoF #kcbJߦ/f!r=}Įxg~Sθ<@}~"V iKЫ/dry#F[뫨Rm1م_!at.VkeXT1.y_YuxeLn-o] (:6`.\]XּѮ3WpwiA쁜^םN*Wdp#¼V`J<*dVjn#A%~au@9+aq:/wsVPX511l`y0?)K~KW#K;d&Qu¸Jd+h#PɻsS-e.߀Π2ˤϨ'X` C{b{%{AҪN%mg۱q0wzaUغ:?7؊fNd+x&ȳpQvJf06D#Sȣ׈>1UCW987х~4ԴC")ktfŪ;C9Fx8]F_x*5` F$x~IeR<}"د/IpmE$k)ehAIBfwM#)0.=о _XNvf٪fDX]sR7?r)@ϕ X 'ojY%{ܙk]T-@%)s4dh1_ADЗ˘#VD􏰳UQݛ k,@^Rc.0XٝQ!LzC)eVjZwGr^Z[]X G)@"3UlT3t"lq9NK3e*Pdl \r1ͳK5M-B'} CCyZ[YRU LX~w.!o  Lfl^:eCG OYV"=% Y`\˨F`Yǝ~<)He^~uaHlӴ?t4%GRٲ=:-T-}3odQn,Ѝ02^l0ԁ`*ĨT1gycBb`~9FuABQ|CB5/ 4Do#(f!e\Է30yaʲrR;s6M(9x f(HT7߷x[:jQY~r9) s);qB .xvl>Ӛ=WbcPխCWR3|Vk0h7r?\.@/W鐧R7/E~w{QTb ͤBǨ%S~:ž{\~O&QݝGPD XS܆x˪Wl!F:]efdj-Dیb+]~̺$r+rf@=¸#tI&u_ OIbtIt} р6tu,uvkvY:bj#dFV!xWZң%F=Du_/{ 51Ʌ,5C6(l-BI,x_LenpA8JbU1w|L]b,~ uij(6neP5lAQ-p*ab'5X -cJw@\ ;eUS=⢋ht/.gg(`6F; }ak5^rٹTf{j糉 [XAfku" ?NJ h0JcG{R\Wh _J[MQnU][T}b;'Rag7`ocdKk1 eʖf>dQn\~9kQu~@f FUΩ뻏f+Ûb;PO1!P;S]f[|y/x1O )s[/-ju)aiT+&7"9Q1 :y)C̆"ѓ,Z+b[mn;Huʡ{F+~`hyh`|wA%OT/C*\s6snh&mKs]\Z驾x`=3L8ߴBOYF@z7ݗY}`L\?p(^jKA ?T Gn.۬x:F1%^̴ Ƀb4Snt{eQ|Z\hB5 LzHU KVSjj12}q}Ebݴ ".rޏy_=cI^X a4AIxƆ*@tk*VTu;He ϛ:ƳJ3Vkc}"a"X{)D: dܸ; B#wx]ykuy?nb>I$ϒ~?s䟹O'| g=~OƒEC]`KpNz  o,H^̓o .|j/P DK=q7޽#p(_?h7~-1bL;ߔ)|N߻&_OBݝ5h{N@Wӑcv1cv1cv1cv1cv1cv1cv1cv1cv1cv1cv1cv1cv1cv1cv1Y=ܟ#$?GHhyO 躖= S X:v^'B&r<@en =5)-;^'<,*i"S΅XzE&?AVτfuarNAmubBLKPc(HxDw*hr 2i*L\I$I$t.? 1~'F0ٷSQ.^Vn_<VylWp"r~ozی{r9K&PV3t9~.tLa.|D]i]&xРeN=RMk/\Wx@azWO?0úD?Ʀ۰HtX,\r`Na^I>_/% zN*7\cW\}%1jÐ_ޯM.5g)*dQZ,U ݼYV0\Kbv~:D+oTA1ҸıC*GӘp d/)KԶһJ\ a}qQl?_f~z\'\@H7ni5I Te5^EkоV%|OEX r}]Z4 V V?Kgz꼶QvF EY"ҡ9" }OD6+ 30PWhobm3Bأ}r +|u`#P۫/Il˒UYݘ*]rGI=<2%0A6泛vSRk-lGZΦ}l*[A6J0EwVJc3i;+^z= :fX2K`o˭TIBtv`|cB1;r^=\*I}C%RW6⦨kZtŕ\ف_J=-Ŏi`^K0 ݷ? (knt{>C-㧻zǃ6-hB\; D*HxM /˷B*T&)֎.9Wfe`^k NjMSZ ̸vNezEm2y@.(qe %Z}o#z+ ,}CߥQ .cOR;Ot4YkP ~U01*stSk/2ϑW4ctՇn|OܸhY0#[1;X2}cނ-Vn_T_Lg}ïٻep/(nPw׊R} Pocp%^ʆ)\ u>P8a2X HkJ'mf'Vjꘌ袀ptA&#]v2MwQ|r;nֵkjOB)k՝|Fn͝aDGawe{TԼe߅e8}OçPnAag$Nf;"8e5(n_Z-l yp'xp bǖ#*^ӎ\LK'TSҥDt=Uc"y̕\'NԾiQo?;dn2Sq > U+Dt?j_4,M7@QY3cOg,xg^K1 yZhN-+j ~{F1`%Z{ u Oe0qd^Q{qY;Zn-",X| _|O 5jՑ?t^<_GtW[OIǬ Wp S@WѽH #r⣨QkRl j=QǕ̉Yc@%kEV"L_X '!sn&[tc `Ybz))0befX)Yn+X P{]I܁hE(z@V?q4jUk$aWLޒ+JJ cXM! 1Ua //5 p3JYc7zx|OD>\bb*s:ނj݇r ^Ot{էGOUDm5fL k~ B Bp{.:bM\`Xyy-kFK)u/&?lzx #`tKZ=Ok4YGEIV z\Z0Ϯ`%ꖍ, JfF?BB NB@ݪ`y +0O<ko3qQ\}:0Mi>}erٞMuhZ[EQwW*k!^7u`|}33g:vYN}OO.ׯx<2^.fĝZog]"K}LTQҸke}0ߦf#u9z /_(b@:Kd/D 7JD,{R$3&v̥"m\A{Ch=W31G/r ,l6|8Q+W=Gmj扭K4_V^X5:Wr`CG ҈ ͠~'G(KNY,*8J,n"M ]ES._B7Xw_@m '@z= =1T!+/:yFE=5~Goj,Cb3cYso[cE12B d풦`kb0.yTuU[g &.\rţBCn(JPTw⸱M5\ -AJhzGșbsظ*#l/ӥ9򔼸mf'?dqD+bbB{3ȝTOL,%Ts!>r0{u\W Ƅj)'?'3~܇Q`c RAC;Xop+o)lt0Og|>O~'?Ajjgڢd%{'3N-?\(㎿hY ]A z6D(sc:[`]pLx -tKOGb@䚕wGCxP9nz `?o$G 7:!|?χ> =~Oʤ ) h5d[֡ngR:xim&<׏@%}`.+b!^eDml mWCV9WYtlR 5epy%yJ# YywJ]>Gh;)1JɖR'm! O_鲜iݫ:unB/wPte&zҿD bNδe^tSW/.Lu/i)`<P,5){!In-lּU*5>, 녯HJn m~O: +/U`f2X ipp&g+>깂dx˲U> _ը-f3umR*g*VeJxR FYǜ IΧ*@.J/W+>'*.](n|asNܯi)v:Qe;]=?03eH?,|5r?V%!Q#h/!ѐU8umTݟD6qҟOEyb?ҟOS RhO#$͛6lٳf21{Yt(D$H"E|j:* ay+*)諜23#B+QFV^`pN&LJʷO]33zvG\r9~sX"Xe+e܅.*i(f!qĠ$xlml֡mOCD01,^whŧNCX_?4y& nTgƿ5?{rMF5v*)pZW&}Q,@P:Lz@6'/bF\ xBpx>G~q(. !qP+x)V2m- ŏˎ>L` +_:n쎲y Q,{u:Aebj^n/3.ӝ#[Tq{O _{K}2WB 0 >s(^?/Rҥe lC"uAq ~_)W+hNҎNWKZ^Yia.c'5?u\'WO3_f+CBh[U(j2ڸ``3)~ǃfLu1F7SXoaz#RqkO%pK)R{ 3Kbs.]zOԝ:LStr)1e.dG̲x\7*z/' tWѹDg2~lȭx)y)ȣl QZk-Kg2&^wX.}$^9C0-PyA3o@ʉ4j*> 3_f qؾ/qF.)67.'/q`ZGزCIB ꎃ!+15ᴴU-ԩM9cPnakhhkaUY1hVgkGlFߗ\Z16Uw:dd!Dp>3T%˺^ΰQ9,k fFV"a*8Xpϛ{EK`]y.Z}J#>Wu/<^*/&'],(xkT=# ٹܣKSvY(.jnXhOnmݟ=J[`V%Q';gW+@1ыQcTy˜Ҷ9]>: W c/7,l$*r+T¯.$V]m R8<'תSuN}G`L$ _4/{,8չ$MX;J br~3apU1H] /Nz;3 >˗fpeM&NݏLC3s1L+7IB%=c7Nje`1 pĎwҭ/f+ʙiZժ2SoG0`> H2gg)x|DKWnwD` C )q)[H KQcPo3Xz-E=yWg܉ʥ|p4 YEe5^x+=>_̡?O!IdǛw( Z{Tn=PHBxX @w\*kI6̫.ZWT L/ۼ~ݺ2Ó(^ACEuiWˮf 7~pK_)j:\gVUHm*j|EKV XpҝԹzC[RW_O^?3d-/|\rm;,5^vSavxZ?'M,[P./UE#fH8h C-V. KJa#W6.,=4*.Ř0En#s)9ybD) rqP|"7RߖqE,:2Ʇz,:*'DS *zx*o GLi$.EzxdHE`2X`Ԯ+q;*,%K8rX&"5-!oxS 4gS a[aF%b|0⪩JQ3p`_YtyM!i'$˕3lmƸ"?#l)7r EZlu /1 #@A4tׇhZ>Y^l"[6=vu>S*S]MX!uJUY^'&sYIA;Dc8IlnׯhDbEgPiQA@cp,e ᅫ'X#Gcg6*8A 9_Ycxh+Ƨ= sD)`}P eSMfTbyA&Q V+Η)~ei{Y~ylhOM3 LIAhK{2gTL(PpO2=]B)r5<) :]\w\qP}I q՝{@HO~c@ߙogw1%m~[&ϓ~gɿ0|Ԝl;XGV.Y j0-ֲۛ^-˗'Ղ*Fۭtfuk3qk\NUQAZNT@WT 2X\{RcuuAnޟ"E{fuCy0cǔ=Q ,+y%;TTznz2 \X*uzc2v><\]L!p?:h{K 9}3_C 0s֏7xk6[ c̡uJZC|=ߎ%{+J` }ZmŰ]PqOs+,zwqۦLWө7X_=oҙu\Rp}H@#ԥo _%ÈGf ̏oKW|>qt6*>?)gǘT6/q1}benv0Q6__z6Ʈ-r6r|g,|߬f_yAfϟXWw*e4~9^OK{w߬۟eIO-hSyҰ[e|"|s>{ 7[ݚYeE^|8?_=.cCʿykcWσ`~__XNM9"D;9}˿x9_mdOc;ߎb!hxr)|>{|#~W_}y|g>3ha|,dN2V#RkD'J Q_Щùiy-ms%*C(^sBb[*ȘoiG}@ZU!CZZɔw,*κT-w0su/[a#tk$PW =?fxlGlC[*cye aw]$^S2 Y(waJك1.0͢WX.JeA5k#Ѓr0bub|)fwf:ޜW),UXJhGT0+@\Jzj/HUka 8BM6x\Lզ$D';sS0%o GZO$<)ؔMߤ<@Zp0nGPˣ<̕d}pA:@2H*c;ֵPM-:r-`ls0@ FV )M!ݜju յk275}Nb@LZSh nkQ (N6sl"W_۬#f<$X\x!RéY;+Z:۲` |<\b X5ur~7tz@2&uդv:ymׂUCfk,LԣirX[lW3N|x!3~ERwMBS1(Y~VLڳ!qf543ņ(pPP\~7ItS?pWg~VU De]ؽϛb>gxWNS]y`V[b.ׁ~1>3>s(gG2ĀAieTyTXn"n^n[ )_ bfl 6(8V*!ϔ%TtƴD01 Z `0 \'}^#(hlX_`DR@W٩ okLy& 0Xy2YcQdCjO4Ia= l :oݵ/Іж 0!J<Ғm rך,+'/oG1]6p̷,ٛ`:QH,- z`Hh= |Cm#p RVRm糕<ƗH4vH=̼uTַㄪ%kvܗ~AY 偂Ā0hKG͠Oy'U3DAO T YcJP.q$I$I$I$I$I$I$I$I$$A$ $I$I$I$I$I$I$I @ TI$I$I$I$I$I$I$I$@$I$I$I$I$I$I$I$I$I$I$A$AQ嵂 !fGAI$I$L v)@$*l$ny$I$@ _1H$$D]p5I$H$[3(Q#@rH4H<]8W4$I$@<ݧWː 6ŧꪒI$H$'X 2$@$yt[$$@$  ŝۋA$0kh枒I$H$DZ&_y AQ,O&m$I$A K Y6 H ӎ#I$H$=mH`֐A Im$I$@zWJMUh@ kXv y$bҒI :IHAI$*$I$A$II$AI$I$II$IJq Xȵ^O{"3cctH $ H܀ ,c3hl( 4VYΎ.TB::qK1Z;D:Ha sY)#?1޶k Ht|$Ixx"Y,vȴtA3];MI,!1AQaq 0@P`p?bƳ O oҖ"[)bk;,I[r:\΢5 2PyFу(Fpn$j_0-&=6L;@ jSbq&1d Dn g,;P UklQl:(0iQU(6k5`:JV"[ŠXRJDGPEC``a2Ó <֖}PT(%2ЀP2r5d"Me+^&1бcT|T9,9nfO%Bp;Xx&&[NjaMRTl 'D"RՙhKz54.PXS{̪5b .(s1\\1RZ%iʦ =f8wNL0ҟ7F*f=B* [;™V=`[|23%q ϹS) e 4~b6]C| od63wjKOYdVK8ܩb1_) {S(f:m +ݥW{=g/XvɁ]q c0$Ī^ugRexNbҢ׈(C0o^=}39AG)9c:`:op C>[%OL*8e/b:StDjՆi(V\!*22D˩ )YRb)n".!rnSܰfzU^9g0A啎а1751G@o^ T$Qa.ߔMĥxKJ ̘Zb+ dn# eǼR 9*Q^1!;kj? 1M?xbi8W/x8U( kqn_ZBb(b\V~A)vQmT E@4+Gi1 ._\\ |  R1yE,HEEM ea)IQw>ZC%sVJ(.*;U4LW,u Z{oŕ] K`r}f!<S@elA՞˟yt(mfjk\D@cѕ5[tb)N<@Ao WEztr#]dcd`[F6s>Ð&yUMr-JD1 ZmTаi̴&|1-_8X* 61E"jxBhn5}qXf@K}"h@7l@¿.Q%?XK pmbUh.jbX}y:wcyK}ůX(ǶJO!>l41yFRk#iM4#;"g`+Fk3 BVϼ$bŷuX;"D+ؽdr 9Ae8P1'ÒU-ENr)VYS!w2 i5פp_b~'!:(7#띑_$]"YAERF$ ږɹ{%˰li`M*ƥO77UHeKAwm}Ys0Y ez3[Z؃uq {Kv`y%#Z`uM X ;__Ijk\ .l%:`n6Cȣk2lM(Z. N%BºK5dQ%n{JU\ *+' X3C*_]|8ɚkpT+cwӴzKw1 9+{0r_s< O$O$C=џwmkJe2LS)e?}upb/`[K|a-Hz17RJ+`j߮hOr˗=^e8XXHZgHhCbTRJ*Wk1 [3"i4\bX4z}َp.Ls" 0 mbp2a }}fcchMbl=e-rK՚ĮQQqx2n8/>nۼXOZ tp|lq qu>3;~")M[sȯvbBw WBTRdQW'%ǭ)>`%WDUwa '51;|t>A^^gј;}s>CbWo?v+5iH-*'3!p^[z @=܀ ũ};|0('ch$a5nPMXA5LӼzry-,6x@5Z G,ehK!J]P:*61gGشC+O!shNRpUli{/mSo2wx_.cdQzBj7r#/Me~Œw9:s kX룥pHiPig pb /qo f|@ON0z. )y>@X톭0dz˅*f Yיab(Ҭ)!U Jagb\|2^_!(t]R-(mICѕ*;^b XEi{1*ٕ%v GGD{Ar Q~ӫ_}B~>Уh=d? x)Ǹo9EyrȉRΘ`owbIZZ d$t[b-hχ0{AmAJKT F 5G*r,̍JS7rX:َP),%AESm7]9"̤,o#]b!e[HTw`|S&kfz@Yr_J于O:#YJk28%ŲS.d{{Ch [$({d )9W1(tx2ocB2~P7w.L=#r+Irz햟="05C2)Ly^> b#Y`H_[% 엸uJ(.蜜-vC5^"Su  K{"+^a./ bSuj M^  n"PTX7= 7Oz\/OdV"ܭ^kdwC?.Vll(`ܿ|Y9y!}117ҽbOYSp+{xةttd&&&#֞F{ D%BF!J%T bd(.T *Y#]AJ;J;J;D;C4!J ;NAبoLKWXyk)si3 KfлAItTqԽ~F:ܬ|J1v\bԴ2 zr&Zb[*$e7b& {-]+p:Vj&۠}m hQ.h0,7R* ̯W9FE%ŒV4}*l1L4#+fm j++"sQ:}l72C"Èl3dEE!k2AdʥbcZTQRJ}BΕxDaܷ(%b# FaZ)n`Z^[.=OdX1.AmVahQ;r(glr@K=dW= ::3zFhٖSU.PqԳy0z>.pf1uzTOc-!1AQa 0@qP`p?CH^uRJl*em<7E V˂U@cXnIw&DTn Yw7bWp #ZTLܺTn`ù̺Y@l~"F(%* ,Xr0'R*Uc,fIF9@Xh!f7  @SR*bWM4gTG,mN eXTb M5+(c]EPr AT1Nxjg'l8LjvvCHdKJW 9mLgҏÉJ%M=̩y Gq Ɲ˗Jgtz&TN'7rk[+Wqcˎ0ru/Pb j UΚ8?>.׸q2ǩz)u+?H$ƝEl%ALKDbbp\׈T ԡdJsfP@[ nx7cbJFPE" ek[)ܺ57|޲Πt}?LC@H' 7[1J;rBwo Cdf9 ' |%NheRZ"*|ΨF6[_Ìz*T>xa2 >&f-:ÑpA`- , 4LtD.[b`R | aV1u u -ĶYnKq_]c:ٶ-5\-œYu|%uqYZ{ " 0P&0, C++ YHQ Rc#mddUs 0֊`z݈ٙ`8!^cI70QAsQ&2\.1T?Tܽò-%2-xr@!!|B$nYIpSi5F. E}K]ըz2`(k~&[ |wArbl\uLCf ax@ѝk3 6C0 2w 'q]`H(C+FT;#QCSUQj 8 F)5Pc1ɋ* Qe}c`Ȇ$%A%]%=JsJ @ԥH\*83!FuR:Q LO;,:ɌQ^2?Ϙ沘\&:}e \ kψ~̏Ya}(K];\#+v[`pg5M }L4ƛk'Rư*& #IX/ 06շx qvJ>1^5#Pf==ո*Qؼf{D&%CiAGdjYYt:'F& 0;>|&Gxal-t,F1E @$ KeQL%0TUxܪy,.1= 2ٟUz Y (F!0Lǟ2|@Dn5ZGY}FNv̌m)+T萀ea +#̾^-Ձ YbEu>qDBض;#Oļ@y33!҂R)P3uRͰBcQL<6b8Hcr_@Ou1?zfJ ̓`%Xlj-7ܦ΢M)`>}!QL7u =H^r@A\Qӎ d !-F5*b̤m^!@&rF6MV]K1nԶr;JH"PApD"Q_;ɻB+(_ WR@9[T)*N㪝aZ Jxk>_cٯAA) xAqpG\J8vmەwz׉aRz'fzezLT: %OFzq];ijWuQ# KJq5 =lG>l}HtlQVt"BS-ae"lyx6,Q;Agq|٩Y0()u)Y|` W,Qv(˦70.b\AgRLw,͙6~_S!0W͊CQ*Z1I f káة 5y!W ̸,`R%7$z#c@3Ss{pK(1.>&pM?/AJ4'dT> rp1 :M͵or*R]jUʔ/BnH, i;>eSЂC563"B310B!|YorłzRڼ8d>>(Q(.If7ŔYX4|Bn;;N*54㒬J:/2ģ Ʀ*he-͞7 9~TҢmܠ"IW(鞰 rȗnʱn8pu_i+PVH5 ]94wYIn9eLIJ#T`:-3 0*Ru>QigJH?O#vl,K%O2Y,,yOZzԞ=iOS ZzԞO3Ԟ7,K%dY,_,eOW8ju"6nq,Ub!n>DzR!W&:OH/i_wE aX(ѱu1oqr>Xb_^8*Ẍ *D"7%}A_(2cV YޣtEO)Nvp9%ʩiODR)9~Xc1?m1H>QnvrQ- 6& $w\rF KM 11K' dU"]&*Iͳrn-%M#2~@7ڼgHx#>I3?3߻u*i3I-/d_0.s* ,6&\1e!NE#nbn2%@q r~l6u/#!=DG iOqdXGpM0@՞{^_ab++w`"2QiLx0טЪvR_ u*Uq W(Nf2KGpn_',`c.@l$fs)ڈj "ݺ2 l-LŘܬ! qDM FwBzB dJi{A4|A4cqPw]Uk2Ͱ^՚Z.wPϻF'PKtO"YhˆƢ.85c y+ E>PV.#^T6pR|?-flrc4k?id 42JB$f{* A#հ(Y)NMі'ST\Rt-m# "0 {]K2}!0 #ĺ%f0d ȼ[ D3$c<%+3+9|&rfQWR6!덋+"!f;qH`o6R e C-Ee6|)K@?U"Ftj6X͢ -n !f0ߺh3+p2YjgkVL4C `aX aZ1qwe+ G^3`1=c?F]8Gt,=LJE@G<|*~"*hBD1 hte?_)5 E_FipԯznE[7C%qE61aHMU02ߝɲdr#q6FxJ>^+(C dAbWߨ*Z 1oPQ-:'%Qs  o_ ;b{BO J 1 Ȱk&ReUPW CLC;Nv{'pNu48da38W 4GDZDWܶޥZ)yϲ95 h/[Q a"  ļ!yԯ߬}ag,X"}cƘQE} n B#F]@9>ĹmQ mG-K8n w/=uylbp 2JVfX_|#L,[szXg A^.^~eP {+E WDq~j {m_8_1&G_('s%fd C̹LǰY1FS/8/OA:_T~}ebqlS/邗Øi*JrUh*TTrܩ9*TB-RWW&%pl8J/15GpSqf:lpܾPldHh*5R[*S|#V RlF>ɹeܹlKǎxwrۗY9w3\>0cf#~՘dx̥,0uMAj*_7R$ͯd_? uoQ;2֦ K9K*TeJlm6jer. QSM1{<7,r\7/u3w,L shܱo5UW,8>%ʯC?zc1_ekdQ&rLRVbbe\KRq*]JE^-"LR\w|U&VPi/.<\rK*RSӁ;L! &B[%QZ* bJ%JJM%_ q3. d6 O&MJÂYay8}B2Rɦ;fLװ%D/YP*[(qrFi - K5ɩ+!1AQaq 0@P`?Ŗ#ğ@Sk㧜(Gs6qpkу)Lсt)5Th,3ܰxzOopeN o7$4lx㱋Zz,r^~G 9iYheʣR훒.[HWr<^vbDLwL$h#=Q6IOKM춚er?d=ɜ IͣY*Zb*M#n+D=rs܎ {|8`7i[#;REQClߍրtل9Ino/ۊ A>,yA?.[q0k1!i9&l`'!̳FpnnRa͎i .W1EW0HJǮ)0V 14`[g\Z 4S}88dRWwpUp"=y4t ijA y\KA@"]T`ľg z%0O9X= Jle> xiGS'5WԴ,lSn҂I%Md0B #vlmxb1f |iäfoβph(O1I1{+a:\۷|Kўjjz{3E>#^o琮&M9I97fڼ?f>yਁ33 ;Ê`vΊsd+٭#tO]:n4F^|C`n yywa\( ㎲| ^2NeLprLE*j`A_uA+^<)!7Nuҋ:m;O܍>Fځ~ƽ>WApӳoC+2wZgtBaP`\.Dt91vb9Jp,9'Fw.CD}NqD`-Gq*brYQX\@#_YO]b=̏`a)aVPWbA@Hҏ?I#8u>d N0d?2mMRS4Ie7X+/AF1ό4߶Q";LĞ!vȐ@$f}p}p"Kas@@=Z1PP:G( ZUA* L=UFl6 ׫5z}ۂ '=c-HF `V-}:{姽9}4GILvKO\?7_4 Q8-*:|i- 8wS<2L`5)FIխ"uM\*4/-d:'kr>rؖSe]pf3lq`@lQ@c s~`*} *s@%kZ#tcv 7s8od;8dM*a\F9!B^u uggN&i\1UD{)4)[fV?) lk<9r ?\F]bp¿ $ Mqu+x}s%QhR/+ܫ-=6\ % 8I"rPlƂ-Xx1x :ߦ׹}9U*r׎zUdo@qjwx- M{`QB眣([4F`W[j#Ê8}p60;/Ch|bj ߉-@S_aM)tG~@uL3@ Z>70C t:צ +@\ؔDTV';#|&K's<]<6yTo0"-MKaI.H>Apzۍl y6=o`um<6fuhM D: Oy< '>{Áxm7EWĸ^H]iZŪ ո7P>I`1ggӾ0ZxN(Q; 鎆HKHbEV0J?OxopR-⸿N2¸j~xq=p-P"x] zuu?+]\[B)z["m\ .\atNXSe*o"".0P*y~{J~ecJMPŤdob}0kwL۩vpZʬ-P6ٸf\ Rhf'FP >2Y9n @+=PSdFQp+~VN'' QmNh|5nPCeR73<Ąɣ+Mz+j`D'8Q?sHP)}ga~×uC@ r&iƱX ;L8ExĖ!N?`{!F 3^ႅ0 <.` J&riP&\śL s@6 P gGge$4"i/J@%p;Y"uEZ[A. ђX\1[ kH>ٹ*`7ybԑI #4.SCP.Ȓk]R;獈r`>ɇmUڦCybshe;Pl*CE4''.`Uw{@R"ʳkñ` )˹Յ%ͥ o|bjR/l,PMbyR~ؒJsnjDTtRycq9>2`c[7D8cz !D׎4;v" ;n2a;ד 'ԍ<ɀR{jގ*ztoz3lzv >+dqY눙Ͱy4Mo~.;( ƶaZ#_8Ddkqo{]@Ry!TsΘ*<7`!fKF(|X\y wpUktie_|D~sacs^0b P[ehl'7+kCX)t OAdZ ;@;xǔ-sm0 xZi b}9x('g4QI7O>"F oPԧFQ+b/@ Jʭ{H̔wtm68XSdf~2/.1ޔ1)2$ 67 4vJʲ]X36mۂZ5D&1Ӯ2Pqƌm oE$" ӏ* 7%t 3tcqlL!* aNbD,S޲h󗮇JF2c"MǺq8 %(, cY Ƥyfmg\w(V5` 85wSxu'~ j<= 3~(i*g; tJ[ oA"? 3Է u_. P#n{ baE}k1a3L;'ĩvŰQjVE5qLZ.`$oDy<\rO3PqJZVC*:N|+c̢LEKK 4%wT+|00ikTkھ-D)š$-k-ަ+,l@kT$p )G!k~<jB@'-s9Hؔc[`c;`6ZGVyi crŰֻO8@n`Y`Ѓ|rk*6o*.b:D;yD%T[==:4㍐A*ۣi˜bcpTPCj^no>$$$BN7 b+MxkPD5[ƊӬji"T:%!&?GLoPF@ix G9$tf)E]#w)Y8ܚ^JhN;1 pKґ{55T:KHJrrAq\ŢJCm8a"SP o ƥ½n.MC%<@ w;qX#'h@d y_6ڂ" Zu1(4A=Z.\|`mp`JY;}2qu- ?ОZi|'=!%(D겼=A!}0vV@t5)ٝ7>165ɞ3z{1猌i)h3kW!R.j=S >]=2eț}pS,W Rn=OfM-WgJD`JWlG3W$ @"¾}嚒`M nQ,kLXW99e= & xn5*( '!jЅzŲ0>2j'ڥGf~0Bқ7jq W#_|rʖx_!3ʺ,0Q@}RuʥA^~Y؈M;J1' + caÔ*#Z.?\Bu9=%Dtermhz?Hl9CݟcsS81Q-1 To{ ~/:'%{ @bÌEK@5W(9|ѤunXaCAdإypn\Ԫʜ5(:o4 BtST.;J3e[ )i5+?_ć_/x-x}? (ElgyաR-BOnrEAB:3VCWoi׶E0HpNmۗ{+t "^x ';ɕIB/E׌RӮ;\t IPbdR؇sWBr٬-b&iyQѳ[p7=g!^05l7 +Ta1|wc_7 !CȻuf{il$X SWC~?Y?g~?Y?g~?Y?g~?Y?g~?Y?g~?Y?g~?Y?g~?Y?bj '"D$q&ȧÏנS Oyr0# Q}9tTiS݅^ Y`=k\+mϏʌ/Dʺ05p׃$)jy0D/Q]óO !^p Y3 w{Nw:s@))Q Wp5xDc!aL40MsØ '765DbҋۊU>Ɩ@ce tL'~>J.L?R{~sc2iÿ1X UsTQ~{YS`Ry /P]"MfS& acAT`ӄFc-qǨ/(!g }R;X (@94}EE3+Vxl#Y '^nn=Z{_eP> (ڸ8>yIv}}b65h' 5{ GJ URQj P"@bJUl "+`^h qo $VѦqPwJHA)ÐX |2W< &GQ u%Bz>@xM84TJZH0I SX.jaFW\d JqEb/>VThPo|tE `qJ "=knl!%#S6p$2~cXvz{q({ @ tFIx2+Fgj VbWz AtQFY Q*N CX@AVv 6v1 eha._ OupN $0-%qu%q DqPjY$y5a|B[0b/2Gx\vs*QQo8\q(e. o;蕳}4XM2/.rxAUJXo1ZfhzQmI֑Z%)eQ͈XGLkу= OlWQaNXB^1-8f|mWaBV=c~6@UAK{~z"HPAk/xAԢrfOAɗ^|b8QVIYCJ1Qdp4mJ4gUsP hB(oEa#zZ/3Mp17YFI(plLIzB7aw,KLU40Y =\!@"7M6˚(ú@z66ɗI=`f=ËV4{]'foȶxPb:E\ޱw@g'.9pta3oY˩H4>.q/ැyDd3@S2aײDM&pQaz^~|GMrv+/4i<5vF*ksp+s+r޺DZE1Mӧq"6n<wsPD37j$)EiU~뢽;{(DʫةMl[8E Ӂѐp7%TrHOC;r|-}Vp%BDQp|y{,ofž@yT77nۣ[l78M wR#MMYrlW%Lt` hPBb !Bi690LL/]6$L-ZfІNBR@"נ$XY} "g`DGG-[lX 8R؋m\V*K_=>q2Fro^&Cb9Վ F'O@:u:]cCG 1oʠb$G!T6dMA p.8\Jp!9z(n#ʔ2 *>wGi(<,QxW~1w!dt &0tQ*G9͠+%=e9"ibcYzh0#A5If V*U˾z M}1oxxȒ`T)|O9Oh+K+&$8h87-3lJQ odc܆* +SEpF Ty/]6x~ gP4,F&KT40h5 .4e E )}pD|" ) 8Zi c&K]ݚȑmQ ;U*R@y<ɕ1,-pzu]qRaNELWBrOsZ?&ۘ pcZ6%%w.3jdp8)Hd]yy4`^~ojux&2$"e8]A^) L푠+qEOh2t,Re]ĪT<œdR.8V@@:7!j<:c wYaZzu|jʑq^ůaq脇&+H6+ ] @sA!;0EG6L;iF3(ۦU6kid8:HAHqk5C^66†jMTKIjJ-ὁ͟njs^+jR3P*Ed:6Q%4 W2֝[pU97]ffxJEfS!WE8[-D7)MւN!nH`wj9?G8w띡az ڃVÙbEM7-7v@8$G+%%l+e%zoOcQO&oN܎T" BbxQы=  x0g\KvR6O#C1Cf$DJ!3}ł%@ª7ȆvD3(uT52ܡeVr\H;F!M6ي! Vдg؂o|x c;GAZ cÜ367cwB r͂٧5hkα@7k}-[Uyq$׆K@Y*zŞ#``h2 A۫,՗rA>ؓ8 @Py c6EbQ.2YNarL7Xs%7!|8 cQ|¡@S JG A Mݳ(Odm^N/'GG uJ. @X`4lhB {Op-Xv7(Ce%<`1<2=m:#Vtdgmэcȵ8Gԁ xSqx->}ϟ>86/?CbC~inzph٤P QGR epԪwAv !l aZ$USM˩~1CPnQh]PE"q^3{ۧ8 f@aQ*rlt~C٣s ,)%n5wdnTGwNy^7f-z B !H/v@7D"FE RUU$!eGt fx:7L}";ÛP 1CoL vOMZ& UKHZ5 Ujv@ Q2/C2ͪHyI-h=µKS A&6.o4%sv,ZB(X+AJ]h#!06 I๹A}. #sJ#6p–4ʐOКb0ڌf>Gx["[ k)`(CdiWlp~ 'sPxU(峓9C47i!㊠~%ˏ9]dK9KUdHꆼsy!{CY˯@R{Z'S{ء#P;ɛ,~.Cu>#~^0a)΢k/txbeJ)s* !z/狑p!^( ] H du=\0]'~8O#x#^%A!yy9ؽP%I/ '#szآG>΄ğr8 xDOp+&m<A/|(Gbу93-.9b>`~cA@?yM9t~ Q%o(ǐeقcqKJ}:UC?y~.'C0gaϟ>|\ _{uP(a ؞ׇ8pÇb1  g(tЮt r;2< (O:k;}ǥ߶pq1)fAlu$` Up_@GlMPl##"8TsIzhWUp0q'T(I\pyB~=!yH6oZ S3oh/ zL&~|pGcۖO-u{>qEpkĢ pG ]Sfl^1NX$^+ 1BNOH%м&C]L +11@198awRad9ghzY `.Q].|bF6mHMاXyZ񎰆^c<8c6SQ7$#ȎPK.Wph/"̶߀.ryam4֫;qo,k9\:'H M,ZRkY~VYK t@_Ŕa"x\@𪁨`qZr9mw뾂fW满~.=p:0lrh}NċNUexz}./O)6H& *QLxqiPW{C|Ð:tCAɱEa%xsx#x:$M=7w1c굛QqXd9~0%2ĘÊP 4\XTkbPp`yiӻ# -.`Qq^/@5q N-(qs;|*Og [lm6aGnIi!izPHXTuLF0x.ёB$۰5Wp< gȏ޶[И= {gXzG?hq  u᭒a6n &(8!ɑӈ q'C`A@GqLn6hA 2ڲ \˚=q`#(tX:򽵈 N:p05bH2Wo͓tp^}u8$>Mۭa)-džt CPCǑ~&uW/=gKE1w!w&PFvdNAc";rۧY=V? UpqU9S*Bʉx=Qm Q|w3IOlTX7GiV62*!:T rf/THx' A*A@2ZLY9~ &׋˾Nu>DqMpj(4 awLC/mh06$ṟK'YМ nI1A֙񈩔Mdc *yC" b( g1YyHxe9!TQ .R %< =01TDkɺj6>rC\8HiYW=}~qoŃ0p 7A%" <)Ij}F c؀4Q* ~pjג>R@d4[pQk`p1-IG9 `^:Ukm9A(1*Ee&3E PVa w nj`؝5{M8}GZ5 ޵2,[N{`kHgE::<ɮ#hAZ+0Eؠ RC0ld:5 [ĬnѰ ŵRIqm@*kT7\ÿ { tC9@=熒ν1NDXF'-,mk" $ˆ1PYz8@~,%޷ĦBlF:4d>^eZd]{ d{6.|p;4Tע.g8l ½E}{^6Ƽ\z3vuy âwH;ʨ"xPk@怚O|2dr)w*\.OzH/m& .V}쏽ő k("4 *gJ$x4'?&`:~ZA4!Qpt7bDo W$PNP& c͔G 7<v95l:Dy )A3E4Ax^^!ihx|aMf+~\d_ bLa;&Wo4woGŀ8xBsS3@[(i@3*(\h(D:ͫ!|w3Bq4χgU"-kCiG,ოdƚ-KE5oQ+8wpaWCࠞD@>V()`O)P#I#mUUyt6p.O01 9;EӆȨ4 UㅦY=x7mGV(Phb1CcGO,3} $\HpZ`gx#Ip U5S<fO)*2\&B^jЫ&rCR |P(DdzyQ 4tEh J=8 [SGsulD0VٔcSp"{ msBRD NmꩥJf/"PwYȴE%1HiJ`Mρ'+@Q0B"Ziv?P9) AUM9\3(Kl؅'`Fj:Ib2cB`p`1so3[hN-:G`f&d(ďY2myCZՔ5y(S@*ͽv A[Yw5Ŧ=`iHʧ(>x #Cm$4(<|]_k1!*48lrqG+5V#`(1<[ʰ8.ҽV)x" .7ЛPqNQX\2{=1L8^+ kOCg/i8W--ʾ~' P}i48g"wxؘըR1a90 4{.O(~}u%;<`KQ㚭oq$gnY߾y)+4,%|5`C|[:Hs=:jQZ:X\{k4Rq]41g8u>DApt3@_lllllLQYQL,'%ͦQN=[ S#[)t*/}ڜ@Rj_(Ƕ]l&`RG*p4IƀK7wP! YI +^y=¤O i>p88Gi: X).gi՘3 MMpTOAaGH|/f]Tv^|=9^}ܒA~,|jy2sA;K/8S}T :]|R07 (c#'{^9E42\ƃXׇ!# f:bj}ffZa@hްIˌCL(d7\JEmقS|y䒊^덇s#zpbrBޘ+`q(0aN֡w,X ^]r2&)π78i8z0Qh5@t j7-Ceժr.\pSN# NhU׮8r9[P0Otom^[|h(fIy3閎.6Y0oAj~W;ǵO-n'+RGNS̕]:.,Az9(=`fo%cK3w\((_B 12hZ^Dyjnzc- H]ky \ceAa$He IAȖR~DT@ u^IpΓ($SY\8 Ft ,R0"d zR$\s.Q{!Ldny.LV/{D@WLgu0{-HW!Beي7=?:x²@GFg;3IKq=5T`d.nhz1G`)0,ϊs ӗ@ĕuӐTKa{#Mlܚ{2! [3v}I*%}ہ%gN Fds86U5^a p:@7rhh{t`. Mopidy-AudioAddict ================== https://github.com/nilicule/mopidy-audioaddict Provides a backend for playing music from the AudioAddict network of sites, including Digitally Imported, RadioTunes, RockRadio, JazzRadio, and FrescaRadio. Mopidy-Banshee ============== https://github.com/tamland/mopidy-banshee Provides a backend for playing music from the `Banshee `_ music player's music library. Mopidy-Bassdrive ================ https://github.com/felixb/mopidy-Bassdrive Provides a backend for playing radio streams from `BassDrive `_. Mopidy-Beets ============ https://github.com/mopidy/mopidy-beets Provides a backend for playing music from your `Beets `_ music library through Beets' web extension. Mopidy-Dirble ============= https://github.com/mopidy/mopidy-dirble Provides a backend for browsing the Internet radio channels from the `Dirble `_ directory. Mopidy-dLeyna ============= https://github.com/tkem/mopidy-dleyna Provides a backend for playing music from Digital Media Servers using the `dLeyna `_ D-Bus interface. Mopidy-File =========== Bundled with Mopidy. See :ref:`ext-file`. Mopidy-Grooveshark ================== https://github.com/camilonova/mopidy-grooveshark Provides a backend for playing music from `Grooveshark `_. Mopidy-GMusic ============= https://github.com/mopidy/mopidy-gmusic Provides a backend for playing music from `Google Play Music `_. Mopidy-InternetArchive ====================== https://github.com/tkem/mopidy-internetarchive Extension for playing music and audio from the `Internet Archive `_. Mopidy-LeftAsRain ================= https://github.com/naglis/mopidy-leftasrain Extension for playing music from the `leftasrain.com `_ music blog. Mopidy-Local ============ Bundled with Mopidy. See :ref:`ext-local`. Mopidy-Local-Images =================== https://github.com/mopidy/mopidy-local-images Extension which plugs into Mopidy-Local to allow Web clients access to album art embedded in local media files. Not to be used on its own, but acting as a proxy between ``mopidy local scan`` and the actual local library provider being used. Mopidy-Local-SQLite =================== https://github.com/mopidy/mopidy-local-sqlite Extension which plugs into Mopidy-Local to use an SQLite database to keep track of your local media. This extension lets you browse your music collection by album, artist, composer and performer, and provides full-text search capabilities based on SQLite's FTS modules. It also notices updates via ``mopidy local scan`` while Mopidy is running, so you can scan your media library periodically from a cron job, for example. Mopidy-OE1 ========== https://github.com/tischlda/mopidy-oe1 Extension for playing the live stream and browsing the 7-day archive of the Austrian radio station OE1. Mopidy-Podcast ============== https://github.com/tkem/mopidy-podcast Extension for browsing RSS feeds of podcasts and stream the episodes. Mopidy-Podcast-gpodder ====================== https://github.com/tkem/mopidy-podcast-gpodder Extension for Mopidy-Podcast that lets you search and browse podcasts from the `gpodder `_ web site. Mopidy-Podcast-iTunes ===================== https://github.com/tkem/mopidy-podcast-itunes Extension for Mopidy-Podcast that lets you search and browse podcasts from the Apple iTunes Store. Mopidy-radio-de =============== https://github.com/hechtus/mopidy-radio-de Extension for listening to Internet radio stations and podcasts listed at `radio.de `_, `radio.net `_, `radio.fr `_, and `radio.at `_. Mopidy-SomaFM ============= https://github.com/AlexandrePTJ/mopidy-somafm Provides a backend for playing music from the `SomaFM `_ service. Mopidy-SoundCloud ================= https://github.com/mopidy/mopidy-soundcloud Provides a backend for playing music from the `SoundCloud `_ service. Mopidy-Spotify ============== https://github.com/mopidy/mopidy-spotify Extension for playing music from the `Spotify `_ music streaming service. Mopidy-Spotify-Tunigo ===================== https://github.com/trygveaa/mopidy-spotify-tunigo Extension for providing the browse feature of `Spotify `_. This lets you browse playlists, genres and new releases. Mopidy-Stream ============= Bundled with Mopidy. See :ref:`ext-stream`. Mopidy-Subsonic =============== https://github.com/rattboi/mopidy-subsonic Provides a backend for playing music from a `Subsonic Music Streamer `_ library. Mopidy-TuneIn ============= https://github.com/kingosticks/mopidy-tunein Provides a backend for playing music from the `TuneIn `_ online radio service. Mopidy-VKontakte ================ https://github.com/sibuser/mopidy-vkontakte Provides a backend for playing music from the `VKontakte social network `_. Mopidy-YouTube ============== https://github.com/mopidy/mopidy-youtube Provides a backend for playing music from the `YouTube `_ service. Mopidy-2.0.0/docs/ext/mixers.rst0000664000175000017500000000240212653464377016770 0ustar jodaljodal00000000000000.. _ext-mixers: **************** Mixer extensions **************** Here you can find a list of external packages that extend Mopidy with additional audio mixers by implementing the :ref:`mixer-api` which was added in Mopidy 0.19. This list is moderated and updated on a regular basis. If you want your package to show up here, follow the :ref:`guide on creating extensions `. Mopidy-ALSAMixer ================ https://github.com/mopidy/mopidy-alsamixer Extension for controlling volume on a Linux system using ALSA. Mopidy-Arcam ============ https://github.com/TooDizzy/mopidy-arcam Extension for controlling volume using an external Arcam amplifier. Developed and tested with an Arcam AVR-300. Mopidy-dam1021 ============== https://github.com/fortaa/mopidy-dam1021 Extension for controlling volume using a dam1021 DAC device. Mopidy-NAD ========== https://github.com/mopidy/mopidy-nad Extension for controlling volume using an external NAD amplifier. Developed and tested with a NAD C355BEE. Mopidy-SoftwareMixer ==================== Bundled with Mopidy. See :ref:`ext-softwaremixer`. Mopidy-Yamaha ============= https://github.com/knutz3n/mopidy-yamaha Extension for controlling volume using an external Yamaha network connected amplifier. Mopidy-2.0.0/docs/ext/mopster.png0000664000175000017500000024070712575004517017127 0ustar jodaljodal00000000000000PNG  IHDRt백W pHYs  tIME5LVo IDATxw`mr' zқHQS,OE} "UZ]l(r۝۝.z#$PJ x@w/sA}>knx)垺F:j}]M[bBduP*PC߇RR\JQAr? w`ѡsԩܻg7ʧ!َpdMB?-p\c ,c@cDD?'^*C}Vl9;2$ !D ޜsZ'tw#4#=F;Q(Iұ ]׍kMEY}9k~Y__mɾͭvb=ZT㟑(C4U9jH( ʍ+Gewn+3'M h;ι?;6FY$srH/mPJ0 <~ȺP跟?ƊGoi#.5m4:տۢ|3eҜ IӜ>D?|w+3 #{pÜyoǗ'4MZ͞j 9zcP K7~?zQdr@E e;~f0y$8Õ$e?âc ۽(c#mc&RAXFq?mT1mn6xti!i>+םprlJ_7RQ(AJSJGO{{V~;;y\ !҆"c9'plp)ĔR9 oaSUٻj| ?~s$AJo\ ++lgJB(JHQiasxU9!讟Kc7VysV]pEDZOX[vMk}㟖f:$zu9CGeE., i/|GgNWzHչ^=N7bmc-o؇u )g3tB.bQI83G[f0[MV yʬYԢuNm> b,֌grNӫ\aC(`a d4B'bh5^Ԯ1_#ɴh9SUk6IOwxYػ\֗͜qLjw|`A^n^W-T]~iO`Gjoh՞EF߬LX'&6\F<&>1\S[QLJΌT5M Fn\-&rHСݥ=(،`Y\i}LՄMڄ-q6CwPJHbLf?ErΏH:vEEș&q{Ώ 3&F _45^ϕ̔oJ7 l !Bw`y@顆9+g8$ITB(^v̜RQC f(fp7DWX=p6ޙΙC)5ijIp(H`_Ż|)y|m;-$:UD.eRfp\qʏl޸m/Vn3beKJ~cc5F,e qGΉ9&2:}4\ sJphH(FC3s^R%QVh,]c %)5kZqA25 Imߍ?̚.UOUGf8ԦPAӾ]8li!E;8VeIKk9B8ԯrb]"}:{dxt@E+%'" H ܡm96Kd/eTv5GGL|0` ~vU;3{W N(Q__5IL *AAfI"FJN_fQi! _3"'gy](F 1w=Po ߿do3Eûl\媟Үɇ1^Kb/6b2?݆U&B DhiܢpӽjnQK|T4Ѣoޕ3;]ϡOm]lDe&XE_5 JčO^eDBtn87j8A ?ATK֘[viekK }RTN])H-{HC'9ɝgw_S7WQTwU-{kۻg[ۑl舼дQ/ :5w"ṝuz $3yp"ع_*ȁn!=;X-bi}{CT 2)$  $Yg2RM $E[ØgsuqO Wjֻ{FMQ)WF'Jh6w3JĬ5Ou`JkB4r-a,`*J| C~ى-4T{;}5beeOm!Tlmb4<Х}R.zQFhKVFӎ,t]%=̙n13CsgfԆOim:Xirc.GEkTloEvP),3>{rʟg$RLޢJkp9S/`ݒroNeׅ~zT:5Ug줛'3C4&/qLwmݒ!%ke36_Xe0UU BDfqUN4cK-樶oiYeOIa@FT^hb ~3)!*5բ$߷ٜ-0<603Z o7QTmJjy_~]lKM YRb=A$<=3k x%ZB连W2ڙS->JWckORluAMftMDVd(*"v((] U3njxI'j󚓲yqξE:]H]=eAG#栘p3UfnjK/u3N"cu+Wփ&$J7l̰r'Q2;uk[4,rF{[GwLK .a %t_֙9dlӭ3N81Rb-yq(PIpR葡N[p݈s-Qe!F 1 񹥵>bh#~2C$uciz&^j,f OE!\n- էJ%٤Hjߚio|DuQVgsCTS*H"K |ƈh6 MUu9( 7BŤ4[Je7~*"SZԱkdH_Uzbؐ6zjW8yMl_/hS>? =;oo/u~)/+wj6z5?RB*'RuvҊv/QJGMVc.f%E[g=[{{A8x`-DI.SU5///55/,/m,E15~] t'b^oMMMrtNpCgT:~%nLۺuk6mc(ܹCM!555n;""st:5MiڲeKVVa(رcǎ c"nuG>KKKEQ O([,̖QS#d(KU,i>lit+t{ 'k,\}ȄF!@r?~?טWp{&M- wOze_(vkH .,TEiZ$pPFn*ng- ovRTp9 o9ʸH N¹T!|((t;"Y1D ^巳3EQ0 He j#(vl;tIA IDAT<͹?j_>y>wص i 1q ~.1Dg5M0l@iH7 = 8纮*'0!/0*ʙhWŦ@aX25哨o+_|:j-!!!yyy}᪪=z488*F(jV]] o9aaa3!VEw8M kQM07~PC?yp֪4Mo4U%88844%x<^׿MyE al mچ zA(6lԉ]XEE  дh&*Fw/{]եxM<ݿg]Ep/\x.f@)pA¬f@@@@@$^$^$^$^$^$^@@8I(8-9pHhAR,k6_ H?Q\\\__{f_'I GQ\TU-..z~oAhT\\r|YBBBkkk { 4qfƊz'WD8Uaa뉉<#^ ***00p9rd`cee$IӴDRxE5M;zl|vݿ=zTxC8 {fsxxBQ\NgS]BHxxMw !6-00$66q"r|||iij{%PJ+++Q 0wDWy1֤tv%Iu)nEQ4Mk )?w. 9o;Ӳ|W|oxc}&CHf纛V#n]^Iq~o9>KWuU~x[/#N .Ji Rݎ_}>}?7BGukqXnR_PR߹/U^QYr;=^gEeUA)填fzp#n͇gJBYSKVyС#~[8ۿw|mʵ'W<.ۖ\ԨdOϾwVDQ_TFń4M |.ұzήWK8|zsjns%-^p°l~;4Bqw= -o~z5wig7`Yp-n>vʿ "O^?qfj[U>&½(FԨ J($R3?_uP [}@EM31^8{[>Oާr-c 7z]G^ Lk+W俾\G)xʟC=oSߟscSSR= OB_ʰ\Tܶu˖-V-ؽn(Չo۳w?7oqPjO;ۡXVW6|'"BHՕ!3ZL ׃E;ڳwS~M{Y u:s*:7 1IhS]' 2IٺvhfXhm@H9%|7=*;zߦRU_u\Dٶsi7 {Fk7d~ҖL=pۇ_ϻ_/*:}ŻZ=%_{ѹʥ.w/O6-cDo~}@;93^xŇ[:@+Ȋ,6 TRI P8h@brOƗ$Y%t([>Ĺ>c2w=:b=~=o=}W|}8ׯsv w}|sr͆>ѐcMQX5`ڄGָ"7Ɨ߸7YG<:$Bm'g9/Sw]weU9 ₭|qs:I1|G;Ыp㍏YMm/.vꉙ$$4Lq/n-Y<}病l?M.x%!;]э[/|$ཻ;JDYZ3uOſ,1z;Ԑ1'Tn+f{%Y){ ZPNy~]1 H:붇USUaE*J$IqTi:@^^1ί?7YC%RLVi [Zv!bxxopݏG=0N1BXQ>CPB-N,hDV996^hVPҐ>9rSSO=|pAtHۛıa1:3f|.]vX#]o|f|07Nu55r &=uVbP]s6JYx?TR۾g3>97}ĉDg8hss{daB5xݑshu|e+f8}kMfInsUa[T$jC7nɾ.1f18/Vڟxw~UpM&JfBrC]O^=~r%%EGs37hB!a8spaRk_Qx+$"!(A!QPˆD- T"b}sOpKXV |&ٲHj6|wkF?Ҥk?eW_{ix݂w_ݾ 8o@t\Bk낯==~ g:gp;GMrBSzeX/u35Bz]ge5 _ݼ쳃R X-y{`!(pn+ RYT޿Eoï(l_<_mrlVOHtw/¼nn{毮C(Y9QxL&NVMXv[ A1;$B.o̻㓵wM 8θ΅soN]MDu{b/ץy UrFc[;>K3Ϧ>oߥ>|Ϸ랼>r͒~Kn?N5S$䚗uRCXL0ڂ#Bt8Zc VyaIX!4}ϫߛ|SvϿWrUi{m _ in4w%t3U$g%xJ@tvecM݇㑷Vvuʺ5@ІbG5yΏ@Of%N瘧n}Qoo{k!;v,;acˌ{3fXSf*p&,)͒T8 H1S !}&RÍ<ՠڤm3熍naUO\92if{߂%?RaH5n͕<;p(y %>jz8kh -v-F-dGfi۪k6nڛp坷R( k5059[s%$׀,f_[)6;^{|r_ƣHKRy欎-$q՚#3ŽkCۓK~Z7'V!]U)7뜝l%һkЮUzvy-6o߱svOSU\lʢ՝W^9v]#-_mmz^6ʱWK7==,ja~X$>ߦ93MwEfWuz{;^иgd9/** ("kЌwkհ*5?dsnX]9=R8,\(4mٲcǎ(rjkkQ;vdee{ׄ6:TSSӹs?0_mɑrL?qapMMMNNNNdYuYn1aÆf͚EFF oM=\k7㱔A۴KQ`L`V3ⷅcZ{EkpPSh58I*g O'$^$^$^$^KPG|%-h&|KQ 煐RÙuꏟ2*V]]Kv8(7E(UUpDFF3Ɗ###nm"*-- A%Sypa @[vGsyޒA|3"""VѣGZ @)MHHv:Ҧ(DQQBM4MGNAE~HԖ뺮$I$RjFSDQeR41Dm$ιi~Fʲ,r||<*!ATU|8[l6IB QQQ1gNՁ@V\\r      *VC8LVY$)#b1x`;'jB" ! }4dJ $/b'z*mܻ&}^1JIDRٿn{ed#",TZVtn>ARd(jR%9": &x71-W'=Lv8 yn E}[_|*qzu_z%?:wܹs]tܹ%]떫{p˚+޷'E)E#.hQ]{޿VgI)[=wEDŀ^ L2IGt߼4ׁEBO4ݵ\PBrT)8[6PDP5Dfj3ʙ3nF܇~ȑO/PR=~9nlk9֤(V Q HhEU[kYQ{`VWX2vW^=m&QBcfulfa#SGvOA.pGvB~p΄TuV`942*J Րh WQ#8.Ft8Una j'Sc͔/[zk6_uX>40>^.˩`(Y@k>U'ӆCE g*>`y^pX&kьp!+x} B9;CTe_ nzcrWTEX˸K9XjZ0+(kӘC|4~RҌqՓ]^tޗLzm~//_ f?~Ň,羯ޱ#^|U~wtd^j,I505uf5yA}&Ze᯾;1?SIYe}vjw` T/ɂҳϽ1+bWʀ89N,lV︦Yκ=Bbeg΂ݴ䗞']=11cۖI#}bB3 {ʟh{؄pH; W}|QMS\e|`JN`(g'<S~` wu].Gxy䤠Cz#F]ѽUD| tƘ~;+jբeퟝ}++qz1.CsHBfіv o[J*H` δ6׵*t<馠v$]35Uc( x~N4[[|1׆%F`jxAWMceomibřju"cv A!v Wj(#/ЊgP즏srȿZOW}{<s6ziA _z:R %rܕۊY' j #(T!'&z?f7-n>)q.")?Rnm@OSlZ:#ݨ6EK82v*]q}iHfB/6k̀opxn>|qGND4ظ7qh=^;pf `Rϛ_ٯg IŁO`EFfʉpBT{궗?givD2wkX2-jl4ә'~S+:s7 8 5uD ;[8}/\()~@KIj^#vkhw9ֻ|lue٣3~=Q a& SNϜ ,] _w3Z^ҭQ.G : ew}7d4Oۧ3*VUxJҨc-}Xz ,ܚbP(Ի|l MHz 9ju=LD Hiuuu``(Brxŀ@p!in!JLdk=n r*Y6EֻLfi&{yLJ-D -VӊSSSQ K[NاOH> ?nOk( \xunY~p c/Yeee*(a @)eD'O( @$^@qAn{0(/ r***BBB$ 5=z4)) FYYYDDf5,77a .. xxxxxxü+>zi+eq!)2{?3&=$^qVz~[c/}wuħuʎj;cOUn޽O пWQ1=*wOLO9 *Goxwe_DA 1ϰ?`@^۴g#o7FW7~d{G[_ 0V={?]ObЀ\{+{+}_zh޽\cO"^ĤJ3VC!k~\XvRЯo͡IU['{m[ MĊ_ 5j7[;W0!J&lH9DJ odKϔiȐфw%6Yhj灗dsW*wKVsb:'QTh"T MܽlѮ^W ',V]!z?\X: .*gɢ, ~]#"]<yn5dX`zMmz5ҵkgD>g>~|wTۡûݺb<)$x5 IDAThnghا%ıI϶+E,PZv#"xlYm6:+l\€:7'BtW r%u4KȯggJ. (^5i1E]^iKKc<0m蜁[^_s99/<_ל'*=um*>1V[7F4 su=K58N푗Z~o*ƣ>ܵCѼw"IE !S_VgMQVDYADQO~%]U%/>1e#uFy/}h/!Ɔyи@~VY)gz؃MGi>apwuNH<84ḿOyツ}]mZ&Vn?1ほgm."V/EBZo]r;O?as >Z]K-L~ܨ"?fT?ıywmmwW"v/,$&JȈn٧qJXܗ['dd5s,{yʫ+ NcyQ`h!ۜ1ooi%huN%&VU\*5-N{?Z7?'|v"!Qn'Z b1^ΕlwQSvwAEAz*"X {ػN{+V@"׭gh${ }0f=gyF3k+6hW7\Gc,NOYJSߎ.esd^rRリ޷c᳥l4>AXoNStj]q7e倭W9u>uuU @gCvrMi{"_?Z㳚BZʫ'Wo{XVx, (JB=F3qګ&<+Y( w wƧA0ib 4c&>AN:iBXc=CzvLVkߦV[~u2 j BViki43P fB%)U\ݵfi"5/B:M7}PPch+x8iz(VW ҸH,_tz 6dAw75R-jߐ> =g|uDhgkeiD4 @~}dF=@aċP(h[K]{S;YE1@ξїoǽ=xAOprv K%p_w}OܷY~䲥_!ajBׅGNHy1^!7V2>}!V UTA\aƩDLh05#ww߹(~*%W(-qu>q4T4ȸq<E3 -) ZI9J]GͪhI|dB([=Jn[|0d*e\Iu4qI-gʇs6d&'T7^^۷RČAxQeD.BA+) ^Lv7(6Aet?O,T4!$mX+F͏)$ Pv2'hlavzҖK}[e!j8Ʈ^w\Re>{AݑfA֒kM.Q7(ȱ$?Ӫ6=8߶>%GӟW6MWBxsk`!q(:]E[[l{|ypjM(77_zӡ$o?Xr [eFqdZG^ -]5~)Ԥ=t@%Ĺ"fu1@ڱiзxQx{yTl#Pݜn9h)-z\,TCMbk|SIb Eё+.[&9q+IA2'[ӷ͚?:qH;ir9fcgyH%n@ɩ[EЈ~- 6D,5 6%6hX*%*~+֦OV޵%TIEo-Yȁe TT7}CTӬϮ+1/O0C&"%ZX$gz8 \]b1.m^eF*>6 x?s:F&h3 J"XDtΟ~g|Gv޶Z"9(N\4r9^ӗywR_xm' wƀa{+tk}g [ ,oCt_UXX'Y&>kL5,BGNffɿQhO›~ .X)5{_,!W166n7ݰk. Y!E_o?$›~4wXh!u/Vtd#F5F\۴UׄB!F e@!BՌB!B#^B!B#^B!B#^h=B! (Bۨp?^{xB!Pg5o(..`Y,!999xQXXü la-: B؀"0E!B!0E!B!0E!B!0E!B!0EGq B!ˆU}CdȅY1.?p%OO'5 BYD {ݗ ~e=7z>fΰ!1YxiB_JF\vyꭱrZôs~*W{ TYBhvz L?8 /]#k3_]غf:\B8oav햟>ע+8{oڍO;D=c۫v3]fɻkt wi.GqIq@MWEU6ugoԛո1@։R"VO mz=6PFn7D ZfM4W}pwmμ:k歽WKiva٦AʘYaċPӨʊs\_ub;/K$7%jk_3/$>*fݽїDZN5 E>*oRv-NRlAA}֥ث;%y~uKzՐ*KqӋyڦ} %IVQ){,^/I򞭛?m= (WK*t 'L\% !kDFz BEK2|܍wD9N@Z҅w"ce4tՐMeݹ2W3 Rֵwfr7f?~-%hEQlhl:U6MI{ut=o(xՂ{=|lܮ ywܵ:5c=\5__{,㡧7*%J&>v>5"j!FwKrjʎNuJ\73jќ.._4nu[}pV3j2Fli(VTE&7UJAa^=}7@~}Ŕ,o|exV䑸e /?n鱤VVz,[}J Ҩޡ;?>iSF^$<ȕ3Cn -{yoԩjz}ŧd ٳ?x-iRz&<D.yh7b۱/p!4 Oh wΘyHɱ՛fIxwsDMkN&J'՜\F10eO}W j$~[\I/UVn LK/&KYs4Mӌ3_,6thndT^q7?O[tLH˫RB"z N77-/D:u|ZIIFoZ3L*mTJ x]V^:޹X]G^`V!1^tOIǠ/ fϤ*3k9/wP[]@V .youG2(4@3NݝU䪝=/7N 'd,o& S8yuܓkAm!9BJk}IZ9mQMS ˂Z3ˆk*>'}Mޝ7ܔq-ŽnuDZUqj/83萴wd|%(])).i+HEg|ܴW_^6S ]U[hI Њz:j(+@|+xA>G3O\=m=/ !keNDi_?6> "HkPt+{5 t@ɓvҫn+ mWʤ40- @d5uZ, @u[*JfLRP#L\(ȍɀPMAj14g=N×U`OjTF/_s][7BaNiƨT" ZC܉AA҈i||L'@Qf B"T̄]kg9TЄp#X],泙zrOdn~\,"+545e5"a! e@&( R*Af{{N}C):"!Dh jc.h2<*Jqf B迊ye}y}4 ᫨ÌSI((1`sZL<bϢOUJ\auT) NNACFkBQ P4"Bd4_(w_kY-5!TB5}nk6=BKd[|h @iBo+͖gwY>40pVLYP;,NVz]of7&SGS{/2&Ŧ GQRA{Dtr9AE!3z1*z$.'^X\مY&NΓ"/ԩ`>TùW“[n,eλ2q.[l\|Hy!+ JѫT6ظjs;*؈_^~{phO҂L^ֵ?Qmy,TWrP2M7.om>n!P25Rr`T{dժ[Nmtrcd@{ӥpiW_oldCIRm?\r4kc3W=,+Fuf\L&iG^nog#Puu{M j!}L0_ˆ&RpoeC\\\\݆(hTX$5DG[$;y}&8ҤVX~hkPW/I%R9@ cX$SltqW{vw^Ob;E tvr+UwbXF8D"lj׸87Ē D&db0|kTY"XJT Bɇ 5 >M';K,`m-!*mMaUȟG異f'K}흧=۳;% w^o;5d2 RXPX,|e1P,ƅ J$; n :2km^7X$ seMm#Z$vz!qNChzn rqqqFVh3z988>q2wPYk?ncY ߚlE;%fB<lY233MLEEE|K\ _7N^[Y&UQ@h2ޥBANN1^G0/'1EXmo !8!6!xB!B!xB!B!xB!B!xB!B!xKiF!B#^(م!P)Ezz{"i~/,Fَjb(~9Du2ѳK:pWRdd5Y맢h`Zd e},ywNM8 -z[.,!!U6egoԛ4ٸ1rår(X3g\mHD4hIU*oq2@wɖ uY7V_wMG,4Z3 !xj5o=\2rnp6|zTTl THx؜3fii3J{ui)YZ|էExjͳ^֨;ԦAw^4qB1g2Q+7\KwΞOɬTثOW}y2aM᛬R֧G; L}Cyn:*2]B SM_'>~/Ҵ˽ 7b/,B˼}Mʸ^F\PChU{"Sv:@(ciU:tp -yq!ZNe@7O=%G]I/i!-}㮶w%ie^Rˎ>Ó%Nmjod l JvVӘ<e6@ճ,&l YwhK@/ EŝP{mxoMj Pg֩v ֨61tɰSI;|] *{6U#C4dqoމzwξr?tPb~Ef_ލk[J \tx+V" R+p[vAKM @Z*9U0O g(8Ƥ_-%װDꭒ|#C܄֎5dēC\47`iD5Ӿ#Ccz (vLݝ5d\JގfΝütpOG/VC=˲,@X-# ftq"נnJenJmknV'@i:PFկKby|@ }]Z?{yɝ+YZ pP'cWx6V]E1nNeB3^_1eNS]j=ջG;HNZ_^Ai44M5/ j#@#^Ntdۧ/߮&)7v]Epк=~j_Dovo:WM1y [SGe }u-Un<S1)re`흼}> YPњ HֻZd+x\OZУ˧A*+*3e!Մ@(iϺCIu ,7ރ#Zm)s(7A*L"?DG8D"hmd^wkVw)8=kͮ;mFJ<]9`PGAs|9uIX'ysuf?M#2D"p:#kZ?x'{'y,GB166n(  (((fYshwi/B5ԗ^G>Do CNKYˆ!B!ˆ!B!ˆ!B!F!B!F!B!F5)h=B! (Bۨp?^{vwi(oBJaME%@_ԦM*..`Y,!999xQXXü,33/=зq^BPF!B!F!B!F!B!F!B!F%gE!Baċb6п97!ݿϱ7 B蛯b̉wϚ7^ݍͧ|Ko[ֻˏ^5dPt?N!9!xꮆ[:m ԊVztwT<7dZܽZȞߍ]&"g۷ \])Wڑfh ;'kF{;Xzxw 3;oԛ7o8Yj }fD+X6Gn!c>} Bz>m]jj/3ϧ{QG` /jZ*646=g/ ۖӑIfBMBc}ԹGez^Cs/TA20߈ҲcgVɔ0bj<{i!:@hU蚹 )~~>6!S5ٽuqg.K5M=4Iu|to|a͏yp٤&AVCP/CI˗a"`0{/z8|ńMEљIuU^- ]9hC혘_r[ՒWVs-:6=<> $FE7~rKw[3w&>}Ӗ3MtWtmm?aLn\tIg/sae'.s o&Upcd݄  'PȒ%?h&s+i)*8j1"&-r:2mQӖNLֹ嘝fnx`"?U4 ?MXۻcwݳ1"iVzf7uRd]56<zj;[ ׯߚc]4LS/|^V{KM4(`i>C4EPqT̺K1> nS~x3 @^qfMwo'dUɯ' .ç5kDV/4~HS!W[P7jZ_kJhήi9u%.i;: o+ͳgU:umѤm]Z\:@*ԇfh-U6˺nj9LfeZ!Lz/(FAzWܻ? `;V{{l'GۍGܼ sDdUh2a.-{ed$]{qQ>%0USN> y->]7n@S=by|@*)IF^rJE! `g(Ms~jCat?֡TS}F )iʁ='W3{4R]K+(mf; ˂Z3ˆP+!KpNq݉ǵV]JP&(BUnmM]zEo~&|\|j ' ⾝Oa22$-"#[57AS3tG'Miw9LJZ$/jk*4i(x/Ia?̀x @2 4u@MG])MƓ۳(O=Uڸљ+OKg\o uϏ yNxtE ''z G#^$\ԩ#OVsc/l؎]?sYy?YulF&h U G}c]B^?u%W|jdF÷>y>6BBj 0TR$, |&`䤋'/}'I#- '$*dPq<E3 -ITW)UbToqBF/BF\H4ITLu=K5'VKNX,98RN#ag?; ;%|x~3aP+켒jz\ ϊ[+b Ép s䁜o(h\͓_R^\ nrN~OwaSfiK;8X]V 6ӑzNÁTɶ_뒫rSdE+vtoUZtcSv,0 v:Bq)W1j9@鳫_yf9Yn8F }pٲ -'12 ѽgRZs-ůxɧC9x-?3{fJ9RШJY>\Z5WKf6nѻ.^ڐoB{2nkAO*:[h6A2L"r5rpǛv$D*'S11w۶N wWُ[DK``l 'Աt K ;"J Ӳ1A(w]1mtJOQQǒ #OF[8ZPEEwSgHuBЮ-9d@d釕D.HLKo;Zy{zaiˁf嶦sb%2D""ڡ gb׺X,v]6/eNϬ3=j}~Wme}𞶕Bc){+>w [ϊx߫i^ֳl;fB< Fibb[͆"uuu>G5ҊBcc㦈PjQNmmm}6!2:Ռ9ڹfZBVZcN#0E!B!0E!B!0E!B!/B!B!/B!B!/BE4{BO7>p!/܏^ff濵U#`BX%",-_YJq?^{,^EmڴW~b ŒBcll:::8O effE@B8Baˆ!B!ˆ!B!ˆ!B!ˆ!B!ˆ^B!B"Y5Ҥa#!K֑~zP%%I7z97ܧ@O>zJGR!7ˍAB׫9QYƋYߴ/1oY.?>olG<0:돟S!K_xӯa4BEM2=SbaxtD?ToWyq;[uOO'(/!hաv. w/%;kt MQVpDѳn0\}A^VKuE̍k?{Qz`Ww7S- ?̝[:vєRN,WnxT+ozTGfNs k3\TD[ZqfB"42 >k5I2윺Jkww6ՑW;aϾV pﯜ}[ҪwnAwDdRo nv\}y=ɉmʁyTۧ<#|;VƟ}WgѫoKe#d!Y~4˯<$ggH`+"ju':ã@V"*jv3w )J=]˩Y_71.I:Ҕ$^k,MHxnrĵm}C;Hܽx^ZAfC`=k/_i9aS>@ +H9WǴm>N8=I%EEVnc&z6dUW.*U7q MIɭ n ߞ_uS  WOWEӷv76.&q$KEYޓhз g5&É*=xRL(^=v^_.^sEufV,G_C;!:o^,gb`k'9u%w/: ʿ}9fB"Tm<}YXpsjALy?O?U1ׄS{L_=yUFNvo3izXyNwfǓZ6,f IDATXYVUnw rf_?{,q إw@]\hN~\0y->CPk'QV?`x<@8*󔱝{8ȋ\;۠ _N&W3zj$J;8gjjt9];Z۷^+xrʛ* UnOɑ֗dSZ-TN]^QX7{RQѲs0[w;N_h|D4V?)o+LLvc(9Q@tunL0ׁ钂;O ,lEiӈ}vRқnVZQG]R4 lG\.isf߿bbˤW,-p"PjPl߰Kn4%b7 -PjÙ9o`3$O73F]9+mRzn<; Bd5Zj Hnoh8r -,pU5Ǔ;s(O=]ڸ6Qs@;baTDɃ#z/fA҈bhNN'qB"D ZhMj_W`K>A:js%:j0⌺iy-dWqKkYy*!'Ƥݐ οQ4P%`fųNHdep! ms}Ҽ}]Mt \ "1'TfeSC^%ߺxز\نjCSR'j*0rC%ILq<E #ITW)UbTgQ"F/BTq|KńjYt]sbM/L^iId;*܊MQW7[5azO;+hǝyUZZ`!VF}F5k g}T,^bg=0[UWd\3y챤wB@w"x{*.Ӡ{m 2aT=x6ǢGqWچ,Xvͺu֮[9W՗I:1֞N_-;Z,tqUaA:Im=0 EpWU/p UצjS9vʲ(ue T̜{XZtc[VKwXwv4zo MiA(`bF5D&[={llO4'O<,syg2YꮞoiiCuT*JàaWY9Y^Œjb׭GA-mÃCz-|֧{CÐi ;5'p:]KcE7kc#"ֈ\Frh@dz cu:O͈f[g Hh&ܛTU;dY'K JxSśo6"(((׫c͒fl]8_P.Xu9{}Em2G?Ezm5ky">{x ''G511ڃ)_- ![Ց%%BƼOEvv>L/?sj:{ >I0}nu5Œ!B!Œ!B!Œ!B!f!B!f!B!f}4! !;P\F} EjRBC"ւE?Uv3558l!x_ciiIYFF> [B)$ B!(B3^B!B3^B!B3^B!B3^B!B3^B!B3^QTEQEI_;(U_MZ,LEQÅeF$~x#i,6|Q O߅Ɇ/&j4]z۰=oRKn{ !UIQ5£5|^{[[wo~BDM΋iM3u>CX]ukʲB5sV,F$$`6#.TӲݏ'3 MF!&eqqsFi٬T"+[eno/ӯ5dFj6b;kn \g=+WP֥qQXь?[}ܸXnh0>d䷾sHGۍ#+ !x7k6uS+Wys~BEcl:NZaݥues[םLglbjˀټ|]6ZRKw`ҐP#(>aWym+gtf :to߱~!FqVҬwe1Z$7vcnݛT3=y|Ě2>e<#+U\ڲpY^wn>ȖsdZ7sv!CdL+ʦ[&zO{㻯7`(TMJ Ct7vl=L22րGラ[$&G[qNUnVĖ8e!B+7sO|o*?cG{sg>t؆q*lY!f6c//84oc{ָ[~y_vww/:}NϪ jFզ\Aځ m̹noU ;A0 +gG8e@YNl/We15s8?%I/7d^̾_.'Nd@e|ҰZ61O/w!5'ܖ<գF-r/BKNn)|(X^0Xu-cϿlь9 |Ząw+ߵu|h.W|b϶;9?߿uJ(>ϳԌjna¨MgVMkq8̏a+'90 a(>;j-}2w͙}>@5b䝆RbѦG:JuehNULc!cTN5M85*/OJeȜ4.Ywn#,kc=ŒjξtVU؍S&Xx1wE/l$^Nfh.,UlԗAFbw^<D&~!a ,ꅴS;*,Kyno7S[FY_hZB|-l7gwP-?kߵ3nF{Dvğ}5jԵe=zaae73*hy- KbR)2G#:%|Z=xkp V![_xzEyiF˲^>Λ+Ft۵ooTLE*T UBX% ǚ;ĵqqo,!]{E>u|_dflxAZTM?t,ö(πͽRr2\XyÁ_F4k+9:WGHྺ˛ΗIY#sK# Vޒٰ*##]>2+Q͌ljTr˙a115*D*ɮZB33H :5hNv݇`?[&,+c8g<)[ҥB)p.\hcyrޫ`wddiS&*R4_07d S 纴I$ c@\/JN'Vdzd9vCF _;ṇiUnc^~R,9Qjag d,axBTZXyXq8'P&_vvȾЅrhX4=Cgf[A c~uqsqjgQaR*.zYHWϔ5Q Dmaj2Y*)O.BWzffaAr%%X8WmfFƆ CgGk)'̸0z<)wЅkU[t8{Hn zUQU=:e8B%V7Z)QxŒj-$X(rMZ5~y+W^ K)֓PR, j,kuj J& ~1Y(e|IuӇ/ýGxGxrRpb-74㭔|EqQeXE(j_!W(hŹX?'sxy#6ky26bUipI`^Ӎ.@_t16t.EPIOM얳%@?jNط4d>~ND3o{g۠?|qґ}LD]^C9fum"# #3ohN MɭKJ*9N"%nS5{H&?l.N1aSu7r/<GWU[&ZdPzH7v^*USek>?t;Ńf!B 1mq.UUHU~ϷD]>sO%MH顩F(7k"'O]JkI(h/XyW7´Ҵ~W*]~_aPVNj^Œj8ӅN<8.9;׋ RIEA;]xnDOCvzЮlQ B1 k>۾ eT$ܚ1TK*IT}7,xA3w : 1bȰĻ+ h5}jɘ~"r=`$Qx $$!`9Fï=BCQ_]Uڶvhy!>A]1'^e_FBx`P𗋞o`6G6vٴmTx^Cۍq=?6v2&9%߯粒_uxS;gnl`fI/7A'O{Ą4׉^ؘ+gGzg/lJ222-77D.WǛSmq~c^ӈ!]gFZئ.JV*].0 G!tppp^?[cmaa7>,o}}0܊?id]w |n<,~_ơ]cp6\a94B3^~ƃmμ\?(VK^yb‹B!0Ekp*ӷB!B| B!B!xB!B!xB!B!xp! !;P\F} EȠ ]<y 7ڣyk!;P4\Ռk%%rDOlgR5;:1@QǴ\9/$w%"8/azJ DsLCLB!B!xB!B!xB!B!xB!B!x_ N` 02R@Vd !{\x^B_)U*AB;*/!ճ&$Q>IoC=P)߿)Y_̠.c΋(.I(}gI{jSjIF[?k+ +˝զ/%NO&1϶rrCzMRI|=fxEPI%|4Bpu"Tmw~虏*<D! Hð,K *!>& <MyAGs#nLY!6S PIJ(R @wciUL2(ñD0P*  ˱ !0 >;?NuԹYJ #=s }D0ozLQ01o&cDt"eXe lUwH r RIjEU+WrF.1 Z\zϋݩ wRgҴU6xAM;W> 6"% EΚٻ] UӮ9Wo-m?yW=7t =>G @X_!q-[Yt|tz}/|遥n}T%?N5Lj3ob`"J^E:0z[#OŞqLy럙7 TdX)0%αA?_'\3+@(=ln%)E CFqΞlEK[F;JWq? aa8BXF0 aX)4kzM=rmX}"]b*.lyX&1uI>ƥwH}3h:99YUү]ׁBLDelgmI0tDKW =yFBNEY}ƴRH:E}{:"B3^ a\]nXm7ɿ}q`GݺQ6Gz(mO=GWp;q;Ʌi IDATԦY7[kÁYu|| 2U7 HD %قQC:DE|ffV6pɌRK:u-|VJ- (IX*˭$3^>kxU3o/%&k3֕9SH[( dsa Kt(PիЁ;ޫԅMx-p*T23c}J)#1PF(e mMpL8-<γ1Db'yc_ $i%q8mҰ4KG -fjڪ$I+*m\LEŸݓKS $QZ]111`B563S*BJ]a@;$|Q\LHa-xٽa ]ZޕateW-[q9wM}I",o0tVTǻkNHe=^̝2v3UԩV0 K$P 2, !xΑs)VWtպ+,'ca!Zu›.W5V(EeReьR2RiXZ5Z$U'YJț^DQp -q!4S=.L5܂4%:` !D[Tkߺw8ix!)K%B4QOWQ޶B) R ˱A$D. >`U (ÿX9%(aeBP(m aW:J)( ^1SO Gu:Vr/ܽ81zLVâ.M;,I%gVxHނ(ks+RAsW5jLxA((QԐW ziqvVdoݕũě.gx%Aox£/8VszA3J_Wi5ZT+D_YCi`e%f^k ~ F#E}D/yw.h,>\SGs}'s~a%{#FG b|9( roUȽ(+Y2\#N̐>O727MWKIUw_+2^TTfU[}C1z*j#,h.mt0,YOS(#g O͞3} =o_;KǦnV~ycǯg}M=[T@]YE& /b Ys:S^jވ$I:ΩwGhL?JY}?IsFC&;ᝫyuGo2mM'-$$75#uPx˰q>Oo6GGDZX]wUI/pPjSƆ~?u*P}c[Z}"G0>CŇ~?"qg`c$HT$V]BEQ/4YFppd8t'RQՈ qCRw[^X 2E( :Fz_<8o} kv`ȖΡ_kfBUvZNۿt6?Njبܙ3;U5NC&v? HRַUCOz/srr#0ES;?d!/BqƋf裇"B!Œ!B!Œ!B!Œ!B!Œ!B!kp=^ @vB賎ѿՁRl0ciaƋj(P-?2BP80Es噚r|L<ɱd!}222 aBJ$IxB!@B"B!B"B!B"B!B"B!B"B!B񢏁$ :NX-K޼tyF)VQIyjfG#t&PQDCEIsdСKh0T}=z1U]%Uuhr*z ^xa@Nվ!/BB(;v1W=ic|ܭQu+W鞞Ae ,ȇwѾW1sOEQak'trpB}<]ۏR(TeXtJeYőɧoSRZǴHHII9hՊ7jg:V+qə_wm.]۔3wdk@|j|Im5J!Yɇ<_-12-#:F8cD3EX W;ڰ{5P2(lO 7e_ݼ@Fd΀츓[t=j֐/zzu֭3w"yX^#}&i8ut1Vgip e oV l:򼂵mg߽I+2_:$B=w-7shm6/.?kWIkjsn䰹F{.qsw[۩ ./IC$\; ;U_7\[u7#ývO7O‚,q"\p:os,/~fUyhǎQ;' sX$<ݝܧc8>@Vzy8J(q-;lh:cdBl̮3Z?00ra@љ:Fvhoذb{˿&me{XqK[ێn-;emϥ a?&󨎽ugM[7z|6 kVlm *֋>c= hVUąuKa߾̦{Ru[t6uaV֝w|խq։u ߳;ZښCc*;=)i2յ1IM6ꁽ%\ƱeefS.~A&>Zˏ->P04&FؼM|=w2|f6N>[ԎO 8-ڟnFE>ݺ4B[ns̼_6g.]ۡqn_۾jR]%e٪eмECG5n][ kѢ^~Z6量gh;UwDfZ8\&z`w&͂Y`ż3lto0pm74k`jn(gAڊ۪ 7Vi`HsjnټFȍ_t˙Q111*D*)ZB33ش`?&=dlçCJzxٶ!CaFּzȔeFfDEӂ+&0®NbHYZf2V98M90Ժ!evNV,O|\qƕ#;&ȀL"8V5j=6hIy&5YJl=SضPDz=[e[/}Wtu%zD|a/ c$ r4Zl} _v?twBC,[7 * xn ڶI8eiAUϔ5JĈa55URx?] mٹىV(0b^`8aMQPҷ^7un!S4n~؋bAzD;l&#i 8\ѷᆵvcKA^sS/pZQ9ƞJ = hZzmfI'%X$:6Q\+n $kױa n6n܀LJ}zM%sO^86t[0[4%$ Zj8~A&aI7OtbEe7-ξz-ۄz5~iœ9t>Qk؊A`׽/b+7ѓxܪȧzLvD?xo=A'œޖK!I0 }nu5&xU3B!B!xB!B!xB!B!xB!B!xB!B!xz!"B!@BIx2229RF!$Gk}Exџstt[~fx0l T$<!v!xB!B!xB!B!xB!B!xB!B!xB!B!x@yFhyH-Nv`jDʊx<:$elʼFN@$\sdѐKh(V'V>5FYbRyw^Qon\E`ufؠ_H%Ah4/7Khiz 7~˅lfmQ"F^$h+urCGwoðr?bd,qA#קEQSdBqÅQ}~ rcY^?wT+U<}\Ϻ\c͐U;Ywd2CyL!_fx+N@tE^4}ف lPC>z,=CFPt'N+ g P ;5d (Y|ȗl:&GhlZ/L2v HnWÐWm[uc}:e=[C0skmpE-=tAYcOlߖvOfjVçkm7dWYɾ6@0g] ӔflٝT; '׮=nŖJ%vfnk1Vo{Ze^C~Tw6"}+]^z 6oU[Oz8iJE;Aa gRQp4Y5ο|ljoɭxjLe RSσ=*/yyt5^ںx[6e|\5u~k枲h9TGCa8"c a@tMlpwOmD IDATZ{ݥߤomSqm-% n<*ҽսc'y{OUEt6W#34ek(c$ʳWN ],YqNVA`_ɃygfXi &;D+ﻍUfUQWO\x UpjwJU ONQYK߱{׍iXnhp+Γraѽq|go%m5{j4li\: _x{y7xEF/7tFM}dr`ާw34q<LyBD/ڲm~ UwȯN.!G7zmYzWi}yc6ng/.ҫlZNt҄^ݹ=a9 Uӑ*$2.f}p"H H`͸$t9-%狧$ߒvC!asf (q vLgYM~񥛊 a c̈ʅ-Y$ƾ#̍x'O,@X/B: 6g?g†J[*)LM@]0mF7ML1ɏ]8esB2#ژQU6BԳPq"P^W8R~( 1~X}T Fl΋/22%.U%R zVu#8M&¦,9~&V&|6{wQ/7> Y olhjK7- xv#OS[+{jS{qta' G- Mj.SIwtgn(8B^xL\3\9%"˽lgUʎ]\rO?zeA m3=m˶wvPpBV.g5.l ϑ7@ _ yUaN`*@ /0 "PR|a>^\bb"7jiF2 0V3>GG~D8(99Djjn,11>b ~, <  @ /?`2@ VZBTXNTrZ.J]Ө u(yRPDovzo$\P(Tjգ1/ -Bab6m'(W)jV5kžw$"ăsF->fxWIcj3@wz_QknõU!6Ġ.l_b񗧮~8w̋oĤl[g'c8 ڜK;ܙ\.I+96سM W=GX"?(A~wӖm!&Ҧ-3T[5X,0k1qo_Τr|^rs.;~|UY#ʆ&S]iyʭ~Vwo {EU6}XEES>ŤgoRw,QEZ4b,D_WփS+7 IXȿgM[kѾ [j\k9bCC=B߶š3Bmrxbis{t|}ze''xuڽfV5(Ҿ{/$3R ZKywfάh2̺x6mYW_ȝ뵬gȐũk$KU^&%1հuJ~8RYG#ɕY7r|ڭ'DΕE7s~-z6f(5ussĄ_dfDo]yacβSv%b%"M5w?F^_ٸv\1 ERF"ʻn;)+תm׶Ҥ}6:+yMN/ڡ^>\׉Hu'mʙxp?QΝу'zSH`ҼH[Y>gѪ~wfZ=ëל~bTKܰռ}kͶy媷j[ۺ0.K@6,?MxT-um$ۅ]lG+rC (%ji~6hf[2gFt&wQ~l(3Ю[Lcp}M ^6m?Ӹ9R>:v#5zä򲍺k%wRO^Uf-7Ϟ}Y^kab#bD0 bݍGN-#.Z.). >gm.2XN-ʿ!|dAzbgsi9Ʋa_G,"1Z=lWu,(~Ox>=h[uFUNxE忌ۯߦCO^ y~`"cn(ʱrfawkvڀ?oMʡVMRMY{낻vojtnv I{!࣌|"f>ydrX:o^GT{'_Nk{&-<Ѷ=gU"܋/.GaVLjقP֙!×EĴY%lp-Cwܤ]xiŵUCڬ F]ᒑ*?$.Wm?t\P31g\P j}IvR|!vp(O%~jݰA"2 jw]d"^ݞX[U:4.Y)S>Eη@A6k[;@jye'eE+t}9nws~Z%q+1.nV~ʼVơ~uB}4S3ӶĞ>}ږ__Y%pvN(ju~AcްBZ -xi^UG^}ۖ XWC9*xho>]Ctq0 t?2uhԥHIS)߈/w.{ʔ[yHnz\v0'^|5WO b-H5wjP7<ͫMRԲuLMJ8p72L|͘۾oR89y%=!o1('VoJΥ[Rm?yv.m|N "KTc~i߮LE,ײS[F7[;Y2zm񂞭H,8ǹĥVHKwV%"]}t[TJD/f jynQ~1KDD,yfZh!%"20g0f3W:M긫]K9Fq9I*0֮zʸP{ض͝*l޼}Rĥd26Gj,pfb""D\01b"35 x/u\{i~VL< DO3fDDdhilȲ 4_;@G^Sk&?n84O)ta̵C֬`l~Vyw,p"1#j ,;xa>Da..DDFdoS-p]6{{?_sGW CT.rȆ8+GtM bcD2"+2RL+jQo! WokԵHȸU֬f{"F b"35)5DR"R İDD@D'%uvjeFE F{!^̔J"#"R(y لuYƥ[.ft99RuV2cͻaCa$1H$k?)ߨP_dUlWȗH`f2N UU u6M+i_n<82qI22"# 5 6*2w9ts5=.'<`A#r%(L:#c~p_)T* TaWNnM"J[5a#[?oWm^#sÖ{Xe݇϶ ,I{~$nO+۸VY˓JҲjErmzp' _r )=Gzqol%E(=|%ݯSэl">ug׻ h J: ǖLH$ OxZ\k͞Del&οOxUt|*h:<>ےUZ$arejŵ2%04'uq(k~x+TN/]s_M_4l٭e%^n[{_GtVtS^z=?נI)jMNH*?>~Ԙ):Ah;r.O}۝773hS=]y__ܵ4SSSDRزc< ?KLNP`$I~T$޿ق"HIIii= ,/>Ow[e? mV %ϑW3 @ /"^1 `HT_>Ȳͻ! (Tci>x9::'+==L$™@8%RSSpg89[cyT/"^Dx7%hr\UtGF/W7<_|NW>"r>VS+yyyyrZ+REt%M2?_)Vʣ  ^|T! #J1B;yj||:(ֿEFШ骵NB^+UjB ՇJ\xBC>oѠ'_=bĽOHDD9O/lZ:wAGfYȾ&%5vGF:H~CǁLz5Z>3YiK.˩rӀNoR:Od3qMu ǧ^f&iVs rV,E, '=yIO(ce$1s?OzgT %_sSӦ,lZVwgلtb7ӌ+YdZ0u$FBK>6̹{d ejѫ2 /וլM[P>>zPc]&cAS'#zĿsU47Fl]˸koR!b\Ae|zƣ/Kmx'-Ȫѳ(֑9mz5s^Ֆ!"[0kU"R^9}w[`wB (/ZiZ:Դ" + GfHLئwA㚗G ,gu?Ϋ5T%b%"MƵq;gv޵ڷnFzuyݚ=sxS-2Q޽56~P]pku]JTmcgLڻJ`fSjT9p~nc9orR' Ń{9=xGYQ>DÐek>ih$=e~>!%(U'+Hͳp m>ܦ0.;w*. #~V]ȹkZK61>W΋]_R84oW0~-\r.\3ѣdž2:3v1j{=yCcs}l`ۍx`a' ZE#mW/~_qLz/W؊mIDڛ^b.C3'd?"S=.ɓ"""M;S}1 IDATş~a#upl>[`Uu2>>ed"Qʅ}'Un&i158_"2rY~G¾K/f"O&}f$J>?%GLG'?خOP.6I.88CNDڜӇO)( {ukIqmEvnopaYa11wӃ/Qy7վ^n߹F6CwzµeNoǍإw{'KGݣ,L]Z:W71zF Ĝ4*^B|uevZV9姝;dݰ F]:xY+$z, E@ @D_d׮tߪ .ZӟujU3{Z읾!GD 1MnQ@ňv=9g%od/3(P]c:W/۬Cʵ9hDۄ:XH9ei'3.[贬}%lJ&KD '[t$˷gJ {s|,eٲ+>w~piҲ-ڗ<<%Ws$"eEG,òN "mk%^KT޾s5_lPD:tZWP||S/]7׭I`9N,\yF[koA9tg_ՏcoZwO م T}cS}1K^{ٰ}}]an>m[.SΑup߽4"Lϴl]o-S'>7-ÝTѽ=6lnɠ[WJ*9o_rOsR!Gϸx81Dڂ5GOh)۳v>f|YED'fnʻ6/ΞU߾I{T_v,.nIhYZ$erfjm2%,Kq7X8|u 5Q bF*g&[M [|^n[sOGtVtS]ܶ?c`\ZaRʳ+,j<NOe:!o\2o4on-;g&T=vg.-rDfD_Wj*^:&DyיqX3RQhTj -|#ATa,CH-¯3g[wm7 ̣~څVZԵER"˪mSKU 2REjWduV]fh7Jm^ȜLCk.߷zV7_1Ocf+p݌so{&q-ly-<2re3tVO!^3۬[_j9DlT5vib;u½Z6קZNRLzgȪ*zLhpVM|}+wM7AưJKK355H$EW"w;S' doR #%[ YKKK:6j.1Z={}bJ:/0E8h4@q/R0 "PR|a>^\bb"7 PYa>^ܯt333g>%'';888HMM-%&& PlEqP"^Dx @ ߔV+?(y^nNNn\5)s?'W]\]T u<:U^^BoMv-:iVn'*-QZ].~|݂Z_(eNTk jBZ2<`Gr)>@ɻWͻSJUlآֿEF(幹*ͻ?שy9yr廳_ɕ*]A(hR(u'| N4`DDKk'MzJfgw 0:x'NIĺ̺LǦR1'⾰Ҧ G>J2(YHb`WѾ,Xb=a3H,J9NLvZ71e &~NbBcӌ-3.sVJ" 7ʹsܯ_7O52 /ו͜Cӽl$j꺖.onšzmq6e}7'Sm=}0+J/=x+Ю}c's1GW*l=^FHʺ_gmڛd.3DD96mJmZ:] ;~k^K͍eΤޱ{="m=w_oۼK\>Xوr7Ja&?5k2} fS k'[cBN=d0ĉX{<ܣC֮fRfּܲ|] wI e,+~]ab:]J{_ȪEuiLD6-r-cشk Äj=j\YM~M,)o셢(&ΪS;T,AJ 81KD+q+vDÐ%$sroo?=9"#r><i<\ a:FŹV궪SuYCg'<=PxΪ%ot2j{+.HYg?a͎3>0MlݦιWf73cC_[ۘ5recV,hVAu6 5aV0#b!"H=kݭū\RuR YH7^ўDDT<A / ٷScCbiHH;q՞sZmdtzT'kS'r(_nӣU+ NRVHU{۲+]J7fm\cP<yRMW ]+Kp':-S:s #6%%"j.:֯g R޽GJٽq.ٹDr]#\tKRBJٹuo\= <#aYpjimQ3PSט}]Fp#͕guE{:^3G.l|JɈZv.uz;;/d6PeFbx*a%2}>m07vZ.SIt;yc>*33w=LLܴ<wR{y{ߤ)F-}MxP "bY阌/rkAdo(S1bwI~DViXҜg#6-CXfHDċRs!?ɃsN.'X9O<#1?8䱓G&u^zo9AlakFDqӒeWG+ >DLE]?fё%LO*cD" iJ2 m{Hf]]kXڏ7^JoW?bKν}ܴg6@1+u&w9x{%RQp\gZ*ށ0]~ʸ1rRxnZ4wLz0v+X͂>ya.%5R9 C!"F* -iruޕ%;m""Vn+@DN4 .chadȲ i=Z@@Izq7:W)l:ݨۍCfWQo\f*軗-Y$St<^`9`C`9}zOnWy^@V՗Yh˫b\\D8X""bz^RMbV^D}H`C;6ȶ\%U"e$WW$լdP&^z6!N%8H E)[V˭j_"7On]ӷf򭛭_ˌ6S=2~McbHGd|J ))DFDx M%daihӧرy"ڦ"-Ȝ)y:k艎yWҳZ[Pf2̇P bXH2D07j# \U|qFNҀ  \ɳ "ZuZ)[Y aZML|qge5'-lGDS [V3t2g(oyIDaM \Kƻ`EDn;ː1?8joN*tʂ(N}{%:mUV~|ZJ ˼0,e_ٲلG'# j-SBI0Xyy;N.kimopJ0[={.h6]ݜٲH[/fJO38iRx&J=A'r8Cg&R?䧍 [|^m[uWKٛ4/.Q]uq$"J;iI)ϬHZwbFL0y/_i31 asyoeinXz+_m|`#_|]ZU Ow.='oXR`PJAkvH;a>aXFi [[~klW؇Vxat$رY9K@eò,$[9M}?(ʈay7D1̇ղ,0\D5`RYDBB ò,O2ӛ7qmlY{e"+Tm4F3tw!7#풪>;9i`><96pJ7M=˔JEaXAEufKMo/{4 2*coFZ!ؒ%C2% @1ð/["rϮ<d'/02+7eusIM,9e@`~]L]"U|C^(A7ejj}nɚJ):ID_:ȭEwE6}CƲ/~ 8 B Rf3\٭ۆc>-Q W=2~ͱ 1WYرK>_ CzCIO؞Z?_L=3' y}nD9Oܼl "+{W;kUŅNy5ѿs7'rF,l#(ve ]vlecnу9^7b]'i.XsQfkGפOgMJf[/}"iݵ_ ߦyԯ5x|ڎ"u5{XѩS)U $:ڸݰmmȜY}2W{J|وzU}6=i@J V#pҲ=Vwқ<Ȫm)6Ohmzx qS\P' wX^cT >2)aY(U";[KkC*61%%KZ1">vVU|+6<=gc^=mP-gyM!N{2Z=Kں,}#D_I?޿\QW-[Aj Zynq컄YN2rWp3ڂ-xO@AԸϐz&/['( C5/n[HS F>ܵ4SSSDR?@ K|U^ 0p!uޑE .Lu~d=2xju+ 83`ze-pw8%RRR,--E" hB1gCc>_2_{,;I0W+GH~rryr>]GO˕rNiܓ73tSnΕ6Rl#8S'[YBb."LqX/|{zFV^V #pte ]<|p<@f@ /W0 |ϟ<_ wq`YZ*P1˴ҥK8"^!W3 @ /"^@ /"^Dx/"^Dx UX2;:<* k{3GMn ('S'L|&Zvڗ >sqf߶t+O}d?aف+*9Y{onvu rs_e%on9:M:sM)H4: ?d/~zqRӸo۞#npW]88ZGՕkpIW^h7 5 9 SDíg;4SdR#8&E |~X<}bp=kͽ{zF[MZkl-?]9Sfe8w?Sm铊#6FΞr9LOkT2cig贞^M.vt}?qRN e.D:b婳mn52"Ks+jjzgfqQZ/iM$TDS#YwBLl@{*KuQSmj۪ηΉœ.WSN%+z}Rl$@AΈݧN5%Nc>yjnz ,?kwUk_jXkNiJaICǭh,+a9NtI}_0$.kƤ=gx`pDD-쉍z4KІڞQ-!wj^EQ")7&̘&0bo,ݻioazL9[ґCք)ahׅF"S'Nʌ]wmǷ?5>maYu[E]6azZ3f@cIELج )fH;s͜Ȯ+crܰ:F}H4>5-^cJጼp6F\i, ͌1""kչ,&kR~R0P~N*F"'NOqjȉӦřTkUYMǐ&&ƅnjp$OsZG~1]8fm>4Ed \m,-² G:c':s}'Fow㙍+'gG;[&9m"_3p#ÕfF 'w7TG&˷~H;kwRz=ظ~שuwQy9Q}Vիv[F}oGKcnjJv^mv)ętZy_K |:GjnRݍDҕN ΔhSߖ[ 1"pݚMG)z-ݾF4|tD#%'\}[Jz0k IJ QOLKv8P!˟6m=/e$y9 ?iShʉHPvb7' - i˖%F:rr*5#3궭R9n~zךcΤDMAƈRګO415Hw"#&%gv^"ꈈ 3~Xi5[;S,Lzq"F-3;vn/PjrxuKB"52%[o)Wo3 8JIrٶKU<ѭ)݇wnI "uqvM:S#ٽ{ϻUuu`A1-3C׺gD"1bj^{BVBx G\GBt`Mϊ1֜ں@=.nڰl>:+)mgk{f& &1"M4p|]1[t)ٙar󆭇c=Q%ߵ~KE'{U~hEg )I6A`Km:tzUҥι}FäיBf,EI:[t83:MWB-iJՁ)YaUkv *9~n#.F\hz%G$DNLkĥ&i.'ܹgtǘLq`IL3ne 3&InxjȉeuGgǛzdDClZRԖW11&MgMN9mY)7xիsPG ĈљCˆH!ץϚE%p*3Lϯ(DGsRaZMkwzf>6%U.`oݖm#J]T񃥥1Ao0SBd~KS *x[FEl55Eau5r٢bP>m}›3rgl?C3ZQ姺)NC. ^?de9 Zc>1lh}߃Y֠w|x##[o+ʔ(sqMI-3A&RrfM T*sDFD2\Wg)JR>4e!""\UcG&DɱD (=%gV$nvA$+8E\[;\XW6'N=-%D߼5JG+ʇ& F[u鲟3B.7/q|wZ)149`qǙuM)4SIMO|BىmaYC(cC8u6g5'X,~Q;DigU-|v$yKޮmuӲ25®ɥ,sάiyQ)?lI}'v77de瑶\Hyy(ߵfo=jOI7&PjZƅ':#JToMp 9u]Dݚj8# qvg 9١[|g*c!85o'"{Lɬ$&dvL?-YkO쩮poï?ge~qʸouه?~aKh|ޤ)33mkƘid'ښ]mEQ (IHJrmc wEBV'UM yq1"u4G-X8ifyن z/2ʷ]3 Doh?9r飽=~SJ^j"Kz-DJ{Μ9"յ)>WEŋbLҲrmۀndqH&`  ZڑwFJa寜(hkX0-'֦etVMaeô;gO %M-6#w90Y_ gyPW<ܚ3YMmI!IGDLoU42ϧ"-f]O q c=5D>׮N?NEۈtq#bLs[jUH0ǦQR yUGD:shfv( O+hI !"bQyQ5Ruaή`̺ܼ\_N[#iޭostD/87}=]-_4#N!*5? 0Yc&|IqbfD$j40FD$rm'2_n6D$-bP*1vFĈlw\=S)&tt3JЗ:U`G_КND$SpQ_>殯g2Yk){F3R"L#oJlL'hmO^6 UÎSM뻱(#-DDzd\-'•?%C7W~2: D$PM\?T'Wy*+6dO?#d՛,KU]g/!T.I|=V،^)tNQ@D}撷U#0"FQ~[S/|UqN#}WE ~Ώ~=e朂G`c0ToR?vIL"%x2qH mU$3a&""`1ڼz""]}`ڮ`PQ}y2#DDzg' 6'߮.UOV2M;=܊3&Gݿxڛ܉fNmyI'|n5@D zl4YUϯCJ a a ):==!cL޹T΍\r_j!k$(>qE 'dz6#Wu$HheH!F"jX`i9t2|ymG.F9 4OCQø$qR^re2A*4FD:Q ^Ig/^vxRNU"'h ih}|"Holе:V`X}:-wG؎ i›m}my_V9$zKʽzhFS|Όo>u; FD祗3]$^O|lWH j\D2.2"!v6?c\sFDǎ1zmntv+Ӭ&>ܺ=+'??/AK,z?&V/-]/O)Ҏu$wh2秊tvzc5{O8染vyȲ֜ShYplDأ AU2D.;z1qV[L̘g[+&qsO{ؕrRҭQ"i~mZWˉ(S/MIj_z qTn,z? ;k.TͶǾu߃uew'"ASc_z6q_Z&&#"K9K6L)|oe={'נTop8V˓x!FOǖ(\*B' v@*Nu~joKs+yw7c2|::V$Dщ SIt^[?v `\Q"'"AR',~TVZNͿ;:.VN;ܓQzLpj1gFBC '7+>[ 7bȘ7#'G >|wemqLSe !v"Ὅ __8I`MEk}GdNh$"?q˵?+Aodf8'Ҷ}VomTkaS}Ϝ@{) 0B'$OG诶x9{U,Q2u7UkDԵ+`RY[{/4hcuFpQuf8 '"M ͈՟9?@D}_ud挎puD}5{W:)TcLDM頋ĜiS=uW5* O{Ɵ*mRT_oyc]rdMNj&"o?M14kt^Z͉ ;Z" ډX",B7CIo چa<~I&Aꭉ1mzjO˖E"".qu" ݽrwi2ΩOE5% fΣO5A#25zWJ԰o}mGx֬EM#D$N3=8:^}]uh{`vfwﭳN,ѕL5@Ul?sf,tcp9m5@P Q&"4 m7ȮQN%#֦iI׭(,mM&TyrU{Ȗ}|tLDM뗮4V&ފ=+vqRBaEi (شz!ʾ+W~q%46Tݭ:3JK:D>Cyz=.̽T-}W6BS =D%スY \*'Yv՛oZS,M'Ϗ=M }$?]{8IVԾG.p&3'Gi$R+6>u1I7+66N!&uɩJj`? TT&%§fox!|u)7,ֈ} p;U d"8VS6hJzˌA_Ғ^c\Fᬉ^([/k{OI-ʸ;fnvHDA׹+wx[Eie_߷iC/ܸbC[vsPlΙ=tDREN檢\wkq?|9fq܌mܣiʥ*Mٳoy0!DP4r˓{w{#qO5,DF'8Zҝ\Axcy?k|;O׽;͌0*"+dyhN2O%]NRx~zbOtv;D^ml"Ӳҏ=/?*:~ K`Ŝ}mܔ2'gڈnI?Ƌ`I3HVQ'4+eǬG$eE83U+^àޙswkКﺻ{QoMBrf{%~W4g MEen+ZxW(H5J ʺ鮁?ZJ5a &n_\{/[~2\E{W7_^|$}/y;;G´']pb{my c#!IS.urMTmן &#KgC'ʰIsMT(yvf7ni‰8EKּE-~|IhVdEd˞uwꟿ<),w3Ό–2IƦbF9Os(4mݛwf/.Y·?4; }6[UY 1G*nuc֯|m[3>zuoy}FD"USEd'%kէ*_5!5'io|ʤO}e~Ԑxڟ*UE>[Iw.ڰ|K;-qOe%z>E-[~Vh /fUU<CUT_8WU8 y]o=ƍaq3,8_ D;|qB&_Խ~^HCiHUU#c} g-_?T Y JD9ֽ}YϿ{@5X2?r13_:w~w~KEwõ~>}ywvYtC&rۤݎ~K"fNwJʉN+]yׂC>x?Ǟ~w%~iť(r0Hq3WmG2n/ `:syzu& N OϰE-(;[L9_W Opr.2VfiSֿ:n($H/]>bً '䨍uAAoK]D{TEc{q-$12omvLSD_q`ȹVd$2zVzqWGYhu\dC.NOϏ>Us9>}J`h;F"^/͑>OC] ?c$%t[zݸ2ev⟼b}翤;hV}hQgPM /ɰ/>sG'?a}-m&'GkEwNJf[}&|%j,r:7/Zؿ%Il? ?*~F ֯`%(FttΙ70] ϪQ-*!%&TDsoL7KxGpf诿m*m^3al21(kRhlZrgxaf@@@@@$^$^$^$^$^$^@@@@@@$^$^$^$^$^$^@@@@@@$^$^$^$^$^$^@@@@@@$^$^$^$^$^@@@@@@$^$^$^$^$^$^@@@@@@$^$^$^$^$^$^@@@@@@$^$^$^$^$^$^@@@@@$^$^$^$^$^$^@@@@@@$^$^$^$^$^$^@@@@@@$^$^$^$^$^$^@@@@@@$^$^$^$^$^$^@@ǒ|>x98 j$^$^$^$^$^@@@1AFA- WlJtoXY 8 :h4F^'s.&2O%zΕ7j#(NՕǫ݂w+AlFɤ"b0]} l,2A0Ejffx0&+tvjP> n%kwUuk쉓vS+?[9 aD|N!\ʲҼr3fNnqW'@!U s6\˜pHK 1^9%?{WJ9ߓf+=J Z ׶qqo_>}45?]Ig4qA_Q}A+?nh|(wyS72A3:ʙM^H ~LZPn?cZ$^ .K*sI-Cfԁͫw5zq1q5Ysx$ Zv?ԑ^^9#iȗ¼>cwOKS/ݼ~H&fLxxvy$rέR1~Vҟm=_-zqϥZliܚ4E}'EoOmB\ek5 %u2KӸ{^~"۷1UD/ǬgSRQxm[kSRdmpB]7&@/>L~4_vOŢcjñw6(HeD ꝮOkSg8 :ֺeʼno8/٘d v&?}Qm#"yPAYU81A5DNDgǝ Ad ;j׸>vi@hƤ9Sr9n*ИTiF7a*'Uus$&z{%AP5""&؇0-%?TY:A*;Ⱦ6eȝ#hmv֙bLTw׾ z޻g>7(191F++iNY+ cb9s"$Ɖ8.n/X a_д۪GTrke8y;\/^XFx*p3& Ô@&W.Ę 8Ġl16(-؈ `1TL1^ѽڴ$:1tz޶.?]wWXVfeOW ZD4C뾃ނhNJʎ[(Tj5LNЩC >s]=ȈG%XF|-]>2u@נJU55FIІZ\rxCnUhRxP&4&?-75V3E!6::\vwu<~-ahD;@:KLbb4%?͡aQb-95L$ 6[MQ&su5)2>!*]yU3Q -}IɡFi&EIiu-Iᦎ3Zc] 3{::gJ4kmla"ZR%STl|Ub,0k:+bIDAT#>!8nZ2h7 $g vylt(pkXDG؄3VugB՘%R i!ˆsDq:>-=bfDD?XLXEjь!׭rBVT xC_)@G]TDD׌=f4n.u?¨f3ǭΑu>) xx +pIENDB`Mopidy-2.0.0/docs/ext/frontends.rst0000664000175000017500000000340712653464377017471 0ustar jodaljodal00000000000000.. _ext-frontends: ******************* Frontend extensions ******************* Here you can find a list of external packages that extend Mopidy with additional frontends, which includes just about anything that use the :ref:`core-api`. This list is moderated and updated on a regular basis. If you want your package to show up here, follow the :ref:`guide on creating extensions `. Mopidy-EvtDev ============= https://github.com/liamw9534/mopidy-evtdev Extension for controll Mopidy from virtual input devices. Mopidy-HTTP =========== Bundled with Mopidy. See :ref:`ext-http`. Mopidy-MPD ========== Bundled with Mopidy. See :ref:`ext-mpd`. Mopidy-MPRIS ============ https://github.com/mopidy/mopidy-mpris Extension for controlling Mopidy through the `MPRIS `_ D-Bus interface, for example using the Ubuntu Sound Menu. Mopidy-Notifier =============== https://github.com/sauberfred/mopidy-notifier Extension for displaying track info as User Notifications in Mac OS X. Mopidy-Scrobbler ================ https://github.com/mopidy/mopidy-scrobbler Extension for scrobbling played tracks to Last.fm. Mopidy-Touchscreen ================== https://github.com/9and3r/mopidy-touchscreen Extension for displaying track info and controlling Mopidy from a touch screen using `PyGame `_/SDL. Mopidy-TtsGpio ============== https://github.com/9and3r/mopidy-ttsgpio Extension for controlling Mopidy without a display by using e.g. buttons connected to GPIO and text-to-speech for track information. Mopidy-Webhooks =============== https://github.com/paddycarey/mopidy-webhooks Extension for sending HTTP POST requests with JSON payloads to a remote server when Mopidy core triggers an event and on regular intervals. Mopidy-2.0.0/docs/ext/api_explorer.png0000664000175000017500000021313412441116635020116 0ustar jodaljodal00000000000000PNG  IHDRw4sBITOtEXtSoftwaregnome-screenshot> IDATxyXTg/_SZ%TSa3T"Ci 4`&N Q'rDjGIuSJ8 :jF=]G _ʤ*C3W5Vq^K)(\qvYLDgV٤eU/v^w}`N2RLc wY;lwLk3nNumI彈+ _ 8QBOY)6^ %fZ(f,\"VqrW~|W~ik9ԣj+*jQO4s#y*y* GTM3Z.WTs^4Z]ԺU2F:rڸDXfuoG-l=ÏwOЕx3Ma:շ{'xnNO߾x4l3 `ƒ[u]αI6A2337kqxe?WrVuXH<2lgl2VAW>X: KC"Y0IS&]3ʠ'Qp'm ñ "aK ۪=lEmZISe#$ڜ M2cqӱXC 2T:sUu Eg["~D.+ rm9r}{G\l[[v|V&jwuq"FTc.b\${4oFGı*s;H̘Vu(ɭ{[2v]U<3] X lhc; oF[9lq=+3t]%=QB#Lx2JSǍ 7hc[i㰹pv4|Tdhׅx-Ƈ2K:?n2b4`6nj-fX#)Ta 8:uaj %>ohp*V%m7P9 WzrE™5_1D\ l)_myٞCo![%mVV 22 m~RnjY #HQ10s;ih7F="7lT/Ѕܻ!Aij\E33{ho2'c6s>[ `` إE0ꖆM/ 0=M;r0>fj%A"`f$rfA"`f$rfA"`nu;+q2_yOX}}[\t5TVty0dh،y h擭Q,Lj.%lSVP?>m(03ۖ-!:Ś:ˋ.&؊vXt>!^ޔ~tu455s;L 3Ϗ'%'%_ ZɅԘyFzD^&>Ϗ4D$eҸ/94]t` $rƬsK C3I)I>鑆g gb[\L:~&iP>AK:L\r~ie-~ɓT:.Gk8^ FIEː4w`kM,ބ-F0e+. #r0u\=kqƖ0O駣Y3g\710[H`a\$&' ~s2LV#`f$rfA"`f$rfA"`f$rfA"`f$rfA"`f$rfA"`f$rfA"`f$rfA"`f$rfA"`f$rfA"`f$rfA"`f$rfA"`f$rfA"`f$rfA"`fTW&VY}uıYUVzح#E:מ!>?cn룼tyu aasqO>-hzkYvF91ZeH"5mDm QN7և]1U?xH*Sd"O^ &٧m۽ fY1 i=v ~݉!"#@zxpc˖KDGeݎR'ͼ{YNw[U -uE;>k$瞠WgLDmwҺ?Ym;Y81|kOo[RAWݭuVeVSZ'qB"7-6:qZ2_ t#e::1R,}}-ZeUY v؉gd~!2p%Műe&RW$2ZEg|t,~\CDj">csu^w%+Z+^' [W|mwΏ+ګ=#5gFT!}TisnUlWi{ԌpWCDUf|Wy39`12=v(d,04 h;VrU>ŧp-񹤨ywkj7Fhv&_1c~+&pzgb=T*QuggQ"F哶jLZh6ߤ\:`,M \̻ſZ) sF#˂0K'?wJg둑s+z+Ku$GY[6q(#3bi|YW&^ }Bɏ.\ 5{{Cխ;+Kvx+f4e>d!JJz;@ͶqYܱs|9KmmHۙchEa7z uƺMY?lcjӂWBTng*3JhS-HM_&X'"jkKXiiP3rohݙpc83D`]~{ɺ ̘{_iS7Rn(>z |m?oj|e62 0VOuoOvt?yqK*K;(U}nT0cƝ̥FFbMWoT},Ĝz wp1}Ȅw&aj%L%,_X&*o.QLQXŽDDZRjvv .lWg*SER#lf ^2ތSGVÊ0QWޮb"l:d_209`)ҁRG6X""s$ljW6bSWtGʨ\.b+zS#2kbkPnIfSNg.aEVvw}HaVbLJrX)G/e&;vWv-m="kMO>Iyccy3qlr: xaJJw]V-CD<9+ Qظռ\ [[*]>+2[=|n ƭyԥqmwż)2+uomddW""uP< c׺U=j!&aś]Yί[U_8&v:7hCALXD IDATWHM4v*bZ: iq[DW]WpG_o5vҙxY/U?xH*'ϺtoNLׂzqg;rfS+ 93D {i>sx 3ڇg",m>vdKCV`>Ͳ^䚅Dk+ϻX-Wjt$זKڇw ]]P\mx}Ԭ6N8lY3WT7׼aIDDmu 9떷Fǖ}t寋,'"]c-[ܖKDZ9-%j˺7wƐce/T+=|DVYYș'e[.q}8j;DZ \7+5K%"dmc#QyAi=йJ]hFϒ7o!G׭ [o;niݺ91Ե :e!^|xn7S4JǮ/OuŴ(zKچ? 6eѕ9#p?L4O:MMKDGnyOfcuu/7x9ajKvkl.EgةKLV6˖znwBǝ3>"aY>+q%=ꘄ$r/1M oRgw&9~b1X /+L爽z[ݶ g|(9x=4"N|Zhe[v.WHvXq^'/l7\.:Q28 "<7yLdiyüAk;?#,yϯk`0"\4WgDєtnn-̌glmdHHSCk'FC'8ɂHךW}o 4H(Cc.ş1K0dm_g<$7;83C1{>ږAD m毎IDDm%I&/=_souj;{Yc;󛮳DDdqÂp[j)5[A^lr/w.e{' ŅD,m"c"?|[v =[қoZ*4D•[&rFH*1ӕb$"; n߾\xA-X`B {>^TMDD}m*j';Y>qT\HlSZ_]-w۠"[]{=HWQף.=5sƯ]oy [R MG;=DDZ^>4}]d`ُ]n׸hKJս_yd8Ň^_׆mα !Q~sb'G}a7J8wھΝGM46rN1+%F\";/mo'^o@A vGy%MM|ᬡɻϿ,_&0~ɓôߵ\ßM7onYZU2U":oO}6VKMMeE]D6ы^,!TݺmG>^H&L>"̊ LX 03H 93N`9@03H`zb 镐'#Q[mRm n'GIRu3%1qJt@^鉏|)`HqK^ʁď4;~)?x:W*97qؤHS{~k҃Iq<"8OSbMm)%;jعwTx n)jф={F}m"o#W=kxS9Ԕ*-;΁&O Y0vb[JS[s=E3#Q!$xv/Y^ߴBﻳ'<$dkmEǷ  J8R;h,rPmϸ6OtsNVnYc>7xj%['Q>c_#ţKxy'4D iazzM~ʶ7'^:soɧ,F'RR }ҕKVOh[v(R&=QeGxjEUQoʚ4؍jYC);s_>uWTi[_ 8؎-=-m+7r/ =@A3xM?O'3ͣ'D$ K! }rssss=;94e[m˩F_ [+N/KزTE)Ɵ;Dd71ʂĽZ$]ɽqT,H7(aWNňl6b>FX1^Jc#7rsss\9dG]ʽqX^΁3;Zʢ{/h"N^qQN"< :wll~a,[s2ݼ}wv3=;R Jc_lnǭ;xT]+tyʚ)UUϗʓƭs@E 0L9-- 1v>زd3VBAy}&/؀7v'CD΋b*LiDSOՄ/]:'dH(R-xW0BлO|݉9'V/Wyj(N$`efc_hUX?!oe\(s3sDZ^ԨgqtxBGwn׫4q\B* "]44$\CSxw=XȹeyyY>L=2)v<8H1H*N(d#O- etIߋ,nxnh}D@Vs"xKꂝ'| g Dnew3O>}?7H#830P:o>fS>^"*;Wd;S4H vDD篍Ų,j4"hXv <"畻?]KL3C([)M9p<~'D+ "wMم477KSNk|'`x1㥚/H߻7hYbE'yVKMWTW\,FIDqmѬJ"nPqJcPN*K 4)7C8R=ٲnܘ8̆@,"j.JI%ҴL7}˲,*5J"" aT}c5_(Əہy[]Y%"ꎂD~"f )啥ifYyQjj32Vj_%Yrm"KUyW+"œ}ECsϾulJRfD5:rL3fAT.ջV͝19o}tćАmQݑvD̋߱dgTcI\w6&_㚇5_&iYr8^?ۭ1xnM:I< |W;eCy0IuD΍G5D<Ǣ-Ic[Qe9G<9"I["ے[sڥ{Kv$q*6^#|?8 d}{TC1SS`4X %%` ۽N≍Kjxbi~fq0x;DҘ-?2C<dL0O<:-.\>!$mWYZRW_ܞ>J!;G-}D mW/^^!Rk9lśvEN tQ=\*ۺgW;>*8[Ͻ$eE嚨n{+VNo#QٿفWߚ7*=5|şr_XlgTJh[ZDr*//_`ˆ3`\-w50dsΝ*Ͱ-g""זxIDsK,-[pfa<.DsުW<;zz|`0eMމěӣ\&澂",5G4wFVT ^ F^چ t$v]X0#rtJ0"S|Z"v6x$F䞋[Ξܲੜ=<=pLE$XtiLGWe5{g[*+fKnዉD^fZ9-YJޚ [qL,& $]V:յ#`f$rf9y搐ͤ,&fB7goYs>;κA_w°GJS;̳@Qhc.܇90sx"CetO:LzN\{'537Tdڛ31$r`x'x^8Uh NT='axz޳v}0M!s' "l795*<|Ñ#B7/eXyޑk,߰|iH2/!dZMwCBBBB?.M:gCxHHHrF&ك5pXn};{CBF9b$+/8oWMד=EO3?ȳ697'7yE/>ؙ!b}eIe`aoNB1̰.$jۙ-+K;>]HD{LP]'$"q۪F'xX8e9 }wPTF,$XD2KB""aocoJO2e/]D(R|"TABolavDJb `GLDnGdpagV2,HcG޻3Gǁ&[d2Ah>KV 9 M^Bڼbǖyv Q`\;F[XL#rin*T&gkMYP4i%bձ䑫żEIRa|7}x"7+TDBqC/MY 2ݗo4d1FED$״(TʥHuo>P9/۰7𲸤֕Ͳ]|kAXiG[7]}#wtsXזyyU8.Z!Xm:/'8:tS%8yihQykP eu}`b)˯˘1k7_2y{N\>yU \ᇛ]W˕d)Y*aN3yS:ս8n݇%̰ {\.\":zm{^% 8pfӦMtm+.kHp3[F >\<#YvAt!ٻoNBVi D C!)v|) #OI6x ʁp (8Up)Q K`¦D)6LVS? ̄뺯uu==E냶mb4\\*L;FE.&8ۺl--I1d=>A8/X8=&&NݣJOHt-uZ{u C 5T*+kVx֎~ Rqdz_^ws燎l_O"ߋEX&v/Vq6u DxGu:#m`v'2U^HaI7곌i-&>T^Wrdhg *{CmREPKuMSı! g pKːH4L,ܵ($ID$I(D7nyDgzEtaO_Oz7~8a~SU髩-+I޹㬴uCMK{3cv-8q6ײO_5DDr^&^«ˉ9a2"  ܊4|/NJD$αqDV i[cKVI5 јt)gh/쫩>MĨ \z&IeW+/.q􊫕<ʪckTeE?IlRٵ*n(9Z-vJezwM]rksntLʹu37l&"[kV{{XJJʦ3rbo#s=/'0;$ \hYbD_x9¬2/}9b̽4ZZG$) W*X"ĈK8"GUϓ#uGR^ܱ jsRS ` >MQܶs'kkrbޖV&Ead.STs|ɥUj{'\)C"G:<*Hw-,.-dO\ܑQ_)lD0믓() +g}7+x˕ |܇Ǚlo 0a8c9iGm;7aZk%l=m .o>8^OLf]1+>uZEEźuK^/ 9'} ϰˬ:qmV_>4pVjz|7,k>:{ai%erZ) RXraYe'BɢOخ/in7?ɈHZvM%erF ۝K+vrrsuXNwI-X Z&W-ϿSg.q 1pC[6STQ!'qI[CM]I+,:n?2KʬZv3̀910KU1צcw∣ gUEWoݺVap49;9r}ꭶ[Ni6'lrTbX㸥C?gY|i8utbX]?xBqW[˅ KFF>;B~ņӖs32O_z -%Ʌ W j+*jIRdC[[$ZUVfou 6rcUHn,̽=>|x1殻uvSUÞ{׊ "ugkbʚN~ѢOKZs2j"8%NYc*vz8"4a$I$b"2-T1*F%M{tzUMn(]ul#.w0z_W+h G]-l߸ىߙ# `35HR^|h-&GOL{d*1n63}f)il:qƙ5o &é*tLtӸ{/w/ƥuv}Ty'L~DD-^k zmdsHYWyH6/ɸٟ5Ca^g spr>2'"yT-ktޙH0&#Gd/u}?y{X-EE?v-KC~tek55e1(l +,"%+m}/lOd[=DԬ3YgJm S1tz)iLvxчbD,Ippb|rZ)zii/~'"_zxB9Q{(^KDN ;6cCy' O~/LH9_=O7`L ..Lm=q";1#JӉ+ֱ^k?U^^ቯ~,O H#y6Z??=;e_0wJ=׷sy]>cvܶ򣅗{]"n4T,L%oj`5-ᖖ!70~i YzXC~hD=R]/?%JNЮAms.a=+߉u.k_^9^k֏ 6^ 3>~%"Ex rM)%+%_NdJ{ |(9 /F0_fqJل цnZ5?c)e>cSLz^ND+xY5װWiUC5 '(1cf-IeW+/.q􊫕ZǩҭRS嬇8}feU=ZU7I;u2kSn~[`oK7.W+W\ug $FeWo"uǬ ~ O3h g,/__k=w~=.^g› h|ujyٯApئce6^/OcH(>3&SԁGgQQP&{$JW_D3uV5%T́dYH"Rr?D$99ar|8w4t{]^'V'/=vl=XPej|y#H 8?_C&0bDfo md ^6rOD$s_ȅ+e l$IRg'F\v QA@D:cp٠mf3pd’n&ҥ|mϰz@\ާ@xR̽@[`˛7o;mӰY "oް,q 1Rqv'f)zFՈ*`B!:Nq*e8[,\-]fgddls-4wחdX,%Lm#šﳳ//Y\\.<7#V{:גQ\?*]O/z,>w}HX3/dX,l 9ıT KJMO7k'hn_z UwMuޚxү{sFݯYXM;tilnvXl #9LKWLvunj{sZsa '=rZˉHz܊w EY"Ҧ|]2Kmm(>-K2:Mf8vMU[Q*OD49Hdaыgalv/sz*%hb8Uz~z,ODshzٞHM:s*V螜tmݸuLw|kKai=62K?$,[R;j.,DO*m:Rv/a*_>e~aSDb|3>^A)Y:dnUuzk3![);.xeۉጥ+߾\VM!woV""*[iͫv\{o=w i(v9U/} TOD0D{sRgSD'cSlb6O͂6(,~O$r;yH[,|XQ}f-L^[lV;|B9HLiY!'[$J]_pwG!ү}-(v{ װlY<"1:`p>r saJ^N0%~owV%Oy _2/}9b=_Ƚ]@cO>YiWh JLH`>b)QiGR?n)@gqiV']7u¦|+gfQ&Sdg VΨ87-w8H{˛7o;|tiH7NoXVVf&&&nwcll,%%eSBجH IDAT;$JaʅeH %۪8,v`ģOd2",pPl1иYK+aquXNwI-X Z&n.Z東 Nkqw֏׵YBN7]1888}Ay甆/o޵LՆ{g˅-j|>``PppCp|cwx`,0.d-\yqC!{ 8C2-<팥)/X,KƹwW/n@8]g..~tחdX,%Lm{1//ȶX,sv]8Ӻ2u-aldq:r87;ÒQ|kR$_Y3N`cXUjz:aT#y{˅gmW8"e׉\8&Imjକ͗ y5 5|sީ$r&̓8xbڽ4dh$֟q_φ0$9[Zʵ˼La]@Z{0(`ZˉHzܲCkupy횪Th6͗$^Q|Zet̼ho?m(Dtʾ|4u}MōsK5V""Pqlc%D5eH-B!Ӡs0Dd1Dgwc]KcK I$NWe,mXMZ6'2SٶXulzrB0 I%;0j$h>ͩkm==VPkHdkLOS s$vۭ~~nnVݣJOHtM/bb8cڻekiM.~fcUWJU5A@ BtONN>3Lzj['gvbԳo;w$%N)_`˩8r&nk||raKEQ$$Iuq4*8"r769$XC~hD=R]/qcc*6Pʉ'k>|p4{zPz\ v(Gny`s}U9R=b K2.`yţ cNtp|xOD _1ۿ]s1ZSRZzvU{+_WS}Qr 8}αؠ5Dʽ56D 'TkTQf:_WhiTMU՜-8TvJVKŤR&766dMXk p=PZ~dFJO?uHD1>߅7}C6C͛7۝ #wgLh3^AWn?ʳ³_Kl?is <BpJky"K gzow_F˗7Ŗ]`bb▅O[~*ooy ӌlJ;O"}wX"1L+k3S[Leoۃg\k$KΛ xssaʝ?;6,(25z~m<"1:0<DD~avYD" ^͊~""s!LV4$) W*X"Ĉkq1! ّO7E$u "k/RҲzHw?usL+崬&۞3]Y f'mf'(vGwJT}Se m _Q^V0+E*v DR(VfA(t'n~"&`3r;i.![]g7U=w_0*o~j&G>|[m*V C4}/[s˽xg5ZkyYMD3粴DD$[=Rk,OR * xXODZSV-[{ef-K7e4ӨTTƪyZ/ik*N.1jY/$-ZeYvZ#"2gHVd"G`㹀5Thn4ml g=#y,nUqDiT !IDNiWr?jYvLD s 5g8AN~ѢOKZs25̟#"UfaXUJ]3B`87e^k[ZS kw*NPڜ.čgZ4D7Iy'8, b4.&&wbn_Z(]tfCD$ǦfP{Vq~#Lך{ qzMOݓY{[5z&k5CPȽ7aZ$k8_y1܍Gf>wY I<!L%oj`[">?Sl꒓;\럷UV~rK$"&Uj~T9mn5>>)pKːH4L,'k>bNyDD鷱EQ%I""IEQ\8kvbw>4KU.b6A _ߑFNd#^L]~&] H+V~@Dl|*ợu17.h?mQ[+jN~qQ"b4ʷfT&bTo\=ókJJM%U kNf44SݍDDjQCĘܫ.=9vyfs GFf)0\d%~g_l񒌋>~^zW?da_S~9mN6y{X-|>IgJ}~_;u(%z'~@G ˉhv֯~E -(I E4z,o0\ KIΉ"MYlĭ[uxFsRRR6%f䈈H{<*8w6GPAN!hlW[?ىŒߧfHf|~cSu "uʧ/}kg=:AE> .'"6<*Lt~/D1LN:_tYh)W&=M)ec#S)Ѽbhw1ĄD.B)$|+'ow|`';*zj*'r"qNc)X-)7B_Gn(}%U2G\ӧL46)W%)'HY?dmw Ǎtܾ;ṙu,Egi{Hgea?+)p 9<*ٚ,g׃yy5 vl=XPejC?da2K؄=CR <|#".#YR2т$<|0#7Oda #fBv$;|myyxQu'.Hᨏ񃷌acM3ƢtvdpVy`s}U9R=b K2.`yţ cNuD$L"iYų(Io??ٝ'Lr"Ozs0}3ѲqظY\SJa3rˏ^'Vޮ̧"=ff};c _#`X~D`,*/YvX7է3D4PN<`kXn.()_; Su̹fyFxW^^\ttt"9$~;8^[XrC3+O%- ;̪]K+_,)£ߌOwMRt "uJKߪu$!%p\`^<]?:AE>&E2s=====?^0{xL60#V!L+WS>seDH^X}r~בQ1L_W)J|vrVHny œ)dK=lNBn Ǎtܾ;ṙu,Eg•WdJ~fdųRY+'"Y/JD ""8+1BN e5$J*Ȃg;Vn1{0//h#ODf|.a!ʸ1)*9BND$W'_?|[vLf_c r!2 3r[Mda #fBv$;|myyxQu'.H(9q a <`vc۝I2)x() +g}{CdK6_޼y9b< ;  *8G`{lJ(,R0+E*WTR'B*n:N[,$[,-.dX[]m-/fEŖR&O7l>r[4f;]8a0cVNٌ8& .{_Kc]'FGTbX㸥L/,X_&bXr֍CD +4-C?b\Y 0#8VhX"UjzGS{oYk/7I֋.M~""q&OZ^NDD3Vò<55 *x"J:vtֱ~b 7K w%^ϴi4 YMkR=L;BNb 0=:DKܺ 9إDQEA$QEX}75 Edؔh+9yf`]+$I$-u pFB틯N~@D{׭ʪckTeE֍Tg%}lRuxDl1 w&˛7o;)]7hpp011q ;RRR6%V¼ىgK+wTaelffN}i|?/'m?^q?}:;RN`A :W"}W x]. Ǭ'/C%:cQy~y'O(yu9է3;V.:1A)'""2 cSx]RxGD)k$Yޤ`Fn)dʉ(ho~.#@@  ƈ]3q9(>5(v9̔[jɤLD?tl67Y=]Np~""0+zsl1ɆD$ \] @!ɣҎw?psgqSd6 O6_v֣քßg㵅%w=Dᛪ+|޻|`No2h.'G-<,Uu\/,CDdwݺٻ3g8:؇//ȶX,sgY3Pq5|zCšj|ѦVOzeeVVMʯ쬵8>=499,:)3'FrN$:eIZ6>XQ-g\vqmmynFFqmt%~T }!?wܲuw_?&Ln[yFƅ_k9ݱUq hɵ }|J5Ӫ`!7:n7]W+o~j&G>|[m*Kkg|ߚ+T5 3B q!shٚ{=. g{D$7ا)vö+Y__p[sO{{(5u 6>%:aOϽkEQqq gݫw/-_(wfŏ޵z IDATu u2i.we: ,~cD{kX 'IRy.9͕mڮ]#PP0D΂=6>\Qנ1SeXUɉeYvyPlj EY"Ҧe_*XCōJ;;7t3JM=hoJ<JM4jHܟ6ZcyU(TovL0>%Vq,uzH&ƽmW2 Plj/R={ wMͥ<%NI3~#˻4%dY$3U&<^)QϝeyfG.:$CQQO';eZ(1yCQ&go]2f$ON"CSQUj۞'v?r1Vgolu}#\f7-1$I>%OUŨ$ϴmXRjl63롐=PvrdM,6lf&Ч:Vf^SHu(Ur*I9N42&7 !K/{]ɓ5NmP38(J("k(T{]"&-kOWm(Zk%%N"!"wcHx׈rlBKn[˽.DchTI*vq 2>T:7PW5vO6uK|:q1keMDnOLIKl|NX:rF;1s]!B|( DD0>؛ܢoj//@ycQ:ݯipӗ9yE)'f&mcRck Kz8}f)O8^OLf|6nT[zf7@24=/OuVFffDG_ĩ']&L ""50Hs|Ȇ>q钧zf$?#_Sw& Hqۿ]s1ZSRB$E~lDf;mv:ǞNt{?>r5,7Y{ׯᅩ._cVivVWܗ!=H#ۄZ_X"bV|ME3/DwAM{YkdLT0SofNfBu 1jZM[93xvmr[˜*/žM{,GG&fK=֠&еNH@dEP$hqZZZ_ֵZ{%yѢ{۵rbO*h0u=i8Wd0ZzXk˻چ! SK/ZFDRE.}{J2Jd]p!P$?H2Q%xq4KDŚwADQv$Z*O\x$câX""J Dd2qR"be u~!Spfi,\>%na-]2r3|)`ېH"H%=1C+_" {;˕!zleD![8@)p}!..0r&F&q =vS+܏쮗\ǂB=Se|~nh4nNd^Vj&l4\6bPHDJP;ZehfԱozo|*>|1OD6%H,uDDgT*r'ApK2ej #F -""А7oc0dI@ʩ.^|T--QĮ09l6!F;>YN3L?rKBTKOa)e?y\׸t[LN53!Xm[iDM̈́޵K465?xbonX,3 S+ 9 D 2H n3sbw?d0 2H }M7wLEZIzp9nϟn]6ý^6g~'񢣷 cmw6a}.I:=-Z2`DH8-_҈\ LL1E1K†'8_Yzo"hnvِ>Z!u9X85;`9W&Fr}j'u^?gVDK>!Z`9\׫.gKL?n71tLI“37juj~SG>m6$nTKL&O$I6==s挙S6vakJ4KD$Gs#!܏[7u=u $W0,e*o3=|Mw6KGOyJѾ7֚i[e.{{-aRvZi )gk~~Ύ]+0,xL4ƺ4m-u=n5J?qR~JGΛ͵ CڏӣYt,$w5,,u12ӨTed}4HuF(O~0Z2Ukfv֬hvz%Rr=i3KWmݣ9_D9@Y7;q=2jIhQ%{OC!O\x$bâX" @Yz8NJDl4]w?=i7$D![z.8"["wU>x:-QD$ bJhtȱ\Hv}^C!""V`<'%!!'"b#u LW]ED;;Vlo.]uјaJ''D͝.?@XgHxeFmSD0kğ[LN53!Xm&S ׍k%hX]mj=!n1u 9" KԪjj^޸:*%styM$ }qbii$"IHx|4L>n nbމM0p8;ŋn[wCV,zoW73_뻬2}E.| gO,<ˣ9VS8xLΒkXg%aB |!d ʭ<~ M#I\/(гw4_@X_a*dꕉ]ΝE"!" ^Ճ∈ S|EI￲5oA"`k3i_or0 #8R]^RhWyVyBFӱg9_wPذ0AZ#}l=sX0Cr-[DD _|eȡgV~cݙ-}$lE8biƞc$o4s)H` eΙߔkBz^}VXߕEX)+{d<`Υe:S+a~mFȽC?ɣBzٯտˊ}E%,/tM+1㟵 ?j} .~lt4di-g7.yEq)wաƔE&嬳ll6:sukBGӉ00Ϗ޴$6˔/ļ1u#XQoκwuYbD, Ͼ]qÅYPy xXMg9"ܲ/b=/$T, {W_U5vsO/^6[o.xlyqѫέwCg`.5{B"sM|7`@"T\ϵd<Ϫ֯flve(߯K##ZJD+?$n\h3<%tm\m&S#'I+3>XtdGU r7UޚfC" ' jFܯf!ٸ+NF!H\OU[eWj,QDn)Cx@aT ""iF#ݷذ0*B2<$* QH얶^2N&%r,d8uxhζ^L Ât"luDD$]ih{[:5v ?9!!"[psj\m.$rAwD,Ѱ0NS}Uza1K?6{I$/Wxɢ DIں1vNVZlmmy"mi_-UX"W)qcmCDD"JH67w:\DD.4 6!haD`Xfe.b:w QjTDD$_=-c*rF[LuvD"aµxR'߾]MM;gܟ^x1m)yJ 0wZZZ )99yF *wEw#HBB|7`~X, A@A"d$rA@A"dȽ\W0C$rAF: ~M&uyV~Ul6fML.cFl:{ř%%"W/܆iq\0|a_hGU r7UޚdEiHd!0\m5hFVfuEEKe&z JUI45E6,*%"J  D:{f\vK7 )[8oyz~~GLGal[?x;jw3<Ќ'мfͻ4rܫ*猯[y""zZ9 :f810.~vr;:ǼﶼM[ 7iΌo> ᥯wgx7oGc>O<}-<"˗MW/5X=/p?+lmVeJb5a )#6f޽:ָ sX|~c#o(=9rrҊыw\:%"!R2Ƚ4Llj8N|fX?LfAdf}5wܹvr҉*(?Ĥ O|ťLv/^Xt+߮dg>H!xvF2B~nh4nNdeщ*[{+Y\1UiKxgYa^],qG..;{ٳ~A GN={ٓ+'.Ӛ&~m1qjL;ar3^0D4ά߮#9hg$;g:&ZI~婝 ?347>doJZRT*nf*ox8=?>qDn=hm8|KApK2ejldb]<>_tnxy$box|_|wGƫ_'D-Tn9b /G].VON}\M=UF&.1~>af=UyjGu7η+-,i?}N# 6"bHQ IDAT˗S5vzfIqK9~کq8J^6v=DD2U#|Nmv/p40ZkFqIU’5GnY'1#/}F7KK~0$T5v6_Xz3}k.ן*څUcOߩx$}b^؈J`iOZ.^,۴tjnS"&V- eʚ5),2e͚OP~l4.Xkz,gvw;j)b#Grqi;wfnǓq"~q,XW.5b}o~Mt{}_;0#OsJN,WtD>_}kSt/܂D8k׮={vfz= xW%bӌٵ3UUO&p$*zz&.-NBIrVEȅWrĪrru|}ddoZQdj:kG,nj&Ydgrt+?8MyyoF0o&]Nvœy+8eBϽWTũsud÷Wr,J>thK̘߮ڃoD-ۗw'hr K9Vysso@|<9VpDK{Lθ$FhzEĪ>_CH$ggC)KUJ~^E$+8iHCAAT)y_)0,˲o2H$άv{"c~ wܘF;_di:@J)g9Q8=~g"A51OCf]c|o"+2n츊0?]b>|爇kx,;[q--OU¹r"bF$pD@=}<.bc"&NI7{D2F)49D9'#FGPRe[#vVݴot73eW4%{nVV5v9!A @<^eLweD8ΞR3?2^9b)Fo#^Fΐ <+]DDw oyaDb":Ӗ?cݬPb9k6UV4H\`\1Zt>|;nj.w|E'O-9;Uť 7I?Gt q9i2]F =N/t17Gdܘ~{&z_.S;h0A"vsrS?`&!raWyՊn>v}æZdOEe֩Sy&Mnm\;~7M?n٩~wD/o&c>?^8 0O0raE9a;wڶ:`81sMy" 01?ʕJ7R7v*5[q-_>|h 4hoPٲf)GD|Oi#:'#tYMUUԕU,.pgz Tʅo|o8}t2o63?r\p:4NdeμDYC3183s|T{x)yxN^?OP\xYuY9a4Ӈ> ??qc>O0opsVA^Q`剷6UhfRA4WUڈnTI<AD$1 ^u㞃Z9z6nˆKD|6bEMꆍLڔc:z+cj~h٬<M5z?!٠@pojb ?COǍsC6SG۾mD:[HB}c~'"4) h!+|s:bO;qfkK炯vNsd8 `aj%vE~YP\mA"yܚeES*k B}62 ylYu'HsZ!Ц%\ 6+?;cL%[(/WmI}mN]IiHO֕1׎nW(tEU|^RG~Ծz(bS:U~Om:tĠ""8'Q= $I9Z>a#إ[,)1i6}u8䈭h?`"t9q{][vH(9G1ώyΊuG5D \j0DS} %;o1JgG8q>∕~ lR5KvTFĭެ }.?{zj3Pkɉ DLvC:N86cfry{ ksc2U7[N}Zϸ19/c8}mjD$5,Tz|--- 3VJIm^mѳTk]XVY[ ziʸ O ϻ`v8 >)g4xK86x;4o>cs9 6ny.qbINNP8#xh:Q}z?a3Y"<9Fh:Azͅ *^T+傹ӑbw;^\vLLW<74SMZZ{柣k']gysOޛCbFۿ3$r_. l(::QUQݻ`鞏J=Sz6)К/QtştHI3 ܊cg 6`o84#>AgmsyH(g/^ڒڊn^q `j%̝YZ 4fpj%Z d$r {̕Ρn&daaÓtl \p^?צR:^ !edDD..0r$/Bh0&Ouu:PQr↹ILF<&5h zi{}BD#җ7:1M{%qqt⦹9qDu4,9c 6koBsl2ᥪΈ Vcؚ,>@Jͦ͸ť}Ri}i*"r>*n'鉈kRX^-[b.Tپecj[|Tب9rxZpTg1ODC-mU2lixB9crؽθx.U+JP;ZehfԱr8EDDDC!""V`ƧAPQx1"Gӝd%"^iaIsLkw/(,nÐSuO\|ycfrqi;wfn#ڞ{%Sw/Ix:J)X_2[hsjnS"q4 l8yΝKM7zF70ZkFqg.%g(VANCDˏԥF 47ƶ\`QE - %z5K8JR'߾]MM;#"NmtxCxeFmSD0)QNzn+eTK6"b밻% 5יFfoa)GDIه(o9)Y||#-lZQ.=F7@iRY"vi5#}&ܴHM,MU~gS3.?;Ih.o]g^EdG=w6 ؔ,$"".ŋhYjzĉ{}ig'ðH~{)bBkjfӹLDpT\`pKW~ziǻi F MiT<ܰmei]iq*5GGY{yr.&NI7{D$PzHcAe[r7JwKM56o0,SрX:{%zE)/F^@Y$Eh"G'#q +IYQZ$ɽ><#Wʅ0n!ZT59)$ru#7aScT[2ԩes ObN}DL^FmɣK9]I[f`&t9Gm5UU$`==#}LL;S.2J\.^&W9.R80kYKۦP^ZZ=s f xWNἥX͖5VT6 rXMe{EE'TyIݢ3f*h۷ Vx[gk)W(yA "Ay'"bl,Axpxcͷٹylb;HlAôWݸ V "ZƦl\il񼵩rUmLVh ەhԄ~S+ 9 D {z|_w3`ʐ$rAϑ[\m&:v$\ zzc͵ Cڏӑ:L\$! pTɤD.WNT8:^1}~aF kB ]ih{[:5v ?9!!"[psj7\HD~ԭJp2  [ dQc"jm]Ben9IDrXH.";əj][DtZ ۇXE!74Ybjwg_b밻% 5sD>n nbމM0p(\aew9ָ*l֎ 2[z|S+ 9 D 2H 9 D 2H 9 D >~\_N7*fKyKδ^7^\(M&[}`A@!]ih{[:5v ?9!!"[psjdщ!&dpd]䵧\Ŭa O]Y,V/5{ۿ" Az77GNA=]~&?Hf}Xh.S}]=ՙ)eMpĂ`IJ<χHgy|ᜫ/41SZ^^vwAnݗGs4#+˿,3orBŷeU(^&0A)%N.Fcfqy揾_/T^7~ۿ=8ۿ]Y_yxGύŋ?bŋ|7rs$lhc:{l˄zMx~kH&uHʀ1o""bQ Dh""r9?$!!cdD621ثHP.8ED<^Fb 9\k%Zn_G=綿<=7"}޺sAl^ZY?k,ْ_?Zk,Ҭ-]I/[+[#z/Kel[hҺ%F*ZGƔB?IV~2hBօZ{ěw !L .TawK$&\k(*u}?ļa8iXVUS<4ޡ 4J[|\2%r`\gMG B&<]Ф])MUWgrYVobhB+3Kؤ\w3 Yr^yzU@`sP}UD)rIN{H\'O켋pD% *d,f5, R4ܵ2P7\밻% 5Ysn7w71Ħf8"GV@ϫYyZKVzy˷~qty[zsֆݱי[@ڼo\Ognwʵi1#۪2|}l_ G._,n68Prkwʦ%@ CBOy>Ysog卥'Dh*->ӷb}l3N#M 6Isb}}}}ߎ8%1{Ƶ(ь6D'&RU̮~"52>t&Nkл \. /// +#nNEHSjЮRqj-l%(3`b|?/ZkyOԚJs˪q, / wͨ&'GnWutn=eݷMEtlGdFnWLmkyrO}ަϔy{o#1RSorHH>F=um/=ך}wl]CC_= V6"g&6/\y9Ww꫻w_JaKQTg*Xӵj1%"{ddg/WBx|1ODDy! ͧ,_بI4%VyRk~/V_1 ˲z5մ6 xbuO=RjReyRךt=v}3&}/RPtf]'*47(yis|g!Tp,_PubVd>?9 (x '£,&2D+|={_|L~E OD1_?m\Zg̸/rɣ+νsۥߔ+)Wx\!E͑$ҙ㮝7S2C( Dtf81$IHĩDy13*Uxi`\ t65 FRh0,r.T>T_I֬箍zC{EЦws~mòٙqg4thEr1~9y{.:ɸϟc+Dco$nW0|qܼs\6ϤBn/hƟ/^+H^+V}cݜ\2SYODBp>,U0Q(81M<=E& ߿(P^Sx0tX8e=<8W[dTGg3Tt_N梁Pry68oXo7+lWgg=R ?`8'`Qw>1%l̊K=x%qQͺ;M/_ugA Vmx~8JYZZ H^?4^l?w,c=Gņ)rƼ0=cQ>geyiEX+9r4Q[BH^'Ԭ'lKӮ FoڹkRP4Qdv$r玬ڨaQd޹T :1⨭C D,(.E"RXR $f^Foiz$˦ cDD WAU:vwtS?Y Bde;gEnUU5cwx^>pΐ+oGrGbNi ndQH{r^I?RsQ/LDvO3L(Oz\DQ$"Q\^k. {@c*ۼQYZs2O[͕[!H`>L-Gn~ɏw454ewD=}wrFkn˾c˥&͝:uOݒa禷\m-bmldgCZEƯ^pj^7y1-;.Wlu ɓ'g=N\ҋ-ݾfF^Sݰy-DW>VW)ݏ?=O{SH`ύ<]]R[2۶t[1K'c-y3*08xݐEoo/r48²ƴ^+8oJ\t<1 } MJm\K+ɸQVa'Y@A"ed$rY@A"ed$rY.4pA@Z!{-~; Hg=_*VUǟ4~rV3*1y}tN_<}ʓ ==Yz쉣 ܛCw>ſg1=Og.jx_rK/7<;WJ8Z_ghٯ|${ػr͑s$/gOc+{?-G_S3'Qn?,1S徣Yy@JȽ9r-3{9-[1ePI 77?.0Ǵy"zCqGgGjN{Ǚwri8g<_Q2ÓN2^xq1YysosssNz-d'O9s&-Uai%@A"ep-ğzⰃ@A"ed$r$,ִxb QdoQ88vaqFƯ.gN8; 'am[vrrlRjdL39ho<$rbXa4ԓa2-{է&9@|gChruWoyBW"}m P3٨fuulhemeגdyaFu}OO[]yy}ϴjoAfa]b]5`(1ex?k\m0 7̎ IDATFv6tV_.ϙg 4~+Ju}{a}G ; CyqZ^: Kxbw xKQ!I8ӷ@"YaFnWLm"MۻEz& ifOd%X9RH #<[`stE'~L7Hcd_Wo$I-~wͨ&'GnWut$,]ʺoi)n[OtmIRŗr/G&Gz/є1/!1ryor3~obޑ{9xSM>鮊vǙ79@}hxآn֘D7(0##-K2ř7Ha,(PnߨPBgq"^N :\3pER$V-!H^8K"O$ I [/_R=Omټ$9ܫh0,rBMDil<|c8DQXFJ^I8ZWV(@GaS,*8ӵ /%l\čjnjq`oi/ZSp[:"WaK-~i@\WgZMę֡KR~EʁǛ|i.HG@"Zw2:~WBdii)h0h0ʨETVi[)4Rsj5 B˳ RXS6sƹWZăc@Q B>Fh4Q]_.~t Lu D Ԗ%]:~eU;hjh{\Qlq#MU!OJA@ JDAgHƓV&rXNQ]D"ťD (D$ %IԓRTzbwY|[pڪ;b4F~+³w}[ 9n EQt6G+XJ"ҵ:W B]Z4x~Q >yS`j>>z#^~Nn>2I=R%Q:`%[SM$Nomv[`7mq?7eZa[{-q+,k>6==WJ-]k5Jj.r[kj"1*ݥn L/^8z+>|.O%ʧwG0UϾ7:v]}i/_@FXr7ܴ߇@{ə3gRU^{y0'N5}1˺w^?GOŠ3Ǽ?-ky+Z=D;K?~׽ѣ|.m/Wl6HLFm9" vyV-NAU@:=C01|-¯~܏=_%7G[}sǒeOy}ݪ}]GyH/ﰃáԷu9jkKqMB=_sslӃW>bm!K+xrh~;A?WJVp/oyͻ|v{ɼi elo8xjefXCsO~_!"׹ߎ|ouU*GrVb?}w4O>e4?[!":T嬮9ˑ eޥSwog1qU}y3{3O1a[~x%?m_/aO3srrQܻD'Ͼ?wIR2ZV^>2շ_}.}p KWxj%@KS+2=; ].;zsO ]pE.qç}ſ655]7xɬvH k 7/MEm''*·G?s*`KeZU׶7'2.Kw.vY/Su82xnU^&­tV6 @ª<Q *3];'ۥ>?%UXzq)0ޭ%"1v62Dߥkp!-2xN8}J.2\ $w ^<-+AUc0 QV^9Sg:U;7˫l| ]6 N.}:z՝پ%WK"}m.iLG6v14ݗƝ"g;S^{hwkR/ߺ7vٞJiO:Cy} aiVz.9ObmW.T]K7|~~~~~~Zq*˿y ~S,=^N]H^$zY_W]n(_7yDo3[ɑ:29999mO K5˻~"SqYO C~07pbn0xSM>鮊vئNU+>g#Xj=_@Di{׽hY!3MkgeUm邰w(m*JuJ*~ =9:cffֿHmO ÈA/5V\÷u _ aqU=Sa\L8^H]{^w2NjtK2_'kw?$T TVsIW*׮w##-u󽒜Tx!""ᜉ6'6/J Ð(BF~pE\~Yv. ۧ4obz+pA_|ɗJ'Sp[U~;^z⁸Fw2<ݬ w8ImO-ʽc<كeaLn9ṁ̙q<^frŵ{3s-D2YZZ D7nۯ6n:yÉ^12*5Gї;I*Ge|8Sh _/\ bݬٷݠJ&+/'" JZ:{>kՆ'Av~ߎSx0,2ju Id= 6~NU^·}㥠Qp'X-o$rr2r R$ -..mke4ahLe[0\Qlq#MU!OlRt:FcDQ[SCCADQ$"Qa׳&T Q":Db4NDTzbwY|-dmUMISƎ2&)irk:$ ̇IQ`/XR $f^FoXsWLBb*-w:#u:/Iý/T29L_?W pdSs0E(4n'?LB~}!2l2\WXiD5ZE{HJwvmzX/`Pa'btdi)NUbT-֢kMЍ+jl"qj}mKնaunh{MnD$7Z.Qi+-k؞.lqkE9Ttqed$'閚.*Hcc,Xwد_G B;0qJgq)IO?KןړSm͇tך+US?\W/^v 𶘛;uTyܾmڡt>8';!O<9sLZ9v%O}Fe>Bm0qls!kd<7ʯa.}'wo a^T]im:<|q[Oa!!ē$C'ʯ|Ĝ4;[$/(굇&MDr<2C<b+BR[zV4u? }pE.<{:=oq9GO?9(sf7V"GLyslV)G?Nni] SLpGSO/|?'Q]KD+ᆵ8엣3?i.j>K$<rr)J¯,k>DK;.zm^.< t[ٰ2c(Ks(7qFXVVy3ǏGD曧Ui%ng+{? ,>cr~{~*ĞODbLd#9s"{_򧦉|rdVJ?*;+r!YB<}4}ŋkKOeo ψt$xԳof~v g/gu9:sFgD>wS+s><7[ZyDĜb>>n.іH&DEaGj7?vYa4ԓ̈́X?$cN+Dä?]ٿ3/^8m177wԩ=;khJѫW\ZKY5I]i'"|aH9}TV ]uvf4$//޸Я*SLJk#9KCmҷ K?Jĩ' ^mp-U N/UnkUt-DBo둌_r#]^nl(֝obe)Fw{_zl_?J F\@ٶ[~nZ[3 A{/'Fji)QTi2||%q$ǣtҮs):vǸڍxڮع[%bBϕkƻ܇L;Ͷtzsqn[GJd'Cd{`6ᗞ8y4tMCHXr]krvwP +=y̙3i K+!+0 Q(4_Qެ?Fėv ,E*\T TVsIW*y"LaiRU NU*5'FT5մ6 xbuug茶|1OD*\JwODͥ,ӗZȗ'ye⑪G"x(JRD|Aխ;ׯJfce+MN`+jb/>ߨ6ʲ< IwM5enIHTR֟^8$}|Woט+9;|n/:d5w<n6i3Ob 2,(qPجBgq"^N :\3pER$DV#yy"8"bSr,<(Q4BzZ 4qDĩC(&J"@Ga6U0JKş|d<2$ W^o4VO{eHboaSmT*VP@W*NM0Wh6Kd JI|H~Q}M㸓t̪Tʔ󹽌W+s$dxgy.ןlQqkPPvD*"a=evUOѫs n#̎?I/Ut&=v&I*a̸3not~:4R$w4Ȗ8{&&3KK\iiPQxLUZ/3kKMOQ9kʐaJkES.Ɖ( Ivj,L$DBnQÉ912*5GQ?(3D$;jVEpj(VDtN]vB$n+""A VmxUP6$\MVB 089F!Y=Uu|]ެOSnT+CZ&tF6:dv~n$r>k>B[pHSURP4Qdv$UIVcTC YgћlIShTQlq1NW<\5Hä(V}Yr R$ -..vx%Ho*2~s>"YIDATʌ cDD \%'1) D]6sVWݴD)zqK?e$mWAEDq}ek^k. {@c*ebOP׷/A!.ԿHMZdF<Oki7-Z6ͩVBPEgzJը$"]{q0(5[EכIkK5"W*²^)jk5Rw_C7$Oޟqç *HYn5l;|~1$7 ~G^=Nڡ6K0^oyܾV n`#ݾ}}##+r,g $y\6[o Ŗ<y88"!8/l@"p⶞bL} MJmӾ~W\tF'i^WXZ ҸO2H 9,D 2H)2~P׷(PCzCkiJћW k$U,%*h>Z; i%wgCVq[GXymzoIk"ڦWЄ1Ø{ Q[vrrlR3H~SVYZ كRaUo0tbյͺcp2HaEh,aҘϟ.W*J%ߊXaMYzpXh~ߒ~8Dʒ҂Йw\$rVyZ W`0 7fV{ 7;gC}-kK /'2Ff. B!4v`0oܙM0.oqﺞliPUG#knZc'<=v; 1kkSq%v`H\\IdJ׽IdRݻjb5W\RU%-aiVbh_̸'j7~F}]uyy[.$`h9eb7 WGCD6-K+/=oܙҖ=^'xgLq`H#bDĪq+-⌒b{Kctw\KFzJywhczѕ救Bu{{T˅VJUf]Kc-Ŵx@D>uv">æ)UVm,]#:L*ۺ|^? SQ:2%} 7R^uWgmTׯ_*] CؐFvZ{JҙtLר7RU$"\>QkQII!t65ljW2~)6_w{dR\_,x\<\ڪAa-RPK21v,QDiuj·*K~X<ج#oߒ,|KDz2H58{f[Τ[OXgF!rB<%FX"2YܚL;.`"!"mf tf81$I qFŭ/tAv-#U{-3m9*q ̪ "q1QcTVI'0qqߕG[.k[~nz;"W1^PAS~K>2ꍁQL8.Yxhzڨ7T[}W+d"SvP*Jc܅F9v\<$rNF,aZnWzz⁸NL˕q3(ʁ]/$/á[;({/{qcm–'x2E{&T }l&Jt =˖fg=q{Ӡӡ}.f톥Ң~TQf*+Hί/Kj.˅<.gi}YbݬjM &'҄Xx}=$.~t >B*~"ڜF;ޚ*q>Ru|~=oL V1`p}/c@Qthp}`h0ʨRdNj-2U3w@Uf*ͱdGA VmxH߲r\<$ra9GAw)2!Nh %3H$FDjLe g)&yl%kjvʪUzw[XeN|DBN_\nTQlq!#RsQ/LDvOAQHAk~IN?aʿ- qQD@՘+@Bhe&|.|(8P*UNwX][Z cDD+8F2: MeOMa 7TTTTTxbK#)>Pa0*8:n`#>L'DƯ^pj|؏'O9s&-U˯l=OS2$3ZdA某ANߨ8 D𱧛eAn+2n,[*F}4.\,K+mo/$rY@A"ed$r)rC;B"YCXym:vaYum{{N¯!NaUo0;8v v{xf?+KjZ@b!kAhoyev YW  -1OJPw Ƭ@񴕗wnn!,(4zpo΍꫷4k>B[pHSU[hTQlq1*Ӭ\NB~/7iS?O݃"+C |ŊmY>t% 11FD$ Ia`[|RA($P%v-5 L͕3v[\'RbIYhoKQfMQ +-Vآ^hVQ.uwʭc[{-q+,kﵔBBP(}YBP(% BPJd B#* BG(U( P"P( EDVP(>BBP(}YBP(% BPJd B#* BG(U( P"P( EDvC"ޑ>/&8hXD g;sofnB|oO:1+' vˑģ"(?Zk_y)x!kV h?2 FC)cSR j=~Zw+\t ^-i՗|Xnf!̘GA~KڮX<cٜ,jNgyr\3ʖU($Z-z=Z,ݱ\@q\5W˟/6#%F7pqÎ݆]9|t⿙k)-rGdzȘ`Q^n i&_gt#];uBkJ]ol'sϕ[D#y?oapBjxLߣz:M2qi͛oO;*K:86]=ǝ(ݮ>KF;?q/9L31Hzn"w_~Oހ@#e7#JmUőI9ehXY'=NcԮ/,bC}F ѻNcQCKzŜR ݑB31ATJrµ3cq(BR1k" M`U~OHFB3Y:CV3ȓ'Zq&Ҍ9HNm(f7_òu4G,mOY4ϟɬy+ 1Y/M菦;$JdgQǬ>" Ex8pR_ki7j0~W<{^.R[/<XitopY3-0%`F&#w?n~m8^Z,R2q2NT7 E$?A}=Hwqw! M  *^]XG%D̴nx ff3fӹg%-Po.tR m˔iIܙ<Q3oҔS&Ѹ+BO##+ϻNJ g`ޚ(V4&s*t5|P˒CZf6?KrԸ8Lk9*]RGKK%A xE{'"|C1Rsh%+,9>å*:aӱ? nihnxCNkh 1%MC"XǏ>O)q{l^ǿ?>XAcs=0 RiicOqڅ79n[D&?X1uJ[3 J^zba9lGyz3}sV5ho?>9uo5p浾~z:t3n>t2Gٞ0˾W}o7 ~w3no{ y~C'2qL;q`]G⥵Oq'JU7*,tOу9 ϱjnXEx?d/1V`^bKⲛ?N-B!Gsve1kiN7s8 :VyϞ?*o~$h+_A|#?c'9YfW>.ogh=&y̵aLgK93W/DȪy|QRvHe63Dt0FNb߱lA=O|̀1^ Pj.A= <=L<uܑS8r]Ȣgu! CmG_BFsw Iž.smXoOyжAfBvNj-!MK7eHn,{9\DG-:#@ku1?+|hHN;|*W_u $B舵?b_/o|iB9twn_= ǭ"iT}+_t "I㤿ʪyaA5^t| ^)i\Η,4ddܛI~Ӹ{e qq,lng qq442N/}ND.(M3iϿt ;e`Rs6p|]\ioD_oZ٣'2v`)l,$WzH?<߳#QEfKKhZ>c.R͞1) 3夌khK%s˹r[1pa#@s|k춘'w@9閳 ǰig-R {ʔMCi#(HHC{/[I79.\7n坯b߿u?2|<`.`#^0ν4-QЈG36l]?)`)[>R͏& K6\hOih$f8NW "xsD m` *,inһ.dujME 4YOYK4k;|Qk}8u/N>Z܉pEL5%,`z+e*l [еcz"B$f4@eNd $ϗ-DS -p%DVAc<瘖{fUD%hb5D^x3_ɬy(.^E Y N{<Ρna} x= Z O=-Và!hƶB]js$ooKs IK"ԌF|~9=$1pX ˱xH kB>d\nl~npռ}ɚyx'eS#H#h0MV/$`ٕ V on_iFpQEW/c^C37jwgB(~w22ҢbWoe)o?xd#OZqVqʯ6 -!f'&BJL3=i&tscK9ItE)4n[t?ʿ>->O-54Bb9JJ03 hzw!{/`cgdVú OE4%w?Ɓ0D<IJL޹xi =C>h߽ڙiu bOB w!}:F|h^:/^/Xb7OԆD]V79v냦zR|$5*#Y2c0˸q΅]$=AA_iJMDv;Fa1Y-}@rV`r^a"nyҔ'ۙN!Et2Htmagd9B@s5Vl ū;+OXk{dɔyy-W"K^H>\S/IG8>6_tIFDguEXI_CDv;FH9t̓:x[V3;Jf(Wb' L~(>pG3,StG\+>{;N'4@59[4 }(']8QS w Zul X s/25-/0/{O&u܍%!UR1X艆V[ V e2,Ͻ)/fpX?,C]uib)jzv˫\~qp't,n3nGBJx3z|s6̫@Q9SF#;/ED[?Rd r7k5#ыY\:yXǰ3iG:w%\ەRMSTVX[eɲͣS9/ىlY;(\{Dt(!-9r3-$ Q9g.^C+<_>1wpewۛQYAt;zpK|Rn];7nQ)"/tofFx*_yO_NU[<w9Kt\V77Ŋϙ4͉]_jF/K?Sbm^d֒uĽ^<8Ü.;Gn|qp'@/VH]?x/pTUh?|23&@A<Y[2Җd3%a}za;JdklAI1)+!C_s؅ݪ2jꩫas^?o6RJQ/zH`Q2rW%_?I:xx;6n߿Iice}w)j?&.mc<"~lR@$#LԂ#g ' y )mi#9 ;t!L^5|R,em(@E-V,΀cO?CI9NF몙ybacy+!6 `E O ԛd%r:{w+#+J dէ<5/Fp ^}ZIragԀKxwO扥&> Ih>? TWsKzEK!WqH3Yrԯ᝛#5yWkL-櫳h4숵MbAv_iZS1aQ]"c?n߸Yc7GPjj[YqL!Zfь.4D6%Bv+o~6~1\^߈G׉U'7h@bR/q7O ٖVB+%f4:V<ūHݸ4p^@MV,$Hl׷]>A <9ױ e_/H3B1G3V. NMY$iE]N,=|^khxdy_\)2H) 7;р܌ϲ@ɮ^iٕxu?/bL쵵~w[ reŶro$]G̿kߙtgy4 ̡r+?py;,)%s%I?=0'N,"ahZ"W7HI0.3h6M=?^DZCwvq`oN{(w!gM-B9xVǝp ~!-;IhxtA8n9s~ =- o7gZi[$<փL9rf9ǹ]Xs/, ˒NAσnxI7ÕO=~/o~64ϋ6m?ƣO o*?= f:m}rr١cJ5lwp{}dr_/P&)ioRZmw ?_8>7QO$QQ;O>tЖ'Ѱロ˲iWkA-rj W/"n.;syh oՊ2$LS\q!Z ¾M&'edڜՊnRJJ 917;SFuf'=+W?,6l/!0mIt}Ǘ?,`]e՛g۷j(֋ ${ ɊÌnQ) _ z] z꛻doPvt}Ϙ`)euiٌ=C~s#01徺22sV3%oN tWޤiIߝ+ۧM|o_}Y뫰|ѯѺ"uaL>fU-qB&u{/kKiQtN}fQl}̘1CG7 jΆ76NIyIoYzזEfHetٮN6(ny7mou;}]mھ3fPs;ݷ|E7>oN߃6w^\Hm]ź͍|)%"0|}|M3VH\aŝ_7}.x@EG5JvGEB鉇xl #Tκ(=8g8KzW(B*i=T= jq K|b[GB5 4Ҳ˳Аr2зNndWa ]#g+=mPllތ[x%45cg1hh33wPlITBʌ×ŰYm>]J_ *BBBP(}YBP(% )?>UiGm{mf=zEn @u`o4׬㣗w?d D32>y7;,N

t faTͳO>ں`'v&MFVT! 7^Ȁ t:oo؏gD0~Lܛ8lgҜ ۷-zsoV3~߫xꡋHҕRe*wQea8c'Ms.+/e,xp2PBnlX /0'ynÐB0lv?H/\~4,g^gp;DjVα< 8lH;a'8HK.yc)_|7Pb{Ba\7imm-WƲ,222u}`,{ܽXo;xJ nJE(NATxy$dvՕ#C7szrsӕInsN='p9' *C|>{II!7;SfR+nl~)*e3v\u91=^gu؍\`eG}H/YE7Q6+>N~(**²,t]y/1$c.\z!7͞~sǟX/CH" Wpq?0O1,`><3=|g> #|kfSϿâuuC vҹ gݶ*#- @u\!˩NᭇQ4?\;+ xI;/onzA{K6Ff SCDvI8 '']4KK}ԁ`cvtNԭ_eH |;]e C \Z|_Fc/vrha=t.4?:Zҹ3y?o_ǽǿ(F <^U+OpAΧkFOin,n<8m٢#%K&lP]FL'¸: 7$!CaT|[xYVtmW*++FDQhjjH$Bmm-999|>F~233e566D, 0HOOrm !@筯'zJbss3---!u.@Ur4Hæ؟Q;)osD<+ddYo0:{ L?-B񂙼b5s힗 52|w+Uɣ\˸ˍ~!/*,^єu >3z 2<"m[x8pQpޕQG>gEz`X?TAO:ړaXGB%qZoW9C}3j+/~y^3N{knw<j~S@ 5,K.exO2~f#k~o~IYf=LwԔFYއN')doY_Fwaqq;ckɯQ0b/#e-ego|xĿ}Zxbi8}(e+~ M7c`|,3O%uġ2~߽o?4<̏n!,+WicYV"33YjSNeTWW#ddffYb---q躎MMZ~<֭##0 rss6lk֬x<PhP&!,&>ىI)_ S&׵0˖5su/s۩$nϟ7Y 4Į9|_#T/؇{G#iGȩGɿS_gNk9\Jf߽'+; 9CưG_OGrGKCc藹iQLXs1uViȽgϓhO&UM?)Þ+ppL{i^_r M.P˜v薕x,Lc]5u wJ;.d-mz]/w 'r7/|= 8K9v攬d/(ΈlR eIM4JWaȊ[1nbGSNN'x8_5g*&\O^/qÿd ?.[iAX| -0ۿ8m!NbBUNmuAh{# 8ڋ & GEEC^^i2|bҤIn 8x -~ {j- N?D'ZoE Ƚ )a7 q,oר9+ZǪeXb+?W`Bf&Sڃ܀-%4*A^Z`(*$|BE[\j9}e6~[T7s%S5}s7A42zJ Dx29qvQ}ku'549wY'k>yZC(ϒ9 0[磕Q>c¿:#F51F4C&!$`ߙ# u_|F ?c`ݗ8TSyU'(6{/sB:]NZoD|2Gf>3v? !O+/œ kًfEK=D(/@b0p't !ƨCr}~ %(biii̗miiAJɄ |ϛ$650bYܲ233b&3h <OP(e;9e X?&,@f2%cR.MJVb ^[qf5og:VO8tw)3)%=]?]?+Rf>o\ѕ~Lygx7\~~'}N,MI<Ah:b1MY+ȴn5[ m24,`q{rtRL"9Ay7\KmY#_5Xa~TE` 3.ۙǮ/ͺeL?eOS`yfIN$r:Vam${X}>oxĵqin0= @bHV.%2]L0L+iK H+ʱXB}$Mӈe%AR|}>_Bݲ2 W3"jq.uԱL?cU;;2w<ܦ#1{t𥑕EvҌLSsSvW<~r >vWκI޾@bq#zC7#7 I=x<^ideeeH%iix$@PxMn2 o3yƝG5?+lF4<^OKwXJ oЏ˼f@/؉#&OYJh?M q 8I̶dyk{h,.#l _/enø999>ohh@<5訩 )e߹iblj+++9%w]/xF ]`;oPdu+NP>{S"Li}=J|y[[N]"øŻ5Oaؙ5'?C^`|rݬ8)8u!^Gp)!!Z6ywᬕ-ԭE޼,*WP ]czyH Huoz<,=RZw+sXHuu9k۟TblÐ>XN"фk8&`Z`bʎ+16F @HaS5-yʗr4Mcn'Pd8] DP6{9!ms=rݏ2G3t"-`,8IunIQ" gCC [iSS^0Y݀"\RRe7XX,FFFF:D"vh4Xw9L'ïA v?XZj]m6/~S!«q:w04K|Svg V o|kj?]gƴNJ(1aq?T|Թ+D>xd_zenF~ 9W*w|B#ܴp~|q筮{uV<6ΙR&>B}t9&p4ټV8<^L3]w [-N)Q7 /O9x/ 1z e=PGI˩{㉃9]mk~RR3 2>zm%BI87;pB@YgXj2dqnt$h4Vmqo+++bw%҂eY WkZ.nДa][8yi/{N>Vu)YO//Ri"~g3Б؍kOI$dQGT[24_?Ao@6g⢟/ȒrupƯ9>H.ع09?IGئc5>)yڼ vW1O3~zj>j檿Jen@|*^NcK3sKu1_]a{p\wk;۫wmg'D-,?>&ˏ%JbuԜ6;wF 8׋Φ24MX,FyyyBx(ӝwMOOoUNWnd7 #7rhC\uCl{=+";o 8M 5Tm&li 㜽lRڮquKO*YL4Yq ?,)# ?:Fkx Vs_ Эż<2Ӄxt i 5SU]K4cWJG?Az2_Wn99i?\pfeв/?&LeED0,[S ptXҭ9m9f5g}ǂu55!^>2IiPkqv4w*Ij~c G(M^Xr&ϫۮW3u82=ͬ sV`&?۩U:NwvOWhKhLY$%SCp>c&”E4yY,?sLJ=qPtoo9| ,W X7H&=݊-mWL{.] C aڵ[k&"^/cǎMUBԄ0VbňF(TBPZ`' y%uW·!+S].QϺbc_`Bq)?RR]gH8]ӟr"_>)A=x='p729ؽB$/+bXSA]t|h1Pn6,JM\vdyp,PP`yKf}߾ǒ% ]! 78]H#G孕/CKJ;tֿN}+VǪu7ǩ  Frݽ>^VI9pfx:S\@'sScelg=QrѾN=NX{CT3{DhGZV6a~{+mǤR97x x>VSϑRKFFD"8-3+++ '::ٽݞm7!$0!T-gڲZbR"t?F^l5Dc39 : IՊ~TL'`'ϯn}c~_Hqe3#Sipv k/aJ** w.Nbߝ9mKwylsXPN\BMy>\^f.XKSBj^bg|/_"l梇?cO_kYU0> -^OsD35ܗ^ڽ] =T'kЎK?;uvVyRpU2wo27P~h~|iGR8b"O>tЖuw=w;tںnl1?owSR>j6(赗Ζl+=wn\$~x];97]56v?:8[ ߏǡ㳻ǍaI:Q/zŢb3c ent1;{~wf1tvLE:bUzIw-^ԽktcG%q+4h:#Lb9UH[[׳w[\R]}*Q"P(#jyﹷ)[4.>X|?I-YB `{/uhB`2[f% 7-LI#mH@dqSfo{~w@>);$$ N b nlstVQi B#* BG(U( P"P( EDVP(>BBP(}YBP(% BPJd B#* BG(U( P"Ptd'oMlᄒB% E'b?k7v8'0lKWAt)`'ac8q"T}{;SgĘ3X*:IP(~2*"0<>|>~yqA#t%V9/A$#ߏE~iB3}BDVVײ0a|[D'~beB]DVMٳYhOd#0ܬT M)U(vh*)bx̟8?[fmOq-Oo ޴ cS9d㻜Q^Pm}Sz'Mf222~«J|C0p>_4u/?Oq#$| ^UܡLs,/Aq)XkL Q;sr[tcm!ŵ`^'ۋm?}e&z;'eg6f=BH)[{ oX!{-毭&ndŁIH[z^%뉙O 1 QaGD'^7{~cS8~OQ"&p&wzΕN8zgp<j>K9_,͘KcKQG2mpv빿Q^L.{=Ng~{!B|8I{uMH->0S+3~>Uvi22^t7S=o_@+\s\?^[e@b-7{^s]@?ɚ_b}t6Cn>ه)7=D_k-.h.3^L"q)bFb@ i477B  !5MZt]';;JiPkkkA6>k"ȽV40}t|X3HK84rs ƅy{ ϾE +7 [ʢ;}]GۂG˞G`Gp~)XK|#~?iADڷX|s~A S?)Snl>j1*}(b퍕#8}yXrQJc_~z%=v!ßƄρRZ̷g+xs42};W ''5r| /θl 8BbGb@A,#ի#i>C8gڵx<zjhh`ٲeYϞ>Í?9h3KAv=f-&? {E/ξ^.H|bso:\BRǞK <6i:ى_M|ūXG",YTϴ˞ $+q\Co>_sez e!5Ow_?o=G_rqorޥg՚wxiۣ|=}"1s)8}3d6FNpxpE`gp9rI'n|ꙷZq<@ bG>)BPWWGCCÆ cDXv-i:)%x]Yx%q`cb"*B1gŬV\]]s;X#gd,8ca&eIcЅ@֬r|WnʟoN!:uG/Z Mx@b7_ϤDJIixڃo?_n9f"nM̚ biyy.Qn?O# -d !|?ðÇǩ9y ihhH;/aZZZ4hPX oъ|Rљ؝j} :2O{OZj6PVU%-HRF1V9aaTL'3|x:'}D깶 K*9jW|<ԣ^2<,#M~ZN˚լo`DnGL£Ak/}vbQ/0gs[/1Ve?qG0~pFE~#>}DnH2Z)~*z\qlhh@4 a )!iiiŀ˹ O^^^/r|dO>so_Z=uzpVM|[|=!+WP&HCm 5 -H]`;4zq BB"Z6K[+XQ?1 X|Ve5XHׯowB"2аZ*i /dWgs^߿ '~}q]h9sԨ[oHK "#bOƋa:;B{3<[ E_,Y6AKK ^UK$bz |Mfp]p+ǑW#4Y|,^K~dF?{n,Hn";nu|1s&_J̝"p&WoSP9JdP)%~HS]:IpWUUرc4wkgė= ;Odf>.NyAۈ.-,^{ t'q0a Ĝ \VCJ,"p\M|T1!JY{LS|!FRLiCi9V~?:v=a2Jmu _=v*/_Kt^xb-TD)!RbmɏM瓡YVr+ \uSֲ,keS]@H$B<,6!9#vOcP%yyM-!Uk`(t[`bukfbל?Ps*H<- {,3 |'z5%{>t=r- mǯwMIߏO9hM6R){rog} F]{9@ cDF2(UlXE4b IW5k;IJ<҈b^!EEEh F+>/Ux4a)'vw{ fT:!s%!Iʄ-˹:>_Wq;@o5n&Ǎ#2M|*bn(1ndܛ1E}E* x4v`ѓJڹ{upo M"Ci.VlI$!##p8ڵkׯx 6P[[KZZ3PQQACC`^"D9ܵ +O9g/:|e1efysѥK8t)EYxJ(HDCIzVl4D `Üz4T.瓗m'_LQ3MG4^:$2o~Xb}k)+Pt I14ʽm>-U|,u#~u=:nI"e'%3skXK̝i8׊;-Pl(UlhFvv6٤1n8,trss֛F1y0E=0enŖٌC./P'*QURo@>yufB#o#oʟhm ?7 v# &)Ω/?;+> K$hL<Uۉ"W;4yS歮 lI cCeɹ]ZzW?6G70Mgir ֌OAPt׶^p!Pwۣ]-:n#ﰎ٪:mnL]oZztZF'R(Ff̘ [LQQQblO":Բ;NⳍVuHٝ.Eݍ}zn{r-bkE .DAKK `"ZT([%h4i0hР bsDV]!dԩ>SP(jNV]\BP(U( P"P( EDVP(>BBP(}YBP(% BPJd B#* BG(U( P"P( EDVP(>BBP(}YBP(% BPJd B#* BG(U( P"P( EDVP(>BBP(}YBP(% BPJd B#* BG(U( P"P( EDVP(>BbEJC[PP(67RJ hh4w/ D"фF r$Bb!kH$Voll`2M 1c+o[Ջb;Fb4M#FO=[eb2,jw `QrOܭb,`;%֭][{ͅYv;Kia-%o;HKx[lq)u˒ oτVJi7:!b6>B(bEb@Joτ'9Kl}DVHH2FxE9WS!_|;OEʼUl(U@R&-J'"!f C rϖlʼLlJ, ,?IoA]~ܪ2@ ?H ū ֌YIEvhHEȔ凜==,1s6)ixвs<V4 બ @ pHam)Gciga?h; %TPJ쑕ju}]=4eʓN={nɺW_$$Vjn@FMKM-nR$i[-Z{w{I+^LH2l)W(v*sDb kW7}&Vu PMGH,\u~;ǍznTH|~) 5k+J X:$\b;EDgA?sdg}e`llT&a+8/7vlj=?"zҞTKQ\w^6-a}vA_nnA].]%DX2Ns(Ap h): !ؖ*yNT&­ɶ{~p$[Z -[hµek%ڔ$VAO͵VsI1NnJZ).qDZXv=e{Nmwv@쇝*=們Zv#BfLD6:J|׊2an f"+źNILq Tu9ՋL~Hq+)!|x$Xmb_|%;Jds܁-lRI!~1ǐѣA%T ٲ,b>/]o zNjM gJR!w) '䜧;+@Xx:sR2*}HGUkkd wεuwXVn&h?yٔ-xYjYm lcc#˖-% LƌuJdwjj;f,' **+vYYX bԨںHˢ!`Q HtLZwߢä~ܩbٺ71p=HĩF" ;e{v:j(7rA7ʫuq=j ʲb H$̊+R2h`rsr?C=|׭~PXPm}O-ۊ 31bAq2jj),(սVl[(A%=In/Ah@Sh$ڽ2RS6TlG `u 8^<%H&? Mr>QEJU\t]V jj tUg WtZS8,▽T~lذ Ecc#WĀ{?bY{z+v_Y7x)^WL~STXD}}55.Yn*ׯ?CxYj&Ͻ%z^ВΏtieOWn8@X$ (\AKr+PE IiL bJ)г 8X,JeU%K,?Fn m9<ٰjk2xH+OOE;q6e%f 7JdwROٳw F$* 4p iiiqh]M,*1<#$''KZD"Mjq2H&d^\ d|>$6DDvV$)–XԨ]5%YW[ $R6zDx}^H0Eү_?ru}ݖ#aqHOϠ˖O~|=_9nŎgtMH)mFޤFrm( ˈ# }OCaFkx5bQGVVф4{KJk"'4XƳƬ[2Yb}N}k[8ej3mEnmZZρ'+MQFQW_Oiz6l(gAz)ˏڗ't]gAQUUɂ (*(bAKJK&Ӝ%U*iA;Jg-F AMM5k"HH H4%~R9meY]׉ì.^CC]EEEdff1{)HJe;e=6Qnvsn/.51ݛgt˗4~Nu[v )%"aPIVj*JfnDrKN&-'IwEJ;Bhe̷>Oi"4L҂Yb9U 2ߏihHԇmz<#.Hyy5vy99m[ wq+k_CDvޡJIX`Y_zsEb ;esNeo(gÆr^C #bZ&H8o7(X6%UUpqIHZj!ںYdm߄J)X'ep\m1aJi>Gu#.neʔY8Lz+72%y~#Mib~6l%(,,M;IJutI4Ah~jYfU 8`0צnۀ8(anI’u| G7t 00-p8dB@W"`YHgm[Yb9RJG0H,qk;BTkS4)*i͊Eaklwo UXIQS\VΛw\3N;]0| X,F^! )PNeeFNxb 3Ii&BhyvrY)K.fAęu=6=OPl(aV]ƨ RXPHNnHh4Ȅ nVcXEUU%555dgeѯ?Dc1RStY.HJ+!`nKkw]V{J{)➖#"f{uugSh@7ի-7 xkR t7%bM+Nv9uw~Xcذa1|,d@s,e+˸xi 6ښZ P_ S+ Gc((avxi1l0^/Pjt7k1h@D"X(T7:cIDNXg]+S޳[ÛFL${o}R7huNp"^ ZV' d)wOM {$"ѓTE,EtDZVt^B֙Vp"++`0Una9Q]DvOxm4<ӉEVbeYo('c璍X5`%a1n.۸s7rloO^gX7 q2U,WJɩUT[!ۗ0'{ s&V}FEEX4PR6WNtMHuU5h,D*iGCp/ZVBu Ȥ Cx 'WZNԨ}~W$ 0+Daa!>X,e.ލw{$gl[þ2'tlrӦ"Ngh EqVWlZȈm\NĜݴm|$]Xw&_a8x<Θ1c)))I%[ !߯?"]nAdπ [2C4YCG᧦GJz 0~'JJ1G LtD2Mo4YHYӻM91ZgYhU%B=/MǦY$Vd%-6Cdj{ z$wqJYmBC (++''qņ }۾b/O"Yo@ӴDh$~bۭvP"Zd^ӴsrrHOOҲRjjWTmojnTǝ >R\\L~~>@M$)ko[\:CLF_u\<)uwI2ʺ'LV--nE&ypvTҗėmccϧ.oE50}u-!$ޟfGyt]g@%6cw_&/3 %;k˶]kje}I)1 "Xv JJ ++ ]ױ,p`.,J-OAzZOkנ:yyyx},3)m]gimR&g]k 8*0I8ZەFQ[h'jOjm WiVjdpqƷ:oneji&DTWUc&Æ 9 7TJyB,)5 M18 466RPP+917(Jg "P"D VoOM39b[IOOCu[,wݻS~;Klذ`Z  =ͩ# !-ё`WGu\{i`AF|u/PX;rwՊS-V%ɤ%+Y6;Iu ! x,Ncc#-y <cI}2W̟-OmF3~2}zX;yKjyžW=Vl{(atnW{FG*YOm}-׭\iAx 8q‹(϶WN| #),,defpI'l Hݤ=uݯΤ:w³׭M׭H)/NDD[dZ&7jo'RM͐sr ܺ:-`ᤧ'sɷM0 ky}9Vw,2!fQl3(apu'd%$ M+ 33 [`sܜtݞ[ΓQvtFMA}!+V.'j!''K<獅5ckEZ[oHt}nMJAmEb綋NtS?$E87FWA7a'4aD"QjkkƏ)GKݮDB`:ҒTVV°a(,()$hyUpf d;dw0H,KةKt_ 2x`rs(^W̆ 222}c,gSK[AK)rrreJJ1HO@s,D"^.:Е޻{V~s/ @ Aզl{VR&lsSX;?iheZ)((b@ /yO}N)MӐ[hll$#=QǐLK+@{f9e^΂(KvGAnYe:f)Mg%Ƙc2 8;t16 OiY UUUsRRR-͞svw6mcO^ï* GRH)Ke #>&c`}msrrl75r4"]nCihh$2|prra;Yߛi9Z;Jdw"[-2哗W@qZKD" ~_9hr4A0d1TWWx :C6jҳzc-'Uiïr5[&Kvڮj@A&V 9N/YXzۛ@KK p!#G"//}s!ED"B0 `Ȑ ҳg>oϡbE@biEqDMx<ޣA.}9dСdd= I݈br٥ yyydeen:*+lF"ޯtDؑvPYjfim}AUY776)iDv$70ˍpO3xD԰ݥ=x!snEbذdefz){*3nŒ:޼)MH)IOOGJImmm"E;P$33G嵵*dff1.=Ɔ=ax>lE#a|>_5%+g!+6=?;͏Fn2MpKZu hvv/K׮vr]2iv2󓖖4wsGQQ?35=ܻ3339.` =* A&KiZw'IT6+E{m"--`0ػd2gsbBPQݯHt_*AB GS7iI?5S)/[ש : n[^ܿZ)%NNNn簣(n'$##nOs֭9NcK*{nB;i뵧edgQyZkdaJ6RhXno;bboa& L[Y u >TathUVwH~Mє6/)nm9i獖~sJdw:ٮ oڞ>{>:I!nKOe%$Um 4j{9!CnWvUdѝH5{mimѾMP"{ѻ˖܍-=@Yd Ll㾔v`iu'TŐbͦZƩ֤HD%B@mm'^[I{ {)馷"חvԷJ*'9{|eE~nlĎCVVHSJ{'TK$}L 5,4Z5Fd8Sq>s[@oR%Xvh{*9yK`b٩v|%h2 iI5.R7yR Z:`ͭ6Vzr700DGY A([/Jd1Q]lm]0#>WpB-+]Ͻҙk5[2gK L'$]DEJYV|7vlr^>&+RL ,LEY %{.0fY ק-Nn!D"eeH]a[Ǯ%֝ӺmmJv[mQYpEt4tkU('*{,B7 圧ng@Z/'q+H*:1 m-Ia{y^v% Lu YviH"7M qy<ouݶ[6){".jېnX$m,pĶq'R"un!4%%wN3z(뤠x ~_:t8fLfWڄk~^o%5!Hb1nlI.`$vLJmۢ D2 |ir: ŖBb$1+Ov{AnNf,+ 77d@b۽d[C ߅֔*W*KRIU}rSu-x[B*kzakB B#* BG(U( P"P( EDVP(>BBP(}YBP(% BPJd B#* BG(U( P"P( EDVP(>BBP(}YBP(% BPJd B#* BG(U( P"P( EDVP(>BBP(}YBP(% BPJd B#* BG(H)6nۖ_oؖڷ#1Ɩ!B#7Gh EL\NgO疳5#%1Bs$F(l3V !'ӏϣojo{4Dh G M1ձ& >tAor6> aCAIIDAT["qZ"qR`G'{1m4M8eaYV+Biam=RJ8i&ֶ}n 0mjٞP">uMRY’ Si @I2zpE9VniR[,[WK&bjPn! 1 ;,{k@AV).ajOc^cj_3Zfiq-U ad7޿t )}e FiK{z [/ ,1ka cz>w'|":-]mh̤!⵵4F5;eI> r(mw hY+KX]ր k'Λ! - #^)kj1D=XLbPA:ceC׵->Eb&U!9gMCsNZӮ/JV]$!{R_χKX[YWkijư/1cJX^ROuCvlY$+ǰ~5DY !LS"4#k?7Ȅa<Œ5Ti=;²$搓bp]E#+KiOl=~L xl}Hn8PX,N|:+0 ?\4>-[bKHl̘1C{B[伕,mDzeu[7U uaqT61gy%ҒHl渲]1&*BS?2ޟ^#3rA-1Q(4Ͷ~*BTԅ4"]X^ MNuA(geiu!O$ ouYRJb LR] ڶԲ8xX,Fzzz+6WMR疰11pmҞ-no&~}>K²|d%AQB8vu T7xoZꛢ겹++;a}y&|MuMQޝֶrBKKK.}>L(jWhkmmFDz>cvTv&eT;s?f yeTׇG;glyw ͢ ,^[͉gJr~9RO&`Ui#,*죁}FBQf+%<_,(cuY}.Fp R^C!sˍb466^4ihh4M%%}?[BS(Yܦ=%3za9qUՄzZ^R>{m `u O-Vmޜ̹D?Yk[dƢM55ѵ]lnn;eY qР\ƛ%@JK E͟[欳ݸm*Y}nF)%Vh_$y?OAe}%5}C ŞQKJ>[JYSb[T(Hd-l^(KvD wXYZOuCdi|+*,>m4DqeZmB mir)-l]=U.XR\K}Kt?KW 6ǥmmOKKfsy ݾ%] ;ήz߹d7WHB$4At)tT:" @ؔWodw!yo~̇kf;g9lZRV-M[tJiW4v+c_V:VXYk@jq!ѤGOD4![|Xo߫w5:Ɇ{1tH(2[M7,NO[^uvbId؞[I $ !HX.WK~>n.)Rن(/e锊|X|%y$[~I6l%֢e-0-O:[͔e }%y5~0DM ֵKJG}qLsp4M7,$BR"agP2ؗZrʟoԭ3iV*rmG >OZlN,uMy Yl.)E?iVB_88}x<^} K)4PF -%u-B3\jdA\^|6tbR|[JsL#%6t\>)eUvȝ\H< X2f2="i~BX۔8;*oQmGk:JJc{-lxKG =P`ÕA@!Wdpl(DT7+=´K\@ a:欲PBlI[4oȕUaxƲΓJ}.E\ЭT۶D9Ked{A%ڶ4)_DVGES%"usi4?/8xjEآ|+i Խؾ%d2 $ ܴ\Bm\tXf(aA{,+%Tw~RR1YO)+K?ed{ARx0X]@ط#f O Z'R8a|q]Ir9T:B)Ķb Jed{AJHY;B`ۛ9fip\I,eTs'@%})n;nV~wv JYGYBP(2_RThu QE(}!KI) {ch%/_VFC_R? B돫J0/^(#>cUVP' x'_>$:  >>ߊr ڒ|VX7:s(TQFoh4pG[@g[}(NCbPU|&{K_G5F#PPRE^BP #x2qPF"?t&( z[DA8PX9==rjV<(#7F-0T! 37 Kz !%!(.B~F`iuM,TFVJ0t]s_t#kƐ*CHЋSG0"PXJ!^D̬+%E N?jX;9u tuԕQ5>aFڬ- >_tul(f*')W[WApq\,88%|$/PVhqև7[|G|>_ hVМb#BV'EJr\ɣ >LJIuPS ]JI]U0OFJW.ٹg1*X7&Gtfq]ɸ0^C/Xx<9T~ !JJ)|h>Rz[d; #{Jo?.1[y12"E70qdM+lf61hZGRRP]^x` |ۗazᆺd oVJd~[yf*S㸥scF1 M}0}lZj ,襶2|"A_fW鐭9l7yn (@.ef Q)! (lUȧXB\9fȎj*Պ1#Y(kЕ0{Ru!5@Tp\ɬUD2[[ l #k6 OtM0}leQN0,^JI0֖|>u)%^7jZy}C-ic+ c: '5NR2fx## I)q\Y*. KY̙T] )%#LYoeuyN_D.3`mRxw@Xۯs Kb3;q'RJ~_ҷ^u%O l/rքj*"S"L]ogfW[rpnrMgNo.߲L$R2}\ckl>PhAu<OɄCPIyCed@װ3p <ہ͚X͘a&)iFN@ u *L(#H) ̺&"1)+^)%A;%PC j,=V4ͨeduh@;2)%^;%ҏa3jXFGqdMykыiJ)u·*i g)[C edn0;oSGy؋ecO|s gتn(|Yc0H}36kz0KՀf2kbuѯRb.UAxe_WWg zyI=I̛^[tÐ_uʊ>GDr'  B fg$WJҴYIɕogmRBo0&hV>i$/D/efHz~9L`]sWM؎[G ]k#LSɷHux<8nB<֐L&IR]rJX<@ PVu.]:@ oxeA4&X3nB?Z/F)ٕ5!* g>`:c)6%X=M˄شgk6# 2 !:q6%Xg}s vD6 '2?jB P꠳QW-8kbDuky A]uUiyn D"ضeYmۛ6{<^/at~?>˲r򹮛|v`_t'G*hzUbbLZ:D&J xx) zY&34VbS>WJ\6ijOڑ3aa.J) zx RgFТ׼K^ᶘQI"ew5"Ue>"~ꪂvCכ,MQc&-)bɍ:*I_XR2*DyȻI =ײ,lƶm$9գ+p|yg+ RdJ>a>FUOO/D>!D |1A]I1 e~vsכrJCd[rJY?{'mo-|Q 7ˁCTցO}|PFVP("BP(EBYBP(2 BP ed B(* BQ$U( H(#P( EPFVP("BP(EBYBP(2 BP ed B(* BQ$U( H(#P( EPFVP("BP(EBYBP(2 BP ed B(* BQ$U( H(#P( EPFVP("BP(EBYBP(2 BP K)B`& ly%0x<9u, q[ !`F:mX/!}hF6[ 6ގM,LӤʭVr)%zjt]0:NT(eJ,/BF'`FQ;YEaYeQYYIuuuQ mQzlc ̘1MӔ"+~8?~?@`JF|>!N}IRb6>ӦMmUzj&LS:%$;`BڢlAW^u) (rx<ƍG}}=SL٢r !RɓsW:2zh֯_ϸq:]GG~/}uqƱzjf̘QVQ>%IRb&K("JmuPYSҗ)%`d2YuBKi[T!CMo'̥Qס)bs@V(eV7J7=P:OV)?Qsh 螬)EB@ FvQ|P:+JEU_>TRFC x躎8jCSdQbE_Bb "W_}}G5/TJxض̓>~vm7! эt';XPzT:|!<:y-]C27d̙TTT9]A8i7)ۍ{TfHI6T`&RbS=q())]zfSw\qۨm[[y=xKؖiKXN* I%Mz~ ŃMt˞rv Cӽ:H\+I6-se}"$ ѻeXNF X$Nd!x|> $a>Ѐ %Nj&m'u;f;o.swr#s@݅[\og ZWu#şo8׾J#>^vwGhAi {?=z>;ݞ]r&+܂ʳM7MӤ5Y5Yf kL"BXۏfv;2w\va.<z tMM~f `Kㄻ02F_` >/uTg=)V w0d2wM(!x<xN9<BIڗ.3{?Vݹ/>A5I$,\tA?v2Nʑ 4V"/s͏pv'|.y fgdȼ~bkաKJIss (+ixMb(-MR2uʴ(ȉ?fVth^ ^vW,&==~|E2esTˑhPЃ7$` 4IG0#yghJf$"?La+I~tHT2#%BBK}Fw݌ǗõR$L;.7 MeXg^ v4Aґ9+Àl/GunMMMi$ ʿ`h7u&Bқ~ >qhv @nzO9kC/}-A"EOp* w۝#~zk,h~A%nA,zxg՟8;2v굼Haox'9D']{wЅcNፆU\xbm'TN9(P/;+ӝy|qq34n աͿs6QqcJGg'줭L1j(.mmm"oߕ)c L4N垛捿M Oc|bSWr X`.:] D?? ,`ِ$b_r '߽7i ~W FPn|պY7pAqΣ]w?|%g s7{S/vYOWbrG }uU]^Dz2\|+X!K&wqyXf+<`1&iL&1M(?YfօW-1?g9'۞j$>`#YңӬ&67|; D>`}8|һhx\8Nu5S{'~䚫rAs5nT3sKH|tϸ=x颃mx@ \?ً8v^~p4;9 ~nR.`zx[.Z Aǯkc̒g[ Gr\}/qߥ<9'ro9eX !~˲H$ܢ5l|s~es%Ln}=W;n<$>xwK/~uRҵyg(;&9q'-m:tza/~cwqtϤSsyt}2cđsA>\4x-LGdg6Z\T]̸kdf`v6TuϧAXy Y;{W)Z-j"'ǝ?=皿APJc=:ꈧkYph1s~Ƶw ==8Oc%)A@ ]#XWYFM.'ldᕲxvW\ ɿN۟]&VF_j}!ZI dX%&Ϛ֊Xu2.%Q1R lM#ւ1m<2xueNgL5gߣMJ B:K9d)wR.B jüoTx$*'-1U C/&Fήwˎe҆ɵp,6d ?X~y=<0ʜ~ ~DqީoHT<w ő0YZ`ejdvA l 6Sb{꘠?ʹ\Κ(OnyY($c׽7V1bbj,Σ63Ju]WntL@ׄF]mPGkkΜa~|tm)x$7 i!n0,5q \|n|L4"#LYoluWVSQ;Ew.bNxl9jb.Cik-gTGn]|=$ZhfQgØšɽߜO9}bTC!:P,tN6?C^~eBGg۶nBǓwr/|E ?ef̆WxtkAά [3cӾ0S`E%HD"q]6DNs:}WqenrLܫ"5o\>Oɫw/ne֘2^{3fB&ͣJz‹K 8DCGB|TTVX!d 4]eeqL\|\{^8 ;|АdZEz|^HopeIXfk#άhH_}0L` ܁n=8or N;`~U ]&HdeLVq9Wqr {+0&3L?KD";ps5gb@yŅ.ikߕ}nu|>{'WʲwbԨQ3y]ӑH‘ e&z՛u. X={cv4-.xL#jlk=eQ}~h8V*Ivq*.*.AD?w9+ٗeCR.f"%mLm"t5_r5G#4c|)ʽ4sv/[Qyc.sOKRװԃp|\M?>U139嘆՘ 9|n'hԐI="y{%z'%K99^:6x<zP 㸺c1L]ڢ xA^Cע3q]¹}ff"F% XWB0$ihB {2{h4czH#RQ˜ PZ?$qCOr=͝QLnZ_CF}&pv$&><ƒx2fţz, <4Mcʕlv}҅ͱtz1\jjn>#gװ]wesa[):R-mh77T"F֬H)11XDdB\Ǥ얧DbcĒId''!ں?L0sqWn·.~s6+{ylB*щ~vrwQ8'x=9`0Ȝmwq&аhd2ŧ *8s9\ۊHB׺RqRMʚu}^2ӝ:uH)Ico IL=g $;7ٱ{FLB?<ᱣhjnulleihŇ457 Sz<!(3v̼LކQ̄#ND;Ht'$m/ wvMV;aW]NvyǶloQ 6D;ۑ=F21nMԵ/̗b%]׳b B|> , lu͕? lML8g0#BGBM6Z\}26,$d{}pB/5<}ڌsAmuԉd%sfȈp~~Ǿ䳹lc7}yoikyV3Æ^ӴNuu#x՗3k\TM^0ߧ;o]\8Gn-7IO bRҫ9wb>i`xL"%0$nAK ePzb-Xi0z]֠~2%}o5)|klo\¡0؛Rn[G#|[鯵ŢR :B^ǡDb! s \is}(Jҡs!}h|AI{fbڴi*P-@0X_+\Ԋ]NzJ}Q ՃbRsB<O~E((S š.Vϥ/_#B}bHPʣOC Eo(J!WH/s]~ťE)TX=riB2@bPbŐDTS ᾁX][RE=pqBQџ(*Sb|8:Ŗ"}@kk+F+]8&N3\uikkcܸqyXn`0w\ȥ6\B]]]AiY)%#F+VPSS/0A"#F0lذ\'%=7|X,F0A%I"`?ʭ!p8̰axw9r$~_L&ijjÇ?R#ud8uT*++inn&)1sL)y~?;#k֬!Jw⋁̙3'oյ:Bk׮%oUZMӘ2e Æ ت>PbdX4Fd8{`*wy^Ə@}I)Ts+=){ Pl|S,=iDߩ"K1e!2J2 BP ed B(* BQ$U( H(#P( EPFVP("BP(EBYBP(2 BP ed B(* BQ$U( H(#P( EPFVP("BP(EBYBP(2 BP ed B(* BQ$ Kv9 B\޳G" %tEXtdate:create2014-10-18T23:53:14+02:00"%tEXtdate:modify2014-10-18T23:53:12+02:00}'IENDB`Mopidy-2.0.0/docs/ext/mpd.rst0000664000175000017500000000660112660436420016230 0ustar jodaljodal00000000000000.. _ext-mpd: ********** Mopidy-MPD ********** Mopidy-MPD is an extension that provides a full MPD server implementation to make Mopidy available to :ref:`MPD clients `. It is bundled with Mopidy and enabled by default. .. warning:: As a simple security measure, the MPD server is by default only available from localhost. To make it available from other computers, change the :confval:`mpd/hostname` config value. Before you do so, note that the MPD server does not support any form of encryption and only a single clear text password (see :confval:`mpd/password`) for weak authentication. Anyone able to access the MPD server can control music playback on your computer. Thus, you probably only want to make the MPD server available from your local network. You have been warned. MPD stands for Music Player Daemon, which is also the name of the `original MPD server project `_. Mopidy does not depend on the original MPD server, but implements the MPD protocol itself, and is thus compatible with clients for the original MPD server. For more details on our MPD server implementation, see :mod:`mopidy.mpd`. Limitations =========== This is a non exhaustive list of MPD features that Mopidy doesn't support. Items on this list will probably not be supported in the near future. - Only a single password is supported. It gives all-or-nothing access. - Toggling of audio outputs is not supported - Channels for client-to-client communication are not supported - Stickers are not supported - Crossfade is not supported - Replay gain is not supported - ``stats`` does not provide any statistics - ``decoders`` does not provide information about available decoders The following items are currently not supported, but should be added in the near future: - ``tagtypes`` is not supported - Live update of the music database is not supported Configuration ============= See :ref:`config` for general help on configuring Mopidy. .. literalinclude:: ../../mopidy/mpd/ext.conf :language: ini .. confval:: mpd/enabled If the MPD extension should be enabled or not. .. confval:: mpd/hostname Which address the MPD server should bind to. ``127.0.0.1`` Listens only on the IPv4 loopback interface ``::1`` Listens only on the IPv6 loopback interface ``0.0.0.0`` Listens on all IPv4 interfaces ``::`` Listens on all interfaces, both IPv4 and IPv6 .. confval:: mpd/port Which TCP port the MPD server should listen to. .. confval:: mpd/password The password required for connecting to the MPD server. If blank, no password is required. .. confval:: mpd/max_connections The maximum number of concurrent connections the MPD server will accept. .. confval:: mpd/connection_timeout Number of seconds an MPD client can stay inactive before the connection is closed by the server. .. confval:: mpd/zeroconf Name of the MPD service when published through Zeroconf. The variables ``$hostname`` and ``$port`` can be used in the name. Set to an empty string to disable Zeroconf for MPD. .. confval:: mpd/command_blacklist List of MPD commands which are disabled by the server. By default this setting blacklists ``listall`` and ``listallinfo``. These commands don't fit well with many of Mopidy's backends and are better left disabled unless you know what you are doing. Mopidy-2.0.0/docs/ext/local.rst0000664000175000017500000000645712660436420016553 0ustar jodaljodal00000000000000.. _ext-local: ************ Mopidy-Local ************ Mopidy-Local is an extension for playing music from your local music archive. It is bundled with Mopidy and enabled by default. Though, you'll have to scan your music collection to build a cache of metadata before the Mopidy-Local will be able to play your music. This backend handles URIs starting with ``local:``. .. _generating-a-local-library: Generating a local library ========================== The command :command:`mopidy local scan` will scan the path set in the :confval:`local/media_dir` config value for any audio files and build a library of metadata. To make a local library for your music available for Mopidy: #. Ensure that the :confval:`local/media_dir` config value points to where your music is located. Check the current setting by running:: mopidy config #. Scan your media library.:: mopidy local scan #. Start Mopidy, find the music library in a client, and play some local music! Updating the local library ========================== When you've added or removed music in your collection and want to update Mopidy's index of your local library, you need to rescan:: mopidy local scan Note that if you are using the default local library storage, ``json``, you need to restart Mopidy after the scan completes for the updated index to be used. If you want index updates to come into effect immediately, you can try out `Mopidy-Local-SQLite `_, which will probably become the default backend in the near future. Pluggable library support ========================= Local libraries are fully pluggable. What this means is that users may opt to disable the current default library ``json``, replacing it with a third party one. When running :command:`mopidy local scan` Mopidy will populate whatever the current active library is with data. Only one library may be active at a time. To create a new library provider you must create class that implements the :class:`mopidy.local.Library` interface and install it in the extension registry under ``local:library``. Any data that the library needs to store on disc should be stored in the extension's data dir, as returned by :meth:`~mopidy.ext.Extension.get_data_dir`. Configuration ============= See :ref:`config` for general help on configuring Mopidy. .. literalinclude:: ../../mopidy/local/ext.conf :language: ini .. confval:: local/enabled If the local extension should be enabled or not. .. confval:: local/library Local library provider to use, change this if you want to use a third party library for local files. .. confval:: local/media_dir Path to directory with local media files. .. confval:: local/scan_timeout Number of milliseconds before giving up scanning a file and moving on to the next file. .. confval:: local/scan_follow_symlinks If we should follow symlinks found in :confval:`local/media_dir` .. confval:: local/scan_flush_threshold Number of tracks to wait before telling library it should try and store its progress so far. Some libraries might not respect this setting. Set this to zero to disable flushing. .. confval:: local/excluded_file_extensions File extensions to exclude when scanning the media directory. Values should be separated by either comma or newline. Mopidy-2.0.0/docs/ext/mopidy_party.png0000664000175000017500000022736112647245254020164 0ustar jodaljodal00000000000000PNG  IHDRN)$ pHYs  tIME A* IDATxwU7)rCIBB -THhB`Dc,4}AQQPFte28# @${/7Խu! pIhg}(sSyw] T"Ў D2&Im8&CFJ)HE#pEƾ﹮ u(}??GIHE*2l1  lE*R]$T`آ*SPTd [*tE*RT"HE*[T" lE*RT"HE*[T" lE*RT"HE*[T" lE*RT"HE*[T" lE*RT"HE*[T" lE*R|02 ``] M@=  @Û$.y bYvCĻqy#$`L$ !Yva]6V"U6 P$Di Asb4 HИ#hjb %!f"! K7C 5hH PJDZi@စ<c'd"U B }Q 0D#P wJ @ (("DBHB+ !6HD 0ȉC~{؉qBC@ @buZvZHK1U z8 "J)lpvJ @ |@D(  =ć=)A(R1" % bO(1jvv/9^|&aAlK(v@@~(f~Ȯa밄$ bmivk/6ڨ0Ý(cז@¶􈑐ʀe(F BbV'_lAa(whHHCw\k|P# !I#A#j) !(1|bu^ J)۶;(8Zd-̠%}3OxήTl Qlлt6AXչ%:l(Yl'ր$hJ~J`|"ؓJ7cʡk#cbBb( !4IDaG$B`Y5  BHo1SePIHE0, %p֛mDh@ӈmE6 al1+A@DDd @fZWj;c+v1("` FbB DAjmؠ65y8ūTv]E-f8veH Rq,Y"asՍ{B!#c9*ۅޱX'.C PJ@A@TḄO;Pzym.zGX(ꉄcW В& #gj 01UnPGQ BFDCPRG2pT "%صS,HE0@(T4 ),*a#Q Ov }/82qP8f}HvD1r-Kƺ Ph2R+R|@X,!(21Htr8@#b-0))Dg/LVހH[^ BDhBAT[S_"c9oS$Y(SLΏD,\"HamQ#@$BB$"m=NRRA75BߛIg$H! =m+^@DZ)Gϟqȗzb k616*cVTv @ʭΌl),-žu:FsRܵYy/ #D-9u_nA]JAX֤}%ַmyK;4J閼zg5by9-M$HE>'HL8_k+u_kVf>7^5ꛗ6+jO<򹨿q sߗ!0Fgkv|e<#e?X'6 -n8QWǒUJI)٩ \ǀ" CRJ=KRJ 8ZkTE| Z;RZk!DǶmsQa+QI)mێ˲(/)eNgYVRJ!Dɑy(@Iھ2aBoY"j9Q;Nz $̷w!6IW @XJ؀ UPR{{FW9N\#>3T_t P[(%3gbе&%MPHF0BDAZE bX u]D 0"\׼]׍эvlaTqJRTu]ܱsI8}B "_eYZ(BλvIRض-wzM۶y44q(RrfV1FJ8NyfǮR-IfcD$+X?YW_< }BJE"r9Ds'mB۶MDB!$ΔaX,rb>>5m@c|`&a )çf6]O;Mwl9¦Ϙ}[IA FG[^{A:RT*A[[Ⴖ m R+{2`]J",p{{{}/JVA 8A sk8A8f! bDy^rܤ|!D.KbÌD__o{kX!jn j-H0| b$\B}m (U ]q +JRP`Su0 s.aSr\ggwvv^wuf3{~r\uuyB GfRTmmm:fe.N ]$!ysg8,+01P^ HDc@J aL`)# 5n \(UӎۯoK²4 B9Tf?yވ#ΥKvttDQTWWO3lll<sm{ƍcVN-e0zWwww|---mmm?ˋ՗|}fLww 7ܰqFf .L ?~v7|?A[[[uuuXlllNSϿ⋙Odi&FwyK/_Oٹs?sOO#<2k֬vߩ+8qDZA"(i =M*z^W L(H2=ֶD)L.G^#{{ԥҒJ# 1t!"ۛw}~?3B z?~rիW?lii?~< UWW9Ϟ(^ia7R8leNm۶ؖd˲:ǶmV,fR0{Zb6rlų.k$(؝*r\ooի.\xdt}}뺅Bu] ytI< 0͖Jt:](ح ðT*1y̙3lǿ/fϞ}AJ%۶y+=i1b̙3y7nCORLdu==='# ζrB>J)VXjժ>p{_NӖe1άEuu1'yk"h!H3̛7f6Ec9fڴiV*J,+Ml6J֭[EԩSR`t'0 ;::r\MM :KyLkΛ7? ŋ/J&>7ĉ laKuu;Z>]ž2Ly%3 C=c>o~30 mnkk1bD>7'N6lhll,+Ngioocav;N+:;;Ynii7477L&vҗė!t\nÆ ˖-;v R"I&;]L=s=wgva}&kjjڔR#Ga@,AIcE|CCCGG裏;{ /pĉp cǎW\1e=c+VXd?'p%\ȍ0 ]eM7ݴl2ָǎ{Ϙ1vՒXA_4gY!ijj_z5lݺ㏿cs}ӓd.9shY`guݪzqQm۾[.]VQss%K?p6;qҥwu͛j.\dɒ 6(./?{}u0Q,5 %RB!d m_Jn. 16$R2h}ɸ{#PhWg?v -IQVWO??>ydF|>6 ;찇~#9sѣ6ꩧ-[v饗9n<}c:ٹf͚+W5C2eʃ>c}_1cڵkq-YDkoڴ_Q_?y}u 6lٲEk=00QXV__oo?яN9唋/X,u]ַZ[['M$ذaC__ߥ^Z__Ξ=/6a(HJbXUUNKyuuuLIwvv^N1W]uʕ+?ϝ;矿nn#/_⃤RM6x G?o㎛8q*1XeM0 o/-;wkG?w[]]hk#"mpp밌zMMMo.]:o޼;;ɓ'K)|;-Zp1榛novE>*^ G²{.\x1Ǵ?#7xIl&ygꪱc~[߲m㭷x';'looW(,XJL}%D>ZH%%K ;, OaAHǭl%q l'B}8hi{/>?+ҙj,1Ps|2X җ}?~I͛7{QFe29s8s}~gQG5f>Tssy睷|3g]xSN9ŲBӟtҤIw̞=C%Q*/^|!} FZdk6~xqlfWiyGΝ{嗳%;eʔSO=~ar93bf~c,#u̙ TWW3w6}饗-Z+J'x%K^ze˖]~gyfE p]7ӚUUU񼵵arJMM ~I4+q'0﾿?/ރ>.[|qJ9fgqꮼӧ;bĈW_2eJ>G}UWj.իWϘ1 m ~l޼yW_}ud6 sν+0?ԩS?z_j=P&[2Cmkk{>OYf}7n܅^>0 3 즀aE|0 x IDATh4 ,c=/@CT2& ZPin+0`v KDtG~O?+V|Qt:=rHuW^9s&Ϙ1u)8%V"$577ϟ?t\>RNR,bgoQ(fΜy^UUUXʕ+.\pʜH;8vHrz+5 ,;wnWW̶www !^~gy&3:001|fҷy 3g_{ytm{{oh"JRcc ^~e!DWWoqaٶ󵵵v-[666~S9rd˻&KD8~6D)QWEA!H4), cZ~m[yĪjUS['kB=}=83N6T E >a$|fxz'tREK.3fqɚœdk׮VZq7ypTƍtRtgZ{{{Evi||>饗]/f„  T*n}}=}̟r,{?982M>0LӼn7o|M7=өT},'pW_ƌR}+jm{``,tؐrL  qL&SWW]]]Eka6#bB۶AkC^^W٦PHcDƲDDi+!ʏ?TX:餓ZZZ,YOq"aw}3<}jjjz?|>~琚/ҳ>^s56mWJD7|O>yz[l93PB(kfN6"68 ?aOY'd?#G ?~|r$fˉ>ɓg͚!Ia$۶9g]B+W.XOe$x<4M6} .;~V6m駟w +eI/T&rV:S š5k~gk1)CXra&93R٧%_l\spG&D4yd׭[7a֭[eyzbƍp7O6V'9nmc5oKK1f˖-&Miiiټysgg>e}Yq;iyK 'm fuu5ˁS7Lg4\6ua=zt6]nqVE{{ɓ rVZd8w/Pps=5KJl k!sKPQMCid.){<'vL~ӛm[35$mBaƁ%t&eR '~wufS: 9R?Oo [ׯO*;w. B&fDѱu{ZsN:GI7nqXܴiSKKK__Ƿ{u֪{MEŒ/LqO<իmNK.]bŢEXd <պ۶{{{{{{0 ^PMZ*%Zӧ1⡇ (QFy䑼UUUqHw5\lLP2! "`PfMaL&w#GJ|eƌl5'{ c\b3蹌\ r9>HMMI'c'>zssBKܳ,-wfx{_#F!뭷:cjkk9春Kg|N:iԨQJ4ݕ>h-RX mԈZhNJc/)uv0 SEHDJ^B@qD1)eŢkA:eR(AZP1!6tPX a83fe]vZMMmJ>{|~̘1Gu}ݷ|ꫮ*˝uYw>ϱ;m4O9y֭\^=3qοowygUU".^_EZZZǎu-Jl}s>֚!O_fg1mڴ VXOO , ]]]ˌ#Ddfp82KyOUWW_ve7x… O/Jo~\ҡy=Z.oI98.),l޼u}믟? V\o|(JJ% 5e8TtL}q;00p[ԩS+80 9`]sLuww3q&/^z'xGꫧzGy/~饗{ョ._|ʔ)v+9F{ ȁ7І?LA AbdP*tJ cxQ m׸#tJ`P #k %Uqӈ# GXo+K bZH7lA'؁&1uR `DR)%( T@m0d0J"h#kc:NY0!7.0 (K(m*ɚ>}G=iҤ1c'pWEٶ](ciΜ9ƘiӦڶ=vK/sϭ7o^CC{f̘1qR(vAs1 ƍWr1qԩSs򗿼^{͘1F)ϜxGZxYg]pǏ爺L&3a„ߟo%S-J:޿vƃF . Ҵ=F1D@HbYPB=(^dy,Gc;ư) ԻgA"wkelC Ik1I&xlNA\ PژS^~@:JAV\,aCG 8B[I +B]k0# }"Dm R[ae/_2edv'|̂z{c9*Ǣsq9IBr|.UL϶!#,!3MR&J[c yۤܢ뺮}"GfZɹ3QkS(B0]N/oy%nauo-P 9YK"bb:CPUZnr1{q:C2zbdeǖ<'8hgg›InPgac/YmraY?,i&tQhOL;0DBP* Q H@0X `<-  jNuGFHʴD MH |EeL0EG"CvJhLOBQ mdž&1))($a" #DzR6v\DBG%G-M[s|;4b)'%T LBƸD[LQg||e2^)OAI$=5dM6Ua E%,KvdM6f`WUYMjs& ;~v=95Ir[͉6d{Z?q{Ԭ(<1bCdlPfLҢ?yHF"$4b0Ej8@_8d `Cl# a!XHHǀ;p ^P@ HjE@`@ " $ i C` A168R Q!>:rStx'|} lS}.zYnĕlo nSpv^r䒒5\ .BoCYOHg1aLp#p-!  2<*ryA ;/o7 (؀"aHH)0&PIm>~9 HEX, eeI;:0J1`Emnwv]RPiAX(ey_ C HF10"c#" h$C@Pۼ~v4n1!I!#"M V"xe8cxK`87(G O;Z!!*8"9㌅BTS.6ARHhP!R9ʄ1!B`Ӱrw=Ɖ-ip7H $D-&c,@HSy+R+_\?z/ DeKԄ$dkh H ;7μ;ٶGdi"&aEaNZZvbCCMڱC"9Te H lH\DP24 8؅d0bH8eTʶEHE>2>X9ejjґlD,h@#^xqzq8=.I iQunI`&7q'/):) (MhQdȦ؂آHD@^;7.!((T&v(p %C ,YuH%~%LIqҤ^w"q$.Sة_,j}Q^kug K[یZQ<\7KɉpOO8!%$aU>;Hw$U`3]þ?u7{RsVXF8v/ob_ÔZv[oW1wއ F@c^MU.>eݭ )nbdTBH1@] -L"l}A۔ } /QA$#In.E4 $jqdb9 w񘗟(d p$ҋ㨶 r~azIxsg2iˑdIHp^coepRpW׵%RĿ`RRtdUea(| G`_t P-># 4Dve}*r%6bCVHGvIu*$m,!h/|eeۅBv!UDJ!F$ fiJA13sg1&gև03%Zoݺc9w8F^\~xoDvGW5#&WLDs5n{ǝ '-9i乿4=, |IFUt:DsER'p`D2 r}Rf5~~~I|1'J4[%P6}ٰar(mSoyK~/(Lb;LaX4Bw(w=$ H`t ^`, S:"p1jj!6mKJz% Z)[aAHiYM$j + cP!I BШsi*L#@&M0"u555\  NjqB:4$Nb+/4LMFN\M[l%m#,%Ty*"i`ù0*I=9,Qr> ɩYZL&Ú gA™p5 WJm+]qC3nߕR&% ¨(l쪇󼚚.HZpt PGb䁉ՠ{-$i@AaN`qP"2:쏚kVˀ,Svo0ڵXgP?# S p|>_UUYWWSOyw!p5DIyiNn IC&kC9?iStUeY\m޼yĈ'7N\Ŗ9uwwsA@\I ~}mT-.͆y͚5Lf̘10uX.{R(+.K2h9 u6SX˲_BpQGY}_xcmYޅa9f,v Ddi$N:Leqd6c HM`dRYRR82R" A%B"I dc,ж! eȦ?i%h F(FI ,.jeB@a(=/%zꢋ.:蠃x㍞͛7oڴsƍ\GۦN錌&ll-n~!۶׮]ꫯ55,˲6lkUUUq_| w=wu׌3Fɨj۶=bN,J\H믷m޼y֭R;Mqk6 iӦٓ +\5klڴW ^m?Sʘ1cacXlhhXz7ߌs埏Y4IәLIm߉\q&)߾kuX*섧P)$@Yr 5JuHDxG,,7 m4Xv:D210Q) B0eѱh*z J} nٟeD \ll%% FX >:X~^z/Z{R$6B\wu'|2]~qUUkvE]|SNe-x{8+OzhԨQܽo~sWuYH)Q&q IDAT}K/K.x:u\5QUWW?]tѵ^;mڴ0 WXkeZ١A\[֖-[nnn>O=TNR~;&/ںu+7YH8%nZcy͚5_/}sl[oG9n B0gΜ "w>8q"ZcM.455xIaVՒ6[Mkq;CdpT܈"rNR!R:&zg^1]EqTڎ#0-EC!*)EdT#* ]T*EhA%r;fM =_.L|q !$d .sݵ7xȑ#;bx'?FO>C=t̚5Vn~ q=ܢk !XcI$#pwoٲ=SD+&JVX#|0P^$P(L6gbef7`,d2]]]ICo.x'/\{͚5󟯸K.y^mm-su,ͺ;";:3Gftoo/WSLZEcN:k>?<2Tz+544p0 Bmm-<E 'w\.l5 l~]wiӦe˖K\n…7s_<6ׯ5kVUUWa^vݺuoQGŅrqԨQW^y1c&N nԽk)6²ֱcKiDKdH1WBrdYe%m @Fkƥ k}a;  0 њo_1cɟ=.vfV>Q`ro3}O<` uɒ%??CEQ WZuE{K.}˖-b`wuux≣G?>N(غuk6q=z^){ߎI=o޼ &|gŗ-[g7`1mٲe81k…~xw}':uoiӦ uuu8F7smmm_oE麞JJJJpba_nٳg5M3uY7o~7Rr݇a9(}G'O2e͛A{U͡.o#Q?xՆWJk2ik׮0`@,V`AWpOum /}^y啖r 6wO3;8SJi޽!CDf-7n\ x<8hFF</**B/>\ ѠHcn[[a6h MBDz1~{lwaqu=sPNKYJpTvvvc|xKJJpp4h^yrq ())ˮ>}TTT/ϛ %qL&?㊢L6kD"ڲe B'mÇrL&v޽{ӧh儐#Ӷ+ꫯ^m5zhyy9؂O>ꫯ4hP*۷_vZS; "L,)[L"XWW7c<71sWcаf͚+bmq &0.%K7 wޘl DYjYܟ2\s?\[[JQN?6p  S;x<>tP~`띝(M0WVVnݺ5 i 8?ągڶ=`߿aÆ`0_tE>oo֭޺zꪪX,OСCĒL&Հ0h4ʳ&uw}wݺu0RtC#%n4E@7Dp>Y !!|>W׿NӈߴiTʶD">DcH, 1?яO~M7cET2|x:5E;ư/^wqǏ߼ys=w=p b1HxDh1ӌhAxGe!:qrIp\&Q[ ayp [>9sJJJm6{O:ʫꬳv%zX{q<fEp``guuu!`'B)SdYF7?*Bq˃`؝҂$#na$s?>"r | k!Ԭ_>NVTTPJ_zT*տ <7DzD⤓N3f } @x B9Y`˗'ɪ*P`aB!~|~&O裏N>)~ /ݻwWW*|)^ _.38,q^eY@hnn~~! *N#kWco0P^/f3gN=X,6j(Mx:c5WE)ދA<`P|-m-S]]]7nܲeˆ [O&t:ͫٳ~^hhhk^p f͚2eʳ>iC=t嗟vig}<[R?N=_Wo7 \0g߿~O?SM6 卍<&[>ǭD&<1OwO(T {"&vy*1cZ[[-Z4rHY[[ΝUUUD7%V_ TMW^MaÄOӰA'bP(T__~xWZXXJ֭[!4hI'tW,ZhhRSS.\3]<`庺0 8wqSNEj,P(aBu>裍3f6lشiu?jB,,,tb[n9rdEE=:L~PcX8Ƭ"뺝p:J DH+//dr_=߹[\\lJJJF{9xA a_f͛[fg,/QF8ΠA8d:/\?~;vժU< '0{ &ög2eO^9sg9lذɓ'n!-[[X^flqqq$Y~}>/++~ᇦivtt`SS~8VSDвE ;l %|>_QQ pa,^q{.Ƅ뭨9s&Qad2=gBW_=vX40vX~T*u5x<`쪫b?0ĉWXo?>Ͽ;sĉy' N2}'|2B.]zs1"y *eYGqğ Ȳډm~AAAAAO KWUw2emGq%\#;|gg .d}<#ѣ/_>zh<&Mڲe}݇I&]vYee%!dٲeg1bDyy9ڏvttt`E",*++x<!:c|O믿o?~<DhJ>AɲH$N~% ;GymrgO>}(ݤiҥK,XqF/G]qӧO߿?Q0c555]tʕ+|}&p8`)Sqƍ;# /$zhc/xu׿u{1l30A&{&,k֭}E]׷mۆj,uHO 6x<Np!Beee=ooo׾(ތ~a#iJcl˖-~dO>AKyy9v7onhh(((D"?aMMMuF(> ju]6'Bp]58`]]]H$dYF~(\*$aeO妦H$.,ؒ_s5eeewqhߟdr9dzx{w]eee<<]__ ;ZJs_3eYX\td˗/[j03hJvzq֭[:T: [ZZmڊ]!b>(rb1EQjjj8 4b/!ǴRzH$OԾ}B-b0,..P( $ ]HH$…>;쳫8+g2:щ⽱^)=d~w@'D"H$e˖#Gbn`D"MMM=PGG7<BQN>4k>s$C;x\?`$;N Qi"yP߱uoǛp>"h\CNRP7%[B67w^4 Š>Qjf0$'WDN, IDATB8#Z֯_?cƌI&UUUxmr< 򗿼{fϞ8N#..MpO&ݽXAx<zX,6i$XXQfXl1~ 'P[[7k <Rm3glmm=s5}c9cLCB΢EPţ6 &Rc:::orʡCB P\\<?<]v<,..>c,Y2iҤ9sx<7|1">@XHL10l6Lwyة@}{ePԌ]KO|Kn WԼ}XMykFcuuu'N<3~?yCZ[nA-`Qc=3 bLR'xiDtp\r%WFŜ9s?Ϛ5+ׯꪫʶlrwwy"wމlګҊgW\qE8BziG}O?}QGAڲe%\j,ϓ/dn6[=neذaf:cS_RR~w}n .ҋ.BZ؁"{LK7pb?w@cX!}ׂ rVn2{t|>ǑOWEQB'sz;N0!% (D\rI^PRz"O>Aw"jȑȂ n21X!d2LVVV{~R htԨQ.HEEEpR#zڮ_a/[b ]8[׎ͳcz[qyl.tX?*ʫl }Gz) C2Dm79)V Hjk_ be0EAl<\Cdި5wDp 9ypk_}7yi%| ?>~8tX17 |ߔo-@@@@@!V@@@@0`X{ DaEGBy(7ܤ RQPa-r~A8`XBvG!L"\\0p /Y[_l؊xXH(Jv]X<$iA;C V`|sx2Ppֈ-^ +@V㆟ E@`WJ兢ƌ6צ3. + V@@@@@0`Xa  + V@@@@0`Xa  + دs\>mE !Fn~N88 8S*ލ!$ɲ d9sB m.뺮$I0fŋ8dHֶmBRU)nΚuu]Ne 84VuG}fpw.rB\/F@@P׫(i|U/Oi]]]tZ$Mӄ + pذe)˲eeeߨ ҲuVWZZimی1Au()ں|>_MM7df2[CTaCBƍcX,8pUgN]ףѨR[*qZ`0Nc uٰTJU@ \$yoF>}&hڵk~5ksyJ:˰1Y^vh۶ilo?쳗^zC0 v4-L[__bŊg_.r0n裏iaF4kVZJƏf ِ^0 (WUt㘦:8ҖիW}fYM! K.=䓃 RS{7bY4ԶmUUyeY]AvemۺiMн>*mnΐ!CГׯJ8X0 UUUU@M0>]~?d2ٷo_1ȨB ɤe9 p~̋YAdGűc^Y%Im=O>$IUd2 A-+I4T*8,˦i0o":~G}Ϙ1cܸq0=O FD"`^mۙL&:~R[. GQ0^/ R4 ,.qSJCP0ĊzEAjbjML&PqzpJ>{ӧ/4[[[KJJc}QEE-3L.D"aJl.++ q[653{^4̶l6F~KR~?|>8x"X֊@ud2xk p"˲EbT u3Hnooomm eeegi|]:eY|>-Oӊ$),lV$r͛7FZ$g39rzʦ7K`۶$I0|O>xx/ax_r !3f̨5k8F8qbaaa6Vl6뮻. saBf4MUUM$I,+nݺuܹk׮zjʔ)r?&Luxz뮃9Ey<޽{lSÀ+`O۶SO,$ϝ;W_5M~?@sy72~WC7tSMM ?>#Xa|GK.= `'|?6loڵk?L4 @.CwA F@`dX>Qczb$I卍ϟ4iuP(ynA5/nnnHJdYB-7oލ7ؿl$$I2M3>#}z$ =zhnn7o=ztI ,x뭷&Niڻ|{^0}݋.h̘1PZ')ӧOssa#G>T*uǏ?7+D"=3~OS헿\XSSHN#IR}}}QQQ<rzZtr܆ ?|JiϞ=Nx߿իWXqcפ]a' |.wml[[+a+D Ԁ@.J˖-6~p8J|>߮nx<ާOc9fܸqG}}>Nec~iAAA(xdȐL&D4h[o8%KVVVBV\IK)aIibǍITTTTUU aχHL&Cx<|~bŊK꺾e^_(/..^n+V(//?S,YH$GX,۶mkjjz7x<p?" p۰;AUU]}>›|>=zYJ^ ljggc=e˖}|u]m/MN>N8a'clS$h6Jt{⦅|Iuu%K.BH&Az#^AܖeAѐ4N: /-&IaVRRGCfa]lٲ#8cy?#0JKK+***;wy!,!eY>u+AETH$bv.C`P#$籴t:=~_|W^X amr ЋD0vĈht~Sd2l6 Bضc)7 ccc0aN W땕+WlhhضmaVPPP[[o:s=~w766®x<`Je sÆ Q䈢%ʋj+**:s.:dcl(zeDC| 3fҥʘ1cJKKAa455Ap7{yȎU=He|F8DZZZѨi~t***$Iz饗BP޽ !ϗ/_^RRuݟ'EEEx#sҢwJb6ΰ;::_k׮7n\wPN<3U44)..`RDkl޼tttO]Mӄ0" pϼxW_}rҤIEP\]1 öm7nժUs4hͅOzt:XDr8x}9䓗.]lٲH$?xBEb}eyl@ 0a„?oJKKCqVUUѣGϞ=͛Ϸ, x!.*++CU=`;ǁ* o޼+Juuu(BNᨣzܲ޽{x<^PP wnOKE{)yfW]]]?R,>9s,Yd[l1 # >yk׮۷oaa$I~a0Ff&Yn]iiiyy9!mȐ!hwtt[`D"f͚㋸] #O?d2Æ Qr7AU@b~"[N9唳>+]W^hFYYYMMMCCCiii=yLVmͫWd2w\2ܴiS޽~?uÆ  30H_%TVV80T* [.L:9>ox$i۶m˗/ORcǎ]|ի,˹\n}Woذ b|O0АN 2ɰijfz뭷n"$e"aT$PbUUŎK:Mwp_#Qn"tuuQJ+}MeY<2gy/++)YTg@ @v12u:o&ЛE(Zz͛׬Yf8N < *pgH$Ib.@4u]ԣZ@./ C0M"exX[о\' p\EWEQzyQGJ"`:n f t]t-p?+r l6iI2 ]ݝrr9|O?Ueĉ?a a`9qhZ# "WDtxE6ǫ"uî(t# )~u&9Yh8rQE@cX;vȑ#y},iuO ,߱Œ }=\6 *p&wa 0_~Ɗa`mzN,~F /~|?ziz<q;\ReY$۲];r}u`GP{)tZ$UU*EQQ\ig,˲mR꺮$I{x" `Ƙ8xջa EQ٬eY{z|4ZWcض|ضd!@uB1UUMӤ߃Q߂L1Mu] d$1M3+猀yw;&$'3Cl$?0v; 0{}>8 >y(c,[`6鞘$q6tzZ^uȲ,kHiah9`CPܟ1 ca0ZEu- ٬$IUxw>7"I]2gȠj 'I*^NDt>4- aCw;wA\rCeYlֶmEٙ:L+Pq0[:0JdYY4L>G)lY֡M{y֬K(١L7leCU+Q%(R/_@%xgX10xńI/{Zr8|)/l6<Թ뜇vlF*$I\)g&(z@Ӵl6ŭ]4e0Bêz1gvW+1f۶5 ,7%g&&3^ i%3m0^فU]p7UU]Eo<<,;mTU4-1HJuyIAR0s;Yx]*L( s¯]GfH2e*$B PVV\.U !s%*d6,_,B!} L&zada۶,˚\ÂauO`Y[K<,Ճ}R,rn|] t<$IqKa;7Ҟ z^Uvx XNx5`oꐢ(bIv[2h ڰ`Fχ[if]3 mؓ V̥%L)]5f#ım*QIoa%I<,fTUU^{@`41glll}=ٺL[,qq^x<.¬u=Jmٲ7_8\.\溶c ݜeYMMM_^r#4UUP>Ms+0*۶mbVL&nICAjiH{Z6>L&-]<&\.q`LD5aR[,ˢe1\\зۮ Lr`=&llggh4PJx7J3iFnGm۪j%}_6Tv"m/^_f&nODMLy}^YcuF $)dYWXX|/áP01*K$:/WFƁȪLeENaaI 1DZbO>y鿞~ƙ#kGȌ0IXҹjRJeDZKdYqG$\. FΖǟI#F Bk+溌JK#̕+,;vLɶa$Ibeu %ɔ0ƈk*r5I1-9a1FW=DX1kdlfIy4Yᯐeu8TceJL˴nٳ<^o&5O1G0Eի.#(i惊rlGJJ,$ɟoݲagc{dR$b9$K%.!̥Dєo.sWbuCMݻ5wl ,)udI!*Ieʘc٪$3ƨ"ٮ(i2%B*=ݰ-u<ޚh]QcGSEV\Bl!?;[N|P1F$L1E%)g6dkժYeԲj6r^E\lה%Iql:U$ #QIheV|(j˒$KTR-uuukQYfȊl,s$Y6-fRqlYc";\&K2)2#D&Lb6缹m-$3Y.,,:x#GF|6g٦eKB%JrΝ|ԚᚮRR٥㺄P]cy3ӵ=p1qeImWMCrv\ۆ:ӧrq'`6WQ(%e:61BvUQkU.\؞찉z4˶)e2!s>{ᇷ[]YMJTQT sY[cJ0CټGY71 _ E,SzD<\ߺd{g~J+z?_wyQ4GnhBJDS=JZ]TPLWSt&kF@PF~P˰_/_q'm[y"hULrFD>03N93PLmBUɯzeZ_0 œ ӵeI}*rWWJږMhe33Mv\RME^/md$*14UOxYQqlۤʈ.SɕaX)-80JYnhhT'5G!E|*%JdYJ3Y#dF oi ˊ혊W}}[ހcNjOʲUYW$jID\硔P:kiDIzH#Ga \65}I$Iv\q>mCʪGe͜Q_WY7u'|Tm(f4 fnأ*z,)/If47̀?`;$ɔHLگyTITw-Î".#F gؔFdK-p)}{V1btNҝMS}?u%8\NS5۱eE1 +o,;yWeɮ߯ja̰~a60SuAIL˴[Veb31u@˲b\Jd4~qcX qv&~ k;(HJhwu˯:#zY(뵘cXHhXRxg|W"h4ZVV-UHaA,+DGk{[ASa*\ֻw6ahɶ榼LkG{{4x"ѕKm-u={TVF*m]yks{U#H$Yr\&1: Y&Y3kHV6BPggJɵ5]oj`жmR=J,w]y<)QA D*0cC6v7w%Y+oYYszzM|qlLcS It ь4~clp6#~%ʜ5g?Zslϟx /)t~̰֙(MQSY9'uW~c])H`DPXkTwse1QB.;sՙi9B3KP4gY p>:qhZ[Z !-%ՆHf4ڳj=}=o빈H)=kRJ-JcCmT@1.wjP{j`oOoW6]|2%YRkT"3@ӭ^kC',fk8NBPسsᶡ޶B#%Dxր`Rk9uB궭Y-謪j*2)eDzyK²˒ffiPغΞv;S, )1p8+jNUECu3<J rǍyݝ~Tf0ZiJK@_yX(8I Aq^*-8*"L7̫/w&SS5wlW}}i2ƤRε*p[x٧>q8@[~[; .?c$H }{OF.v븎ȲRfư"fLs=w˛=/l e9zNR}ó !ryk/J}G}گ\)gf191O3>߻lQ|+7py,M5٩ٻ{vvl";񵯷[ Ͽ^}?/^?#ws'PG5 &^qEg&:pϽnݶ-o /|aԒEK/p*nZ{76ZΏR=tw\{}]}Z~W ^z?%Sv ]݇ALMOw} -egu۫6opc׎>'kd^x\VmT5{go]Yo,s+bhUO?tKK˲OLu ^}=}7xӼqY ^^뮻a_o߁OmAGWoG?Z04̳r\'SF)UkIt䧏<~×nw^ZxIR P)%R53J9sJBxI e 7PGg*,Wm?3VZyJԨnxvwqԹ#z;o돓8>@aQf 2i?Ͻa 5zVl_r{}Cu뮽'O_m_4KWZw95:I:Ӕ2cLp|3|71vz)g\j-dzڋVZ1q4K/R°5&&g&jo~"3$BuKk),&oLfG~$3V=`T(Cl`7ki-z3#FA+qE8FJEqݶvsX.ӂtxkRKA#him?_ɟp# h CPCPD>={ q)!7tӂî4Zxc=[W?sg:^s5؅\酫\-m޼iZ95o~[~Ư9ctczŁkB)%DhF{45hh@S8Hg}椓%?$ҥvc-:ɢV/^݂ށ.vO<4~hLhQOOvfq|ŅWۿy|+WsO3?}I_tF[ IDAT~_D7NNO"c?3=_Ka;gM7m߽ .}d5W_s%A`1PB\I$j6ГBWϿKUei朓WԈR)w sW}v%!L $j##O;;t'Qwvv~w%J%1wbP7#\di'me_&A0qY]x1cPuJZH_Yb>=OGYP^P)MwӼE%wӖOT ϽkN/Ѵx{zq,N(Ya죘8ZPf2d|_LJM/˕zhj2N{vE_3>ͯ<~9k^rE&$2I13=h6jR"t\S(1oVMu*HE]qzz{JRVF~LpS,*;P 4[q8ξl~+!՛ s cYgYpxX,Fc6fG5Pݯm}߸yቋ,cP"0->E.z]m]*uM*PRή.YRzT;{O>؁-jh?U78sʭfڬkRHFA!(|OJiENDVj:ŰP T"KKإvW\!gٲeoݶsלרU _Uz[/LBE͠ [m ao_7lx`yhkF) AUVN( ^* P2!4yʥrRզSF::;Z¤s c-:ajdxx+F&&65rC)(40gx°i~fOGoǣccSN9%JQg'S`w?wWnB6弑uF1=m6aK,Mӂ뻎`XivtaGs=J Rp̩u%el[e$Rr]Rx $I(Jbs}ǷRXTŖbwVPJEVaߥ_5S)EF I4hr)=KgB&Ye<)e/ozX,,H;::ǩV--/\{_|fTJQ*vE MҤ`dž8K<R$4M,s\UA׷lJA4/2VՎ,n-PB`l^u㸌3 4Q"P 1hg8<T8Rz"輟;U20diiJ+VxM/l p||nndqRA֖VyQkiiK$le.w(noo߽;NsW7~  FPF CCC{ugff <8cKP$ ÐJ#|onVMHmLbs.$,URR2I)w|ן-'Lei] aVJ(VZk|ĀC`4CRWG)\ zmri2JL,0@B4)Tk&)WI9y5לsV>3M䣃喖R1 /B2ʔRYw !|?HD"2G%-,[MNK)]Q3I.C4M[噙ㄐF.mj!zx^quB2;֜}ΓܲesP]Z֒(>SoM`î낁4Klw_ӗ]i2Њnnj3A" )%`n!?IԌ ~!RH#F4'y`@Hcq?jڴTZIe" ff{ѻn_2rQ;42r*d& a\F--R9JcfĀH3DtC Rj!=%Z* RK Mlڵccٟ$Kr0Al6QBHjݙåB)r4cjV(fk #\)pwv2p^gu=>wl/O?4!ٖ2eTeJkm\m]{5t %RPJh>Eɞ]{ Ͼ85=T(Toټe[ %Uc(3A e$HhPkMh&8Ý+?H*ȁP,N` pm8cH)q3Z| 7TJY[JVSPѴP~2iVfg}Ӑ FGF,B2?(( ["8`^l)-2c ԣfT4$/{֜pWңP20ZIJ4K,iFJJ5hTYd"y;28SB@ms.ӌ0B di(@ Q3d%^ym] kZn``nR¸)ΘG\fէoXٍ))'ڱh}D:0 0Mi}fjXڻoϏ~pg˖XQ=Zi)( s3%U>gͣf 8I~sÇ=x4|RM4#GպKau2#F:1G ь"et!,[מ{G;ng_{EI3" BA4LRFFk FCw[wK[zJ$J]ωR:NPkx9ǝZ"D!FXwg\=/e"1`#]Ԁ*^,R? \шB>fZ`=-TJ򩧜ܳ/ ο0ô A(^@B845#;+WL9C$IrZiPn:yhNX)E)۷{w922z9#sppJr1(xcGze!zONM+ 471yj q(])aҩGDIA489e}vCi?411Q(4ydž}jէA9]dH:[45*c6a[klRz"%5fQB~ ߻ktc&Iy`zPHսtGzx[qrjꟿ/iְ#.%E^pi?/R֯SJh-(!R ̝ƤFLJJ4D ɀdQ"4"FȄL$T.LPD 5l-@T xMwyf3뛼Gzp(aP.$0$H9 !:"V˗YeolN^3SFy܈/$JhY4Fܜ?```',Wx?qԌr\,zS@qVUSf^@A84l2wՊS7l+tږ/[Rjٶr'&HȔ=4atE Z'=15=lپ套^>|WRz_*ݻot_>wSZC9((Je|+&=n2h4lzηr%Qʐ99C@ 0Z*J(clvvv˛[vcλO=ULe"cP4c@PkԦY{s'7rTn;nRX4R&'#K|׶mqNKaA+݌|[1fzͣN!JAHe@3ʐ`EiJ#U>"I4j64e"9lFQ&H0WI5b*&p8|zzLT6<݃s,|NoI 9ɴȂq=D E e^22r;x[[>lR!x??_}g>?]tUKje'nxz/^zeZÜe˖wu)/,iƘC %)dl-};A]7ϟBl2>wA!RQ溿{kfw}g>{E_(A͢0/J`^{N@U"Z -O|0.J ")MLި*3Fǜ3Vں՟ޞWf:ERvwcAq7#uV@z~ɧ^z%XXj5Z/<<ݷkǮy= YG&`hu] B8MMRGWȄ28 \oaW݇Fk"]? DI|bdPQ2QjROXK걖Js卭 LN]u bJ\<;xzU'q]ȁLmhh(( GzssD r!"9]WL#A&'*3',Z( >aRc42Νo5 /XT&@mAM/K)L-{ZZG'vό;JV=]tuvsTe9 twu;ZA<U3MFwudCKt~tyРR 9:6Q(e.eW^TJZ?uyITop|f͇05~ ;wi4&SS(d R%L*|3%2i eD *5]$I)b1P(h7%jeyEBgJ BA"H|CDVHw\7N$I r)E"Q5ޖ(n(e8ڀu4^PB۶ei~$%4JsȄAcuҁ 5(d4Z b}T~E}@3R":V$c@I$NFsgh0㜹qLQD Q âҪ7sqB)(ICZ8up@?{|__`x!$  6qdv]$ y_iV8\וR5)61$纮&h4qP*ZJt*@iIQQ$ Zˑ4@"3R+(3;ރ_+Ï1#zR)[,[::[.'1%wΉ}ǐ~>Sw=`Z'>Tdh9\hᥙhYww4MP})r|ǰľ߄|!$ߡ2I2aEpd },@ v݂6pB>qSB׈j> hOE} , ZcZ!7q)1~H @Jqlr ȑL&~Y>|y4 cyjTZGQD)R$2(!f1erep\8dkJ+vq"h4 ժR|۽{1 >75̾3*o8Hpbrp/=~'q8T{tϡFiPa#n:ԣ; c4Κ3i51AFgF*ɐJ2S+9" hw^yUEqTVbTd$CÕ IDATqYw7w|/hC#I$n" ZѨA9Lnãv,:VX>.Llpp}=_p~"F6=;30805==qh?m:ĥrqء{BgZɑ*y+STXCP ݗsXZm4]tO{p% 62o`k{~ﻇ,Y$۸/~ XiBl1oK.%X3 /hFaz掳g>8<59:6b J빧>/~7??3n!"\KQ螞b|T1aQ?z`X(0B㐌<#o;:~qى ;ڻi2ZsBGm͑rg (%LWGWXV*o̲!g9:2z={{(n֛OC sל{%ut'Y&AI) Pe215Ru/a04h صcӦMbMIp^^t=W6l͛+XJ޷h4 eƥQdY_b|bX&T',Z^}i-NY) 0bbG_ ܹkیA0` "F Z#(І*M1PA퍵X,~%\/ {o/<[sdh `*Skľ[,G+X,bbX,Va-*bXX,UXb kX,Va-(,vdbDa# F_Z, B jm4fΪ?K` 0Ę9F@cM-q"NCa@!3gAİhmWt hA}-x 29ƃPX|vXbk3'8g=^+]G)=rb9 #\RHEޯ?,9&7FK6bG܄b!ECa0Th umX6gū y_BaC"K R2E* !h.r<(,GZc Ej4m(A5 @d7a͜ #\ 0FQ; 6kXFB)RRyC=C¾Co=ו&ZiFH=1H@iq8BQ2( }[6l,{8לqXd@P‘X,9H3Rk$Er 0Du};F9 4R)ДPMX,=A`ȄPFJ JH֜uh( AfC(8b!RR %G kJǴhAnM`C qb|%sMcpcPB SXB0=+P\&bw'0pOZ,?Z,UXb kX,bbXZ,*bXX,b kX,Va-*bXZ,UXb kX,bbX/د}BȜRۍ  W߂sEFkmZ#"c̞r(l.i9%=zౢ5kXrq;*ƘcC7KJ=+a~L {Yb9_OU?6< kbg!ߴUk(,Yr|pKRA弝 wC3Q l6:P(0ƴVa-q%GjUrT>tA)UJlOb9>hZ CAU^a(0 cB!!fc-R*Ͻ*8r98=!,?,*Jb9眿bX>-m%/Dt'˲c'C6o +"J)bn k^:Zʃ=+lab1,!~ iK~/]i?챏4Z,J)qy%?ǫ$EX,wfV@ؾ!abXZ,Ǎ0 1o6w\G5|=bXs r1F4uA z̵R` JL#Q 7 ՜ AqmnX> kDN0AjJ " s7@t.@ 0PAh(% pP@@#H%b( 婗8IZ4!+f$ $gs$-,q~'PXdCDsQ< S~xLbIs\Qmh$j$Xth:K@u6z! PhZ,2訨:]c>b+2Cq'2%pU4)sZLbDX1)RDbʔpâ6ш|#zPSM$N$IP%IJ&X Q!JGjjR)EQ8J444{^4ceJG{<ǑQ'M,˪mtHmiȌd2iY$ISJZ4MP8h,8 B(G)hb1 1>xqyaȲ,^c q_85e&IRm1~"'>C:Q(jjjRֶm|СVg>3 QHu]q%hp]c=ki:$) >D"8N].!4M|X NAPT*%NgYZ1v]H${`0].N^1˶m/$!O0 cxDuc~UAMp8p8phCks82"?: Z@oÈey~?G_ETʝ,ܲ N-q*)\hJ59Ģ'İ%9Nt[l6lsŢpJb,Esz ׈C" &TbdӦmhh8t޽{hQQZr^>ѣG2TU5lFIs!dΜ9+V9rChm~Ν5558x`]]]mmm2 avmգGFHgm&=F@`~D BY%IZr;iҤT*%[4uݭk׮onn޳gOcccKK֭[cXmm-&뮻.1"J}x'I(#^UU]fM{{{VVmv7lr|>_2t\dR$$)JoϞ={999`C !G{ۼyo~ 6UUU jmmݾ}{uuu}}quuu999sUU1v0hdɒ|pĉYYY`pƌmmm#FȔeYTCO6mjjjc 裏瞱c۶ׯ_?{hdQ5@i@.iN?ZŶmUU>^oذO?UV|. (5Rq B ܨ>5M3 W,fvu]njE$AAAj+4/7B.E.I"D)! mĔfXr.e--Q#,KD-L1N)g3ْ4]l&Q9RN !/ŋ"6H$MMM_xan7tʕgAٶm'   2$ ٶ+D" EG4A9&;TTT<3:uY|O>d?qW[g͚u駻\.<> llK$pXH$bfkk+1[ssm֫W/] 8UUUwqG<7 #Vc߆ >nw2t8I85  [pYo_\\AdYxVVtLTfv㏧N +qMEc$1۶Rf9IC'ۃ۪*jX 8tI1&QN9ZQلSʈlN,1R)we˖.[nq8_|YgURRz0,((4a`q\ s!b{キk׮pԖe _ri1t:u]1[ZZ~,bnndR@ l*r8#G;}>(uuuw}ӁXsh4 ׋A3Dv$ m Zx;ukjj:x;vcر><ATUBj, ~[C1 #wL&SN1bN9oooٳax{СD"Ŷ`0b/K/~M6׿期tIW^y_~-p{5sӧO4O>h.&AȄkXbRmSÖd[ܯ9ے%(] 6d'[V>PGRj[̶!pB9&-MsɦP-fsbbjJPvX{ӥۭW_}7x饗|>ߞ={nQFɲ|r_[[?pgm߾n8ǎ{B@#DlO>}EݨfƌÆ 뮻=Cҗ_~'p:>rA@x6O(Z"T t 5ćƙ3gX"??Ͼ馛v}w5J=\4v۶[TTe˖'m۶eeep K,ٿYYYCC 6r-i#۷_~zkn(؎SL]RԠ1AUU BG@* +~{KKK^LH;묳L `1 y\DXi͔ҁN4駟ޱc1 ~2P(p88@1vx/~BYbœO>1BQٳgRaÆ$޽?v;dȐ?ϠbF'pd;>h߾}fBݲeK]]ի !'t֭[EٵkK/cǎ:,z*r'ׯi|>4&LЫW/Bȃ>hѢ;gϞc^nnnnnX5 N'!*XDM*Viǘe)ZDNPjْJ\2!]}%OǜT] M3eSOŢNn3F8SMX6'ܦqAԖ-hLQ{hUO_Y/**O>yg9s̙3g֬Y-"t}ذa+Vؿ?;wgyx{=o޼wuWYY4Y8QN$I[D%KLӟOO6mժU`;v[WWa˲t1Z[nݷo}PăhSN9߆ザXmSB(!Qb;,"$+.qKIf2嶪&D#NqoD Opqxp8))68ʩe%BMM(rF\xq.]JJJ@dlܸq7x~YYȿ޵kWcW^H0{mݺe˖_iiiNNc,L23fQEQQ[[[[[;rK u8B$!oa 19a2RN)961MS 74hR%o~WNA^ČsȗKmX,͝;WD"m۶={\YYY)ZեKDB|>blii1cF$y zd2crM>=//beY}7A 1nݺ |t:.\6:f̘7xc„ e꫗_~ynn.!d.kȐ!`D"`O8Q8^v-#F"!8#HT*--7n-5kTWWkiiikk;.ѯ_?Xvnj^zAL$e[6mjkk;x`KK˘1c52O)ѥ^ګW/ ,+B!(q?$iѢEMMMƍs9Ν[__ߣG@ p4&L~=''GӴZh!vCAra۝Jrrrr鞵HZ8EFcʟ5k֬.hmm=zUW]UZZ /pt0Y/2hzإGMѻo֚N8aرPѤݻRO ,%sjsj(JH0RƩLWUTUaA (a==xĘ_uuuAA?'|~:[A`O-x^:t[n$M2OS]]]]]mۧz*ƘFHㅗs<s/ٻw/Tj̙ݺu9HDpl-8Hmii;J> ^ouu"6z9sVVVN2e˖{'9rРAÇ*zǜرcGpxhA?]#ܿ(ʒ%KEy'P(qc?"`@ispTB+d)Oa˕H$ |wHkq\"Snҥ˴iӠH{lPQ 4044Ϳwq̙3;wx׉V8͉I gJ'RB1 +e'pJLbWE,[aovQN48~}݇Xk 0FJUՇ~W^,Y/~e^^8j&cRT,z+ 1++ :~zzҤIeeeˆL&AX0>EӧC=%rkk+ c,Y(MCC?p@~䢥)J7_^^rJ۶{0`믾*5,YgϞ^{ ŋ|>e)dҶm,C&7բ}$I(i >jذaX #gggXb5;Z)p8@1X ( Smz1 Yl%pd۶h4s!a?}_WCk"Q|H87x`]ׯ /QX,F1qal/fNmB9ڄqF8F(6RL4Y2aD։n?ھrG-\"&?zv/It:) 6FÇ RRRm۶;㡇G'5 #`bm;L:!C\k޾zj@ ӟ_$i֬YSW_]nx"wnn.[0H$t:`m1ã #iHwElNJKK_zQFA)HѹSC}Wt]~ҭ[^{`0??2ΡCڐe_{1n 1g`e~*IK\owudG+++xLKTnuLҥ H@i[[[m !U4DGҥAۻv!뜜Z M0 3 ф(1:Ĥ Z 9%ɒ`w:GZ؄Ҏa8%IE,N-I %(>ֵdG6ͧ8ZGWX[[Di<P(.V(m(J$]tnݺ Zp-ˊb"Fs755a's9GҥK/_~饗_"fH!6lX:iҤN:͙3j޽O<Ă #~ppBҋQn("KӴh4 5vuY˗/Wkkkincf1ƺu={Dyޒʮ]kjjVXqС~ollI# qHĶx<:8Xp8W__on޼yٳgқZZDjnn^vm.]cРAd?hj0rrr+V~3g:th>} 5kܹsƍ7xҥK~ geewaC.%.ve999Xfye˖E"w}O>3f `+p$O?}QB\&uV:كeO=TqqqϞ=EP(DžF'V'g8,!69W&Ti,ŒQ3ªC+7}RݸTLoaH&ոN$2q0;;kD"4,VI<O޽1a\\DND"1~ɓ'[gqF~~>P !]uί!lz}9sO< x<~ 7q6cĉpޥKYf͚5kڴiTW^A HM_b"`޽;RBw)''FQQN2%Hbye]&rNh/--Œݻ woС;v>|8"HLB:w, a%1\<\>8//Cԯjل#Fs=sb.))9煅XZ`c֭)S be>}ƍWQQWRR〸v駟FMMMY0?zkawyrs='f@ ~!US+'j =!=9Z,S7n܋/xg wyzK.hń  u}߲5k֬\N bbXeD}VV֡Crssg͚ճgOck֬)++C'N!,9^KJuDm-[B[yvgiu[Uj[iz*A=& [ΚITkT}9Ҭ&sJdi33p&[nd ObYON(:%2푶`[QQ C@~(!]bfl$DQD9 z^!acy߾}ŲW9tԩֆ Ttt4/gyfРA<ؾ}P 8=YA]EQ<߿.]MRUUչsg5(:rNgAA48uJiqqhp8ؘ(c pYYY^^^KK XP(ԩS'~3+**.^xaРA߿[n;ZrPIe),,,,,t\@YdMMM%ώBY=z􀻍H&怺:JiAAi@`5??!;;;///@QAAP(]N&B uSSSvv6xRyO>'܃O"k׮_Wf3f Vm$zC,/EVWW@49"@b텅|1|Ѝ򲳳*F}>ߙgyUWs9=7,hvԽC^XX(+ckn(M)Ge(e.ܕ ƽUc+đ) .6@lJ88#!Īҽ{w8R=zw#`(dr-Pn#z<,$77OȸFϳm 0aFcccvvvΝECKicc-2dȐ~ULD4Mǃ%3*b+v1  %OUVVO(c |R(x`lJ0t:hL\qLUUQm&tDqpAkR@&={F\,ͨKеk@ y|-]m5DXa2(=z@ s8apYDBh9|MTPP{D=)r DvZ=QeFPs%_d h)0ұJ)Ji1BQQ*4 XqP(G~pD~ zx-F-esX:OẏZWeEI R&ߖ8)=5-Ḱqpɢ dBBFi:3M'h8>~`jb&kjAqy^ӌ3@@(/F )"zQt͊U=tlc$f9$E Gb8khB6 o\E4=1YyMi0=nplX$-sl !Bˋbcb!@"x< @m0TQE+xh4+IJ𴴴x<DhTc2,7߄w{#:!dʔ)UUU<~;0qXY <;`w!bp"&%`r7nX__NIwF%PJ!ؽ8PrNm.Qo˶L&&1m(lLƙlSfM-2o2?- ` * =IPbaĂ"QT `χ" 1$Qz ' E\:DL9آDϢ>$dј !̨i1, LIDgҥW_daχBfP1FDId2zE nb*'t0dx2N" S@A蜜8N$Oҋ:H{, bmag,c!pXbvX_aU+/3`prq$da3`8/T 8PoMC R hfl2-E=qDe*:V/@pXz Vv֗xJ~e,c;nCQZkԟscMDKw\F9%'<Yy:)Fe]Xƾʥǯ@we1o 1?qGGe ;ڄPpNG_JM8r? #""> MR1w[X2#6e"&!)vȰb'0VG0SN)0"+z.N9%KݨeY鋑BuP}J0YT. u4ME"m۩Tp8 i۶V#EN`B9T =Ӳ,B*3&%lܽoW$dLnN2#ttB"Y)ʊmUv =_AXToXt $0$IB9Nx%("EuxLuUUqa}-Dt: !TJU!:Ȳk2 8碛}K2M0 YeYVUU2f.OԮ] 'd̄ĈM ve6Jmjs%q-e8eJp[U(bQN v9lXz)3|qv/pou}*˝Dɢ.K`-W=&slmլ1v߼DXNBѴ{OU'<.TNlnS&ܦ_M(%6!Omni%;Nb2Tm]SY<V"EG#4:4\L30=NYFAtIpm}DMDp夣ZLӔ$ W(7ީj`M;FPةz 7%KS@$S~x+'F(;(('3cKME=&9J bhD&pN\A]ӋႶKTGdx2 CQ\rxeYB0۶Ŭ;E Ȳ=k;bBaa.iV2iPb%m=i6J)%r).00,")#$M,F "a)a4!*n? MF5Mf]cY$/5VQ۶$KЦz,sv}+͈ղP(p84M|Yu]7MtRJ$+t{ιa~=QBm[.N̄m]<_38͉I9dfJܴM8mnZ2e96-Eb2JdE%DDFeB\x+xX,ςuu]_~eˢ(f# ۶>?<}J0!6\x_gLJwo q~Z`D #-|0??NR[bώEM>/YM|F\x@d2 +M-ݿҥK .nሣ2hqߥ> !. kv<_t돞{h:q.ѰG#sH$;vx7ŋoڴ)LnωuKw2u5Smꄚ2)aKȜY'J23ip1 3uqI3nəEL"Q*QL%#*I8λ\RU`0XTT ֧OכL&{xD^uǃYft8L\D"OΛ7o׮]NvuY'tYl c T_~ѢEhIM4Mu=;^ZZZ㏏?~ر!D cu8xFX bQU5Lz<47oO2o߾m۶Kcǎ=p_N$W_}5X4줲5jSO=hذabG7~+ GʫMjX,J)SS(*DLY y?4Z/ɬ]瞫bsνkyt89zeYD"(>/H`hٶiX,vMJ#:t(TWW۷Oȣ>OedANUp8 p@O*K1֭[7;ǯʥPJ,X0sJ)P IP(PteYh8{4?m$v;N ӣɤih4MX*–>H9NHMRP(K/}gT+7mEq8Lxhkv~~>t 4#HtAYc GH$Ng8]ض X||/t0Ea y͕H$\.$I8a---F- B555$)??Rt:5MC˨Y\r3 <_k /Gajkk[~>;|19sDQ˥:x0@ `p hI~˚ZRBgԦS#[2%;EuDF}CsKW$ߴׯwߝ'IҺuo&M~ GQ漼<۶A|4M 9 $ |>۶@:m;++;ܹs:M$ AN'c,++xa:jC4Md/y^E1 wߝ8qbUUҥKnj#Rv)kTJQ `vF^p8xc߼y3!dĉXsΦi\.4~eܹsӦMv[YYً/x7p_4 SO}W\9?~^q:\p p߾}:OceUUU͘1ôܼhѢ۷B38K.1=֭[c ^ve.+SJ\Ok8pKZSSO;3τa.f/z%˖-3 c„ SL朻\.XPx1ŠCdH&yyyzw. & ;,^MÇ{ݺuaguҥ0رqÆ 'Nt:7o.))߿?ğw}wUUU?ذap)~˾-*`PΩMg0g-Imԓ2ntZ'wҁE.eSفq^ʊbp׭[xbIH7|o{:w1J0<SO=SO3zQJ~?8`45 ' xM7}sgJ~޽ްa… EX{{s=sNw߂ 6v?pٲeHD41? mKfgg|ɃniiYbd"0MO>;w[o7.//_l<ӧ/YSN袋c7pÊ+!HzήoiӦ^z͛7^UUuWTVV}^xun&DN*++{|p֬Ya@H<࢚bŊX,ֿ U,X0cƌ^{ ‚?CJi,K&wu3<ӿK.s΀ua SNݼysq^̵pUUvV\N6mɒ%Æ <ҥK .0d2t!ax<?pڴi---ƍC=p|ŋq+O|f͚ JѨQwABȡCJ9c<OGfϞ=7ti\pvoܸ1߲Ji?3N%IuY7lRHzV>!9f)yS$f{uC:.O$|>@kii4Yp8!dY馛z 4}ڲ2Yǫ:o޼[n38&c9Ӟx≪^zQJ?+BUŋ{o/bY//.Խ$P(znݺuܙ16lذEuY`ѩS5k֜~F*,,ܷoڵklrSJΝsο/ƍ_$7oѣKJJ5k֌=z @_vO:uÆ P'.]*Ic=C:tilٲ+G0DPဂ >u=Hܹn;3pnnn8v{=ofá*0Xw˗|͓'O6MN޳gO,i n~ J|Ӊ9`0K,QFA*,,DhPXEE]wz-EQm۶e˖[nٲeǎD˙@~kkkTsΧrʧ~vꩧGO?o߾j]9\ݻMh4 ʃd4M$6,B***6nxE%ɂoݺuIcXlgqFnn.vVVVkk+y֭={>|8#G9v2Oݻyn[8drƍÆ FH //,Jeggs=;wz@ ʩۻ(+|ٗbSڂC%iڪ-?Pi MQQiTV}IUQTj#ɏuBKCSp1y}p B9Bhmssa۶ʕ+oVTT@91⾾>4W^97 4ͥK%YJYQQ8H(x׏ .@΀F߲eKYY4L&R~tWWъO&iZj͚5 +**LauȑT6Ɍ+4m;X,c%Kpgg};SRRڪ.]=uԱcǤ[Y7{a`䲩q[99wl-*6J1,O89:*R>+ !~rd2jժ7adi*%·nZRRrL`%&6mۦiWՅڷB 8tҥKXQQ<֬Ystt̙3_ׁ2˲ NReeepɞ~8$\|~+Wļ,WRRRWWwYu~mc&鱱R|, ،744;w!8;|B*@$#t@T"d2L&'fؼ1ߴiӃ>?ϓO>OFT*5mI-т ֌|>_TTd?FR& )++Sw̶ym߾矟p4͊SNz!F_۶z!0gyСC'Nmmm\ڹs]{ᢢ"^ $&%eBӸmیj.~ʔOT8~aQj-Y>2zl`x #3pY:d* }_D"~?.Z(TZ쳳ׯ?pO?eT%Ay#ЩdZH$RC+++F}k_Ca @:FGdRLxO5|_1QQ^^~%`0xD"1ںq|;~kk 5,VDG‰X&GFFV\ /tuuC  ZB`ncccwL&"tN:-[lΝ-Xcbiڵk6lxꩧ.\HD7}dR8 FKtlc5 C "C<"|NKA.ͦK R""YǟBx{l0D4ֶŋwttw;w<Q )pMM |@\.d_!Dmm-[g;`U###Oǩg!]98 qЯJ!B ,>#===7o~sᓕ KM N,ϳ H^aahhhr@yla* 9 /̟?Ddee2&pS^^*:>U]n]vj\NN|!?7WPn`ءY9!-C'"BT=Ug @B͛7{;vª.8a 86eR)L(ʕ+y睽{]O޽{ڵk!կ~uϞ=gϞ]vJFv N:66kt:H$FF(W!8W3$IDAT V_~yFt]ǎ{[om6χ |>?11>66qK H$3SsbHZ_z%(TZy[oBFoH[[J?,K]g!%'E[LL66qb.3J%)J~Sﺚu ç[ .WED00UvII߅)8N$~>C/}R j_NBSBf3L(Zz+SO/|aϞ=?>}z۶mpmڵ78ׯb߸qc__c=|& KD"UUUA5HUIJa裏"҅1Pqp k#{?OۯO~򓎎,TxLmmmwݽvZ` ?ݻϟ?ݻw֭]]]J+]P6&  ~|+_ rwYVV N ;;voWϥ0p8pBXn/iӦ}iFgTNYn?}߿w~8]r;珼X H94x!CD9eΙc ۼ0T5$1q˲]w݅X]]}K,W .ċ/nnnv]… *_WWwwc\(=&ضDt:=<<<<<\__u֎e\.{}q;qA˲>?x< !(<žU6q]7 !VX1|"qP(d|󆆆8zߗ.]ʵK144tHG;;;Ul6;00J,Xpm !bXkk+(|f<nŊtȑ#ϟ_p?~wb˗cњ6}[hQUUu=#VRQqV(xii0'ذaDcL uww/^8/[,:ޮ"`MMMW쇭O X|9N dvuu 8q"H[[n2J1Fikfuue52qux|ǎ@ rd6vttϐRթ|+*JLxiHI2Oc٣oV-rBcR#B8Uʶma_O9uȑ\.\.$I'n:%q%Ӳ<}uBJM\0F|[/V9KN@x9lTXl6[]] 'я~4M.Xlҷa]*D²,?WJȪ OH$1K>G9s8AB K˷_:|F}!g?Of#;JLF?r,#Υ ]> FIII%IA5~#o 92l&F$|~.#A3R, 暁BxJЖr9h```ppw}g{X,ea#0d@_Zf`[dR jT1)$y6"M+ ]m}x= 4:0.m-`tBrg Geelx铝a$(V< ,' ]srθdIΤ* Vá c!qIe$)3 $IL2/f6o9 jmt/۲e б"sn.= lhbNUEfĵ_iqs%FDW\7eY*ٶ= cǠ*Xz "C&4&5.&|ǐLH&\.4FqH~N6ƈؤ# $˄ 9cBC&Kޠ=oBR c^iӦQSSj@q.)B9a AȪJ { dUբ>S59֋ʛ'ĢLH&www$wVirC~w󂻂9 \b.#Wr!I$1 IbUb疠,,q=$6eބB@ <iQx[hTq2yߨUӕZ9p[A4'^UwT7ӌSxP&K7I% .#"r\K.IfK&p Mk\F:,-ħZY֡䚔$Ɖ K$~s&[yfx=sF~_[PY#"BPҴ:P@5V Jb~Az4O b{׳9ܘjr^&w@VK7'2tx y;5vWA" I$!IXv麔2kH#N,KI) "&I[N 74j"817'&n^QPmBBn"ɬSJi5 z@k4Mjk-d$_×nWb [aNZUQU}MxnlY_XB%8Hιp1OzS+q6I+`R#KaP*y5U0073Q*̏45Eɺ2rS I2X!pAwkw9np HP}zg}#K'r $sƈ AR͈ jKF8Xdٶ74z,gy BX"4O$I۵qCj2MDLJ+!Fd1:54%y'aqNNm;ǙL8`cl KD$ƈ!?t.`ĉ W0m*"A[xrOg]݄dNH'ɤ׈H04" dfUӈt"HcLSR2 ͟H$˨$DD )D* ʲxg}4|XR*O%7ΠVɗ˃MiQ45STkxH!_>8g}tX" iH2)'?s?bؗ*큒NOG^9*@rT=x3>J,Nu}bbBǁ3U#DYH/mFatQ b 'p8o߾_]]neUJyfE8**Y~pS޽{|MՖ\gyI-4Q5=IENDB`Mopidy-2.0.0/docs/ext/musicbox_webclient.png0000664000175000017500000024344512446377410021327 0ustar jodaljodal00000000000000PNG  IHDRXM IDATxwtTa)"" P.\VPNzBzwޥ*]:b5u-H dֳef9)*U|{"""""""""5Ty*WͻV0t}~XtIAKe<&Gg']DDDDDD$Q'""""""""P'bs~cNiydz];ﶙbxow}""""""C5 ݊!zTۭfe>y*݀cֲt7|ۯX~}"O/X&):Z#,Rz27,L:_na6Tl.MDeP_M Tux,}""bҘKVBk'}s:|: [987>ԮTқ^o3)_z_*9_/;t=5o <wx@[ps`Ms/""Xmy$ ~=,*αlEBfu/{˾4\_BLp\`ʉ8+j6ajho^.4 ˞'\uiy4Qi]A0z3s̩] m8_r&kr#Uuu2R=q}ޘ)KVmil""bҚKlċiI{}_zi%;K0iVƛs#\ >븓hSs%U]fcJ|v}5][¬ G8qΗ|e:sHXĩާ12c!^ ِ̿nnjKk@K-x}v|9(5ƽcSO㭓2"""lƨxMy&{fkzmeY_Rgkaq_q-\87ې7۝j.9p|:?Ԭ'm-#nCtU~?p ڝ8Kɯ0bk< >z[-OTvL1~}yyu@8|mB;JĵLoD?Qi ֹ mz9 d.LyFW/}ׅ@. {uG[an^ bx%ZW;eDDDˏnqߠC]7iҲR:|t)'N/蛬/wdowٜѧ3!~]uwsz\քZN@36r._N-0`W,F>yx)˾ߨQ}*t;bc.\ʉt;mՒwϼJle >?6^M6M_-qw -i3v|߂fe/9ɄvUoRVfK*βf0{wO:M[Iߊ/2Co'˾TnÈ$Wl`&ʾ_˾՝Ai0e ~G>yhckdML}j8:D~y~[,-~;ÍO=%+iZv?rL9B`_MJg.DmkSƬ)撕;<pa.~9~ͤ_ԙnCkd\vʾ-""bcΒ=/Qp{e_m*p x[l6{v}/mNR6q_i &81$ ppclM̔}ub9 b?ӧaS+Axߔ?n=rj~ %, sG2ΈC%5p[m'Mݹdm&^7/et?;͐C$pu˙4w\fnc*U× X:1?oT+oGe* &QVN]$ߣ)צwxyh(((XWTCSEQEQEQźOD((((}"EQEQEQE((((uEe<4EQEQEQEQ+*DTbxPEQEQEQźO4Jlj*CL{dQEQEQEQźOIy1 Th((((uEe\1ݏ>K'"bsRDDDv=ylZD>KGBB$""""O}"M8‚Dsv[qL0^pDe*}""""+g}3&\fݩ|3K# YuÀ{׹e)훗2<\7 ]ڈ< q>Ϯh?KODl>ەʾLjSLHB?oXʾr>3/ ex1+ܩݥC*e)\sOkITR'"""br\w;ۋ[ױ:-Mqdɼy6laWvd;N *$+e .:ϢY}"bTخ]8 ǏR*71"go_wqҦtvɎcbi>m#nqqw6fu@ Osɶ[e&#woDl4BG$]; ~~=e:,>U*DDDD(.')ԙ: c8:ON.)U(>MW|".YK楧3EyT1J+L1!2pd!惡/sD5}O/bnӒ[ODDDhH&M淩1}TZ{~D2)|>CRx\^*^tB)Cܹ);7&oy?PyKEt~Νr7WtǔʾUTߵ/nǣ`t۔Iԝ>ӧx,:wX)sz /,\JBP핷xW)bJ=WWK /P3Ηʽ@ަxBT|5}E~|'wtǔʾ{s3'(# '잃N_ߠD-/pn[ʍ˾ ;|Xx߂ a<қZ79eDWٝt;\MXQ~؜u۴$*DVy﷚O=s-/F`AvOɲmZ}"bT<ϿE+;xt̙ʷZS❷)Ryʗ% R|Q5>|2^)U/*˿oVB ͛Boٺ{'Μή.#W'Ⱦ7=E[q76Ni|s" [8g[55u_M¦bc5G ʾ1Ko/tDRq>ݓƑSxsfF;%ɸj F&k1E=!!r+?QT<e+`RTqFSF5k y%tg)V%/+«/De)K_q5>s/oHg]4p :!~Ύ[)˾9 fɁT</>Uw_*OSP^(ERSt)-_JP(bYʟE +On,@y)P0 'W\(4rIV{61 rZ$ 2w -rF߾C|7Yʾؓhe8qNNG%;u7{G600 p/4tްýxv"!!utɲe#hk9hgPz88nKBBNgCG ;BW}"T<U.{_L”)WRe Sd!JxOTx/^%t^ K<(?/U_&W\Γ%J7o^^y Ǖh_Zd,Qq p:Tmʗ&Dy"ƒdx; \7E=s5x gGwg&?/:ucOy;}`.:6r*8LskZMDGrnH4QĞ]Mw" ػODDDQ+rBT(UW{7KdG7`Lwt>T˾b 6q:6sjVu*,MO N΋ۉHڞ>yH*DDDD QX~ jas` {c=UBh|Γug3%+\ܼY4 ᙧ P ƻ5ho|^ 6|cR'"V͒M2!LaPV`HY- Î63칐l`4O`eB= à=)IMhn' '""${ss<_$/Re\hŮsܰ-%sBxRT-R R`y1%(;_#;?S"S'Z{P1yMwL*DĪY˾(άG9p!A#\&!2{9bs:"Kt:G~ܱ{/ ٌ;NK\D{7mxd ShЂa]&df<8}>`Nmj>l|CvG}{[G?WhĄx>éo8cl"""OgX(7_}.mȀz٢9 {ex`ժQ՗]9<4)/'=ylڙ8ӢoxV7>ۯIwL*DĪY ˒ilmK7!|DZ;0q}; vYy m;.~/!!p n厃=N-7cgcI>4r0ל3C}"TYwMa`wd IDAT,jow2|l/g=A?m1Tz4 W o֖˱.k}϶=Yݭ/lc:/Eg C=1|*/1fI4^',Bmf6w00 G50B8etnB={pQ;rg+G׊c$/BijZ3`N :2l.BS;h R'>w_Iwx z}0gxՐEKVe8wv{gѨԷ70Oeop1mgyXuPPt7oٟH09Q|LofUaOGf"""B(T(=W&Kx)f~5^*`o33oa+#0a7t'sSُ^Y?uG7嗚?PSIeX5KODlU֔} f{kZF7LVngB3p1L;[?V SUO~;1l8u -jh`45#HQ]7IoFÐhw ~!) ea7Qmv^927 w5 2c{1 9f _q0p'˻9bތ)d3Z7ds@5ߕ0h4p<NY4nI.o\<[0/UKߥVWU.i[~(/<=KO.n0`vJVё^ԙ~}.-KާtIeX5KODlU֔}qcc^'c2v$vٰ⃉G% 60{h(Ww궔ݑt!0h9c/ALj†Sg6auGMSh;i;]Xt $1la`x'˺8`Xp C˺`4 ignXܷ_MLl~iY7nh\U+g/H^/M'Ѿ%Ʈ5]ܻ1x6؈]x|VtǤOD%Q'"\0&eh|&/d?AKGþ =GNa[mafg2>IC| n:rYUe< wjIv0 ha:tPu0|m 52د&R/r"""bBT-]~^yl/TW*@Ͳ( X.Ljيv?BM9Ï?~nƍ^Өk**?S81fITz\78|==+8t $ʡa-f7X0 ¶AX o~{nJHҺC3aeGLh>}!.ir]}S;i#Z1k!Xw]Xv(SՄX9wͺqH烊Q7W\zmTx 7*o4Kz!`nycçݸ+6{:{tW^ttcк;_U$1fITzlw cı !| 07hЋGѯkMC,axomptlBa?qowm=pu~u2QefL fO5 IEY<$Pb I[YOH[DDD*]/wѡ':zjϫp9ɉuk9᳁+3cg?^?;Hֽ:553WG!k֗n]i؞..ցޮ>jDeتVLax9K)'6&LthKc:^ʺmSJppX:7P2s4ݽF%\ywΒLlC9y2=Ouiړ &ʾd}?HiϢvAi{S6ė9o;iW!W3y_4Gq&A]&жp;6y㛟y8+6$:AtH!{\#tQ \ 0q5'"t?&6?jr6@gtqJwnlTVcR'"V͒[eQ'""""6l7gNx;JZXfxran7u2gK#6,GM\>Jd)d XÐCԙ]iW_tXʾJk#"0,>U*DDDD֭]V|Tۃo%߾+{k&#܌8A|T8^Jh81aD;EtibMޕk0' xB7JOts˧`11fIDDlVfNfYDDD$'V\s{6:e~u?qc2n_:J\;s+FhCpM{J_[p1ߥ1{#K^t]e\c\ВfItd&}""""Nje5g$7yx'u.N-6‚ ;Lq.py vgޥ+ 4hK}Xǜ 0.z,01fITR'"""xtl>SWql {| Ճ?#8rq~P?3(?9D`ra!-Zɴ>4sn_n^ %daWZ|tǤOD%Q'"Jeٗ[r>>voW`ϲB!?x? OKJQ. . Wb-VA݀Dr,bѠ%tMÚ-pN}"Y}"bT< 8K’U^aߛ yEU?z?pzrbjDe*}""""KeX5KODl>ۥOD%Q'"JeR'"V͒[ODDDvfITR'"""bTU$*DV]*DĪY̕}1\ Čmrgv Q1=KDb=W!.stǡ.` v=ag}Fyq^w%H6S'"""bTH٭9dśO[˾"N;DD4 \ XǤtTa-gJLD|ꮴDÎIe#ODDDvR*Y}Kauih<#ɲ/ИT'bT.}"o**cʾCkÊ3Х Ƴ|Mlw;{Xū/k6NgcW 7ڌZX ?0A-wa B|yΞ=Ylm{G600 p}]NVG, Q\; 1\h{\U$$e*DĪY˾.뀝#ʺ y <\K!|[oh⮚3q-\Pu2 ɨnO'}skZMDGrnH4QwSYy,:lDd 'WՓQWHHgGwg&;/AW#DZ#j$n3. ̠>}"""",}5\{uoņsdJz7"Oo:GݓY\Ӳ[ʾ:f\ݳLئN8ӱ1\=z}X)NέTW9:ν|ZV9`nԀk圚3a]u}n޻\!&zVٗ=ĝbU;gگ>M}e_4g}yo}ʾ3^ǖ 9M+r>ە#ʾߴbĶ!,;֬dʬ, &ۓkXA1"ko/V[%ێUƛɹ ӫ5 3 VSE-EFN8xuaPS+8%NgzT 7/FL`*{d_ k:8v)b+" +Eٗ>N{ۉ>U$Q'"""bW${\Ʋ9 :{o]H$ޠ{|1xM 4ׅ;p-p.8'}Iq4r氘Tʭ_ . K9ƒdJo=㘖ı %O}"I,*}iOd> ˄/ew3SG.;j`-"""8Y}48˘zF*×} b-.}}׃uqAtm. +ۜuÃX6%!UѾƪП݆jY}KaٗͥݣhPr.*HŽfT,1]87k1ĆbwZ-dl ׂ7$Q\Hc쾒Xٹgu⣉XLwWD셍l'%."s<20dyw\K9M܃;ۺ>N;ŵ)4vh 0.f ]O$!!}G1}{F?ßMiiV^աFsDž%*U=.2\IW19{-Sf-cH&F6,2l/kD,Q/o<؇ʊN|E۷$}$$D濄Q]bg`g{_}_eF ' g)1\9۸`gW'&n=ELBbAXރ;l܌r%>h2;v8yߌmIG" zm?|7y_ɷ>N싍>4r0ל3C}" 9쳈>L&/~Igk 7_z9 U-2U}ʇ(֑i4&;#e_ezH>1?5pm_ɲ[̕}Yv""ʾ@wcIM^z 0 guCkg7ɬV.|jnaVvŹr0B; )ğU;ܖ|S?޲/d¡ G c?YyPV^rd_8ӽ2sdϓϥ=~4/sFXЌ~u۷$*DVeG;Y Ks{W?ީMqn&LG z=Yu$SAtr,_B}Y +Ϫ^x]H vO'O/K`hGN'>""""˾ڼ< 2ڭnED~ێw&Kǝ zlɴ؆jdzCƛ"7,+-Iv}""}G ǦS|s@c8ʱhq yoQB{ Ɣ g_$$Зe5t iMlS97s 6 rǥjZ]DDDAX}W my8jؐxVT^*@vK?auH:Fpj~}w}k:QM6j="9ڷYeOx~۷$*DV=FyqGL>)h;zw?4_iK_ϦlJQB;88֣q =6kejAlr`vKHB+ee_T5Fw笥h.ڀ;ci53~d~IIƗt @i7sg-b*?N^؝SF'̒[ʾ#rG^TOul9ߐ{ d g7^g2I;Bn<dLe_|yta_p&ޠ#3 wvKMKqdAXrDWzm*p跘5tJpսfiPHQ8u8˝zm*ݘֳru W8u!쨜eIT쳣Ŵ`@ 9¶Y=0\̧nClus}e\;'4m)sRkZޒxGP/7>MvM!\7M\7 [2p6n"""6筗p  P De*>~B06O:''%Q'"*}&Bf\Ƹvk֛?$ &"""bTU$*DV= e<}"b,_ ˋؚɨd->yhOGmR'"""bTU$*DV]*DĪY}"bT.}"b,>U*DDDDl>jDe*}""""KeX5KODl>ۥOD%Q'"JeR'"V͒[ODDDvfITR'"""bTU$*DV]*DĪY ˾CL20$v86ˈE^̚/QЕ.(άhK-\%>̇^EpهnNMENFd :*DDDDl>j$se=WI,b;q-q3QS.5ex"잲~tK)2i~s qJz,}q2 or|WC5 wq98 \W,?߷>AT.}"b,Ƀ}bϰ36oCLN<}[/D'>bADNфYIEW%vaboD{?ʔ]vX+w;e_B, |^Ϟn>/"UIODDDvfIK%b@\{s)hgPz88nK|::{dٲpƵBF 3Fc徲/Yq.^}Yv"]8cnXX0;vv8zu`[_&1ti肽a{8o>r-DkIc<8/wBB ng`ژ^3w{~;ȸvYz\EagSf^&!!haߜy+?< IDAT{g= V]P7OW5bj⊝L+Iw{I{OңODDDvfI,)bϧ]fG{n =]3tHIr4\Nu2 mTTLh;à\Od41D\EWgߢCƞ3v:ݝtg[P;&("1Ɲĸ#[DAFB0^ UIP@ 9z=[Ux=lnKd>a2bTX7>Dim3Nc WE}=oaT%'nFGk 7jz&n 'xYZo%&sYXI<=Npz`c䵰i{ʍLɝ6A]d0yj8KɌ33#¾/yT' %7O]ȰRF"ki~:!G]fȁ)oB<#a;qrni(Æҧy ,T<>NHajF5ssS4ahX4va_SJ""wkӋ4]]Ae)%M4X]vVz {ak?<=Q5VG-:h󜿾m|MsQ^S=s<3@bΣ>f?1Ga5_/a_KGim<ܛG[}s}8yaYϧ 2{™YfYX-kQn Yyѽv"st͘ z :^p&';o ,2G5ű<<"VE{J{9:_iVaMGeaggz}=/^yT'( \ DįRujriz/;C!}x|*sn#Z#V^16#]r6+Et}6v :lY,61= gfA \^FYDj]<>On$xAkƽX]yT'( \ DįRXWr;騿dbu:hGa}\vbrWig_˽<ǰ=,2e7w8osre$&OH|qX6_/VcTEbJʚ\VN0 "Wt.k`AAWVRaGY̌l^Wdfѻti>ɲV]e}_OQ'"""_ .yn8Dxg B|؆3ϣҍZpxy#SݫF״6*TɞyQbsp3f{F:[xxbf$>YqjNu;u?[~0\oxG f6bx۟?I󜛣SY]3-g;fId4vxG/<*/.}"|)@ODDD$p)KE*}""""Ka5_.bE$P) \ Ddx6z|)@ODDD$p)!gM>?lL5Rt+"JaHR'"4AqOհ+-#X/E".}"2(_±fϧq2A/|0w/{m0bRt+"Ja&[ ('ۏ?'3@%ߴZ~US7?Ae!:1F="#`gϟf6_~&֋KߍLݾ]ĊHR'"""i)fyӽh&;v[YwȿI:l .=gQ糛̘̿O*{t.UsG+=T#o :v}~M`ʚˈ.V 2*[BwmJ w|22TUU~OW񪥔%AiSP=֧2;w=ٵa9-Hx'7~,;v{rX\yI볻u|ˆvwu\8rO~ɽ7Y]J<V"=_k#@Ya/IbݷӜ4~voFn_JUU""Ga{ؗ}&L⿓vS{ń&+Ý]&7Kvx#l]]9)!=F:V~6v+Li|ދ-5_)h' thvg{HNdf󼱈AP8۾FM&S_pT2 6ru{JOSwX)=ȥ/"""΋{(--FM#l?>~JWVϞmEļ!Y_E{7a ^Kռ|-7|{*}>ϞzzWzY]BN|8+,vu~j(>)i|Y |ő#6}ݗODհþ:w܃gL3a3$7 yi 7Ge2>ԥdǁ;\1H;H}ֻYfV^o"""l]4f~ZLX_/0rGp3-VMƒ&}vDOZCYS^le=l> hGxֺ2 XAN'dRH¾ZH0"yn?>;or|lBBU^Auq/[;غnr|C?SyA5N6n}LZ 0$. Cڜ/)d2ӗXzM[lM$$O޷lw 呕s?skK21a"~?RΚ2rfש*jG'D~:9q +8< (NruUoQ1٤OX:z`RH> 8e9[vmcEa_êh#R7}t֮먭q{B[P&dddcE.-k0 fG.2N';yr[fGnG<}"""26y7<\{Ԋ`?^wRݶS"ӳSbjˇbr/v$9wh\R>w&Ld_YEyxq3'*y/&S(Y>_> T?Ⱦ|nn"0^VJNS ,A"zfXPXmt_+vc[oa~Y n˼?%00f4 x ^(M1>%s|<~۷RH{٪2002.l~WMy-`R{40YyVRRt q}}^~;?~Z6[c #F;,9c%O\㱵sġ>_(@5a_;53\Rĕ.z֣.F:*Z=x¾^>ˁ6689=-xN(La5_> T9hZGaq1bZ_pu[d0 2M1\aJWoun]ZN\ \]ar}"""2(K0+; ,— , caAr6dd_\L63%Lʧ)P$+#M$o/|88$h}CkEM18 9 eށۗ^H9}W*~*c?}.{ a&ʾlY6`#]w[{PSi>rn^l=Wwf}[X^ce.+g2Y{޾H yt0=8}5?B3p }"|)Uzt>+z}DsC}SM+8Q٘ *ǖ4m'*_ۧErlSsqx٬?|zGu:j(=uUY|_r|l'/jڝ\.; ر4p0,$.ȢUpgE`aIɾۢ"""""Ka5__}P7FþJ!ƈerc%Nt42-l{G"_ǗEO02aE DįR| }yڹm:Eka7s>)0>CVxr3B^NX.eQx$KQyOfc9=wJ~^zw`VTpf22LGNe{L+A}\9y YJ^Ӟଢϗ0KYIΥyWv]Kf20Yb>*+g!I[xk_Khl>+ r3fpzK{l<[B̳)ʱ%QDk&GHǖǑSEyJa_eeϏ LPpsXgߠFm,ܦxSvxYb>hd_9FXd$C1F\~{_Ws|2ǘ Lesҡ}<̔s|o^>Pqy/3CYxˣY~\qsBYu80ϔa~L͡ODDD$p)K);9*ܶ3)'oRYyb^e l/ŭ-#ki,kOna_ťFbϥA0}.1zSY2=Fql{d_` *(5\#P,;T\ٗJH\~}G9+ΞBϣg *P(d`RcAdgZLJhxJs䍡ODDD$p)K\g"/((,K\)hiH^zE #p߳ک5%X1ޱ(s\=¦f[csR(XVwN[> >10LeIexS̰wS[I d}E{NJUVRQrC{p+{c/VrIu^WOeבUVPas_f<pK`\ؒa.6}{0A3rkTV;;ŇrK+FmP'"""_ .})Y\,yB'3OL-DB㙻q7: CMLz:n9g6aDDgpfc I,PAlEFXY'T\P doϧt8Sxpptl 9WRQpLl۳)S=V}]Jɭ,H\Ux+ {Goau˱ 2P+?8C=T`wz ")T@͠ODDD$p)K0 W9(%\VIee9E62-* eDdx.}"|)c#zQ.fj20L'e9J >OD/e}""#MaHR'"~͗OD>OD/Ea*}""""Ka5_> T DDDD>k}".}"|) D$P) \ DįRHR'"""_(@5ODDDD#_%KWoKE*}""""Ka {+|N٤E2'ӗX T DDDD>wq<9uRt+"JaHR'"C?bץohio)gyd0o`}]ĊHR'"""O}R/>OG\KE*}""""þdz]E56bux@I{w:n;Ny#;o[ymNler']0ה5÷̔^_.bE$P) \c*k)=]|~ U?>? s߯}os?2+gd Yﭤo;U;7yJm xkMVax}eOggb2Ta EatɊ bfmH3.`ݎ ΃9eC=[ld}#g9XR9ZgQ:w"^(ydK:ɲ('ۏ𢡊xAV1}>oD)?#HAH_ü"=nWrϙ$ƽNylN_¾ f{K ʼnǽCl< }l޶s|"‰Y3Qݖ߄}}a83 ah;/AZY90"a'l޷a߸.m&Lakv!ϝn3c2yg?X{CWly Hwֻʩ{NT|:*2/^t}X=l/-~[=!}.JG1M j IDAT36@gaZN_\iz~8HS7(>ODDD bϡ%œۏy%fc{wvw&2̹gw9g a2S=u{sNsa}$_ܻo."?縷r3&#yvN61nB1lL՜0 a_k9kCYQֈnqh$xLd,]SNY\TG8ƃIDBWV_!G\XF{BD{5iQ,-A>AppqaG 6s6qt&C 2LDJskqF\VFl2a3h{=< s6c^2fg0™.Yo0cfu}l8E?( ay]ϙ;X'm9jm/d [d26"?}""""ovQZZځT׳s+? s_A>'6闱ҽ'f%5ij\[Xޙº7{ބ ƿ/ݏX/.&Wy>|ӗ2԰i{ʍLɝ6{a_U3.W#% ,L6[cXWلeɩY> }d4 "n+wY )z5ך8aQX k.`ud#I)۹;{۽}o8}f$z}%mGY_FׇeSk]1 R2 ݎ ât|z{H gmU;<"uV{z7Pkc{Z杮ynmx=믭A>7HK)K{6_bj^~߳wax@ۙygk^g_?JӃ31379_R:mJ5Nmζx֔rI׊ýI}3ͷy߿A߳g\Cra`ycyhɉMs3 cg{XvG4WK=:CENBWC , Obσ#4 "a;_[^=Ôe9ݯ??yihьd~g.G}ƢyX}u#FֵG֭Ⱦ}ɛ9$O;îs>p+3Α d vW{li0 ~Ɓ܃$>mM#rx ðsXaL%+vESK`4I ttT`Ԝp34jN#J-O/wgl? ;^-Бе@n{#F%"?_>;mγ6&y5^\`%qt4rUឰDoG̑/ ,2e7w8osre$&a_ Yy-XÆaܳfMT89VGa)hshwaQ+c{}Ffo+w`)wn>, E4vc뱍?n(,k̆}&c_HG9wh\R>w?&Lq;8__ʢo7a"o̬TnD *"z"|)Cg st*Kyfwr*AI,}W=UМ{VaWl9u(>cd Ò̪#hk{}~/f5h60Hff^QGMo홚BBU^A #q+ m܏H000RמA}X~ n>> hx^Pd> 8e9[vmcEM{mL70(g&gfDPs4rG;(f5q[}i}vLwO1եODDD>k}2 M̗& e4bl,am 5ܿ0ĂFY/܈#|C3pu7yF=;բR'"""Da5_> Tq>[^F%m<ܛ9dxAS[kG_~gqu)OD/Eaک9۶"t)ֳVصRw, 0%}}c`u)OD/EaU#0 .;^+o;[m^vlƳAZ}۹guoc94HR'"""Da5_> T5ab+rve$vmQ;=},cdg`\CIK3ٽkb ̂ZեODDD>k}""s9p|ũ̹ć:KlNg]Ζ\^pLad]\ku/Y_\V/LlX}AԥODDD>k}"F&#}ݳODDDDOD/e\Ċ0}""""Ka5_ʘa DDDD>k*51*""""#k}) Dd|)@ODDD$p)KE*}""""Ka5_.bE$P) \ DįRt+"JaHR'"~͗X T DDDD>k]ĊHR'"""_"VD>OD/E".}"|)@ODDD$p)KE*}""""Ka5_ʀdFOܳ_vھ)`ϪZ0LX&:2Vapc|WSCȮn{'03Dpx mIQODDD$p)K\'tEZH$sJņ{l'yl? >naH8[+Ɍb噇9X_փiU') \ DįRF'kvf< wؕh"qmڜG`)ѰvsBP2* xC DDDDS}"2l*>gOg2/> D䬭Appqa3N~F/ EYKOF\VFl2a3hsvֻYdY0pfl)c(}l4g3VM '’0ϯa~V2m^=LafR>G7*c` =)""""c>k>7>Dim3Nc WE6+KONUnVxF5R”>G٩?7 Sn{iWá9 feWh4¢\ Vs6OƲOD/epak*D,4v;Me+\Jލ:=z^\=poΒ:.ć,js'|VO I=B];CLOa6~BR8P9US鄤k^377Euuq*=íԜ̎^Gط2Dx/mb ^ʘODDD$p)fϛ,IC~N}  _þ;HGv=۬4cge[HdP`g%[cMdvywasw>Yyz*sn*MF 5F.g31O=Ⱦ!oeݻmWs %EW^՟#2&( \ D9 |} ,2e7w8osre$:=,|e-V,#WRlj|y([l˲M$ǰh%Ϭv\viBjڼ;AGEG'AGK5{VΦJ̋d;1;w$su9d]rribH#UO4cӼBM 3) ٚ&zW=Y6,s'Fy#SΆS_ca}jv8]Vv8!Bed?1Ͱn+J&bd &*m Gz^Qj#y2&( \ Doz'"R'"""?C*=Hly Hv"I DDDDט .d8)ڏ>!S'"2&) \c*\~ >1IaHz#¾3 y/ 9~‡keÿܕ9Svd#2q&_2P?}\صwЭG"_xkgo:Afm.]eMepEJaȘODDD$ptfI??ad¾_O9O3}VɒN"+n~}y(E|WDž#t'&2DVwہrc"6at^)G泄!y󼷒*/u=AaMa&[ ('ۏC ~;a$>3Kz7a"N)1]u5[ݞMjV; џ4yU#V>pi|9(ME{+W>>7HK1 wg$c*=)2=902#~ |fk2քxƮ1=!Y=}I f~}/&r^[9>5x"VD>7HK)KϚo+Bpz_؆i}D,00 ታxG?%J7a ̱V:.\Ϫ1ؑ} mW]؟f)w:5y^89/?%2=:m!p}O~g KH)]`1Xf$Rz]Ċa} |uϖf |g=HnA 򲘏# ๜|lV;5̈́aSk9{2ՂayȨs5JKKQH'"͗ODհþZ50H9X+xs=yxAQE 1!zG眍|Suz_'wi1 c~ h}"~z-y\ME7 "ל"$ #cv #r-^hd`,ύCWDDDdLj)fa-Uv`OKQ'"j>0NYΖ]X:=k6/0YYFcp|dfd}40CWDDDdjo TlVH2 roG36 T'X`60SXXODDD}"|) D$P{U:W?+;4^au5{xOD/Ea*\lN<&k~rvg|f'|HH\Ŏi""""P'"~͗ODX\./9h'3[4:)ڒNIdBnɤODDD? DįR^v3 DDDD>k/b,sa;p9eI;Frj KW6{}{G 36ܰz]&wݦ͟u "EaHR'"~͗2XgCEFk{F\VFl2a3hs:".bmyy?EFgƖ";(۽K)AuU&;9 YCmO۾gqR&BʚT۽Q{T콥<5u9_R1K1~H#%oo"ڔ's6cn"@ZT?o.7jj8AF|~srYyQEf S(K>fV'g=K?L.}"|)Rwb6Md/$f5ΰ(|2 auh0al|9`70H,v;S, ffon$6nVm'b^EZhDž XRi[X k.`ud#I)۹ku>5gi/%3̌^ȴzʶ$Cxo>LVjd)g,L徉}=yEe...|`ǭEPn1v3VE^{x}5%)>0#"""_ (;'R̸$|8宇0/cr( Q¶[z<< -r٬}uy|ZlqXm=OW`d-쑂c~>;twCs;TP5`eB7BэwY]=n79ʲm':5/oͰo._/}DY6+>̞^տaf24d^a})qzdWvjGZʎqHnwwBSr&XY0ԔU}tY/ &#FE>x9 ܖhH!ꖏ^þ`FڷC⊖}ywW(gZ^ >"i,$H`zi7Uk6ȳ98gPJzHN>ܮfYeNd 2Ok/E\G늉/VMa^hTŸN=T%8l. Nt?Ę ]{-MLDMR}6LuTp[rc2gn#TmBǘ3acPv!#a3j=ev؅V?a_&MT]`u9`ƵacjBfiHH ^/OaQbGD1-%3syToxfhM.đwIpx & {p(moP;˫_q8e3 cln@"Flv[ʡLڂӽKp{Pي K-Pf p@>HS % ,d{n U!  lv0;xu%$BCk]Zq6Q)ݲ-Otk޴AF<1X7`1/}DYb!քH;ڃIo+sؙ0)HGD}DDDDaŴpX{8[D 2I[å=1P6"0#"""_ (!>"""ŰbZ8 b(^1#"""_ (!>"""ŰbZ8 b(^1#"""_ (!>"""ŰbZ8 b(^'#"""oO}Da, (>I"""o>"i,|%xŰ(~1#fDD}2_F `8 >"ڰp>QbGDDDQL gC,+}DDDDaŴp>QbGDDDQL gC,+}DDDDaŴp>QbGDDDQL gC,+}DDDDaŴp>QbGDDDQL gC,+}DDDDaŴp>QbGDDD xvLIao4smi/Լjއ\gf3šcߒe[7>2gf~WguNù&pP̓o`[s ]|qv`fۡ(L$nme^kώA6mui+32mx hLc{ԊcQ-JzeA2 -#] )tɑq=싯۠9Tu1YY izuI#р:S N |ڇhއ=n aܩGI&ȅ ڢ<N'Wƍ#(HRA]x#F߇r; ?_ "%i5xf S84`>heru&*coWJ w}^ V'\=8B=\A}Q?\BQ:'`9Y1r.ʒ.t*gaǶ$e7n8| ;C\@Y#fOhcA2WN2K P!-XAǪC.dڣ$]^"H-<ݖ/߰ePK>¸TwAIs/.fr!* '{'`Ne6.ԣ$M!4vӒ9$e/iDWp@[BN7V ka- J/\54-~ 8i'lٿ뿿t4bAĬ$y'f7 ݣ=w@b Rlw˛;'%/P'cc};ɻc} ɨ4T:o/й :un++*a2!PܭMe֣KN'$9<1Aݤ^$J[=5gB s}~)W1S[VPœy?_ B/[1~c'H;!z˻%""i.+f^{Bir>րݯlƿx1EF?5_h7^k ܶ78& KjW U5LJB >aC ̿>& %C]ӏ =b /j~o]H܊+KVo/W}!s1keZ EOfladdޖf!}¾-ۍϱj:t.ut4?MӍ׊l(;1vokDfALi@om*ҏmB(`b|r|ąyXpNhJcl_}FLwAYՍ9Bcr]oU?\ rx]΍kAaѰO?@}RlكSgq\mE1`jFߣgXY ƃY{!B=V>H' yu2<D0#""v?RE62kð/nzD_g}˰ϳ=$bl1jը>Z{ҊʭH( @wŇY|8宇0gR.Ew0$r;gf2HH)Acak~ìO <`a9ȾJ9Cb9y: aO7o7 v]DZBJ=۲<i0rUgv(`ad$(HPh[ufpx&O+c-{T7ʑqѲ/þY|< eY+:BG nbh=]\o*x.z=T_o97z-Ǵ9>ie {N.gG1nBpc=:3S=*!G7-ؒtM0#""vb1nk66>}PǢƯ ! vCtϽL,Aˣ!-6zJ )R_icԩ 3C'ٷ`l|4k.+tǑ'rMN[|^wط'޶/B97jy> Z$'S :ڶCiUkPþ} 5CU+tg9݁Ҕݸ?ݝД]x.} :D ϽƼw{PlB˾0l7D{^ق~5ŕx]ε[rb>F \+U0+s{fWË9;N"kY_U;%8 qtB(Qz#gs껱oP] 7޲n9a=ičHWz0kr+q{Ve>-͏w#Ax {2ϠZ#dm}#aj r>S{TBogǭBivevG/h}EWh)])>fѥN=N7ӑ˰oi yy$!dHkx[Rdx[ZB ̐>.\桫(lƑgspt\c|]BC&X>vq\vFn`vLw@9.c/ԛQ?ۈ'U|1 c4ۗu B(ƭ 8U H;jQu_z¾5>et2r {Qӫ8?^DLHBhu4<%i:X.`*{zZ)4ՆRU2t}y׺a S^ P ,O֜y;a W ǘ_|aS8%Qh8svB-T{1thTB eC= f?s B{~; i ݸ^*)8>Cpq{ Zԣ2U@5uOAro4azri'\b+\ Rq3eZؿ oOC*ow`B>fWB![2ρ*T?Y~< ֬HC""" W-~ -B ~lXA{&84$E_|\Ѡovv6!W fr.s.IL(RXmǕ> QE]3G{ZRx & {p( 0 3rDドݶq*R!eO+Y_ 0 b["f}S}\oƵUa[{ (Lo_+[خk⧦=R g/Ӆ"4NuWNٌ# ƶzgkCU{1e_˾s$7ei8Aߌ %L 햽hy#h˷u'D3<m zFiyYY 0~b{M50Rҭ2ra<]4 aO7"_1>da Nd><6?D= nNg ăxfv 6Go j!ClϠm}i{3#;.!_3o0%[@\a'PV3ΠxsG|>iw\,EQmƛE #lC'.R̀3,gG,؎!B}rhHPg{#RmƋQO5gbɔ61۳xT!48ی6>a=?BJ:`:t!񳟲 BC""" 7 v?`2qN=Ogk F#Q٪\$eH. WwUefbRDGB{V+FR'M\! rQԊΫ8I7#S$zg">Xv[1z3EΉ#j.48FѰO>֭1B@lqU'1z[(R D}F"u h/Kr>FF84cş; 35N N_4ÿjdž"Ϛǚp}D";:+B ӾDK}6~-${c3=d-܎Q\"$ a,(4?av| 瀩*!wq{[uKu]D0)y֛_O2<>mCsoՃӅ eUg}n/-S0O&о3֞V(䳿J}E1 ?mFǽM9 IDAT#>Ŧ]_ߪx062L}fG7iSvԯ }DDѰ-9 +QYo9%)cuV44pfE7^ n SE-n/xb9!{_ΞoHY83J>g 8DZ;3Dw5~JBMngF_a 0ex*sf'م36[  xa ;ʱkׂh )1$""cGR祀OM{2Mr gaGD*a-eEZ$B yYQӈ['`wIpK9^L@(3Qy>^\7!/C׊=\7h/FҳU^~ j^>%n݁4g{Jէՙ%Tg+=| qP@8UחˀR!c Ip_UݭWO+BC"""8}DYQ /o}YB ŰbZ8K(ιgXd ,T4BǖDc\sxrP!rp9ưbZ8˚48z6;lS=؄ q> 2Ey,""ðbZ8Z^*:م,p*^bMDQ >"i,5 =| ܠ6aQbGD1-e(V&\' >"i,yu^<Rj`3Q8/}DYB}uYpl6]ňћ.aQbGD1-%X:H;#>"60#"""_ (+.ѣELD0#"""_ (kyPlB.L!24ѿAm>"""ŰbZ8 b(^1#"""_ (!>"""ŰbZ8 b(^1#"""_ (!>"""ŰbZ8 b(^1#"""_ (!z>""""DzaGD20S_x$"""0#‡X"W >"i,|%xŰ(~1#‡X"W >"i,(BOF(}Y~ gaGD‡X"W >"o'7w7/'?º!>"""ŰaQ0#"""_ (JE >"""ŰaQ0#"""_ (JE >"""ŰaQ0#"""_ (JE >"""ouPUcJ (JE >"""o!v O'2~ư=3#(aGD) !hݝtQD9srUT(: ǗA^1E:V; g~^1eu}(r0d@D}{PBˈ ԁbDLxf;Sk[^6y36G\Y>aѷ ~apܴi%}D%1Բ ȑ{q|[gh*+Eܚ7Ho{߀狗KǗ qP mIc?/R:/1#"""[m}#KDa_a(_/& }S&d'Aa_ʰ}0m !a_>"YaZ$dPX,.OQ7oBFe!Q.Ȭ+Xƭj=-C~ U`pyʥ,%#>ܩGI&ȅ ڢ<#xY+[ͳ%){K#JTBm{0-yE|5R ks{P@RC&T=p #VGpKp-n΂'kPylXg- ඏZ\3{q5[4 9Te8;l'v*p%+Է>B+>]h}8n !ÎcSUj}l> xV!.B2,qF}"~;FqH!(nէCaRt]&9Bd6<ޓKiWJ)@U- PDDDrY13}}b԰f }D%IThRQ{ 6QNA98g;S&GQ .@I? U]̬| )2`J6vB6?p_Ҋq\$h\YgطSww6R'n;f7 ݣ=<>gAL @F-H>ԧ)(l-m}8ۆ"hvZ>Xg M pΏbp`#$/P'cc};[Vi .pr/CF>Ưy¦y1C2! f4^p98.@g{׈4@S#Dik9\! x0kbu*MN&\~-}T;>B& %C]ӊHWBOAo0ܲjAn<2:`Evy+ZK3p.S pu|e >fwBUp +[V?~lyE)ߊ%{?תϷ sm*lod{!W0PG\ؚ,#>ުnVdCQ܁m!-aE-Rҏ [q[>c5{plu;;rce\p :%+o QXxL! Jn$oCe:t!Dֲ?8ƯH& /)ič;B3@sm-C0}ϴp S2u  K=aGDQa ȿ=-q6a{3q:JЮCcU-g`wK%hy4CO TQơ]k<} ,jJ>F:C+k>hpzطşƾ}H86O ><_(Oط ]qdJZCz]v(\ Zaur9PyeœcOB簸@k",B$㇅]>![~ľL81H^3gqn'B@3N+~<Wz'[g n\/CvyB$ h9ST!=ɽ>@rK V|: !Th}'CyD }n&o}0B:)}UQr'A|Kc\Gyp'CշMA5g 2XTU2;nx:>@duNv`~)Txgq@2ƭj-dQ&`CUr4ICYg{P&W;XFnaob"jzB*ӝ%Pen 2J&fPJzHN>ܮf,q8l.;V4ՆRU2t}y׺aYon[cm1'ޅ;u=Hm; z( dŸл !ulN -_[yfCHbK20'2rKpx]=z,!5zDZq T0 c/D7Ipz*K}3DA<3/{|(>tUA-d(_GaO mϴ1Xg°$> QE㙍7Gc޵E¾]EW9n 8(CY&t>"ne|%I %R kq~fط|&Ow>ma;ʤ-:݋J\AgAf+~jڃ, B /`pJ[(4[E*xZK.+kB{1.3>ެAB@ž6+Já#Lo`¬;`#nͨPBȔnً':8ݲ/Xr_p]aZ!2Ѝ tNOks/Px>r5iw5JXWv'qct[0ܜ! }ǒFCg_=>oR / DH9>ۂg2 @[=>`#"" >" 1a}n~2zB}.t.C T=c6,OjCB@$䢦Wq(eai: H܌L!x!5B `mOpvz{л!^VL} 3} Y>"""0#(aGD)}q+U q{jŜ%Kh(vw5& z_?Lo7U=h瀩*!..c%"oi|Ka$ v;`̯*!xjh3|DDDUaGDQ°(R$~tbuoNo'/Z>IL(AZOY&.Ҭk\!,܏>g 8DZ;3Ed)3#p&\3o(aۆEƗ;FqX!d׀q|o6DNvK>a'ҥrG+2<_VL:3ҕz=#"" >"}DD%>K{eI/^[>sE4I9^L@(3Qy>^\l! '=ndUep.>%n݁4Jէ5%Tg+=|쐅IpCEKo_ ꋑ[BrlŰo9eV&}M\G°aQ|J (JE >"""ŰaQ0#"""_ (JE >"""ŰaQ0#"""_ (JE >"""Ű Uh%"""/+, #"}DDDDaE >"HaGDDDQ0#"}DDDDaE >"HaGDDDQ0#"}DDDDaE >"HaGDDDQ0#"}DDDDaE >"HaGDDDQ0#"}DDDDaE >"HYOGDDDDߞH/ ȏlј" (9rşs`f3ԇmk4ȱS׊6- 8Iǝ<x^tB'Ipvb*&l۞j7ksH#]hن2!AKx<5ODňg5*(1ucOtH245DDDDaGDQ+aۧaٷ{<jtMy J40z0,#p}/z|y $T^Ø XھKQxnv?u8sQiaVN1#"""_ (Jb1pRu}̺6x E\/K}Oq2C3oaH]>y 5M3D/}D%1%@؅9NJˋ./ jFG@/X=I]Pv'(=eć;(IA[ԀGB)f4"]!ۄߣHt.kq TPÈgh8nmWcqO5kzKm;vaZ$dPX,.oFÅz ێ`ŷ$e/iD6jh r2pw Y#Y^5~!od_sxr4cAeWvwCȐC<$59b ]{\wHK35  VzIB[v%"""?>"c'™e@z3eQd;mY"%6iK=]Ҋq\$h\ۓ>|LN+/N@Sf;S cU+[0~%J;wIp` m M]zܯHD uФl.;L/Tspz˖}[a:ur֯~/P'~A: 5ðGHAJ-b:0ӵrQe}\-ܮL%>\,:);Ga,qSp,!R@k0`7` (Qܭ[ >"""o>"13s{!W0j!m5"3 zM6_v&s[!y_x}1t^^O\\eݮS r@-[ZCύpP\|n+2R& %C] }x73b-0XWu]8&ת@e9f"ȴG0I7QI!;0 ^, l6t]3y~v>h2~{[8د-k%*9A& k 5Ȱb>"OӍwd-+)8wkvu@2 $(ȭ:g ]>=iE}Vi ,v[T:¼a6hy.`٬7 ^ L#%f!5F|8n+.BQt:i E*l5Ǫpi=o7g2,qg\T-lkC;2lɲm=еBUԎig `a_uvçfv,ϿCbZ)_8_RxZAϹ]AA}DDDDQpBW妥 :wwBSrw;B (Mٍ~⳾%hy4CO T>ƯAQx ^jkX1G>:;k(=[urfv+1{ZUciaPlB˾mC pT o`Y߰/x}n< ~S0t*N@ډ7="w mC'ه'+ɰaEIl}{k Ur5. PG㰹$8#xcVmPp-tSyY+ƕ{2{" ՁPe7Q` IDAT $؇gt1=҄g`]USU:q $prmS|Kc\GybaTJU8 Vn\e&멻&+8cf;n;. vL;e2I&$y9&þpʷ/(6HljVk 5B*!9 pdGDDDDİ$V>߮}2(>Ҋg>cxt;ʤ-:݋)ZXHӅ"4r:+lƑA3\8]P"77aێ+>ˀ7{ OEX4i:A˶f=?šD y+>ρHo'3cyToxfM.đwo-Aߌ %L 햽hy ]Ѐp'!)s6'yFY#*Dbͮ7 6^~1`b12ȵmi䜋5 ]f|YL\=m8O}DDDDE (Jb :'#h&m]V ]Ce#1#"""_ (J$}?V"Q.L@pi@jփa7:i^BvL>"}DD°b~] CeHaQ0#"""?uwQpzggGDQ°(R}KпG__MkNǰaQ0#"""[ƛ}#KD)>k4xo>Mxӵ- M5ۃ)w ANU>MXǝ<x^tBDߢ/?~7ou^7o͆F"""e1#+aۧ6} Meh 4ێ(<7 {DZa?웝/ V~6fg׵DV@*|V؉! NǕ \t__'OmOգ2/ ! yڏIcUY:؉؎cKerQ"pHU YB, NY3wDDD!.+f^|~eGDQ°ox]<}D@8al~Z-}"(^2<D|iB/^Nc4!2o/~8u-Q'̰Y1z!Bw+_/DP[/}p"iiCr"Mf;Q\7 Iu9myҌtlrv"BeE|5R gTl'JRF ێ`zeS &oƱf+V& WgL?f9<9bc *{KqڮƎ59@]]g)w0i>>]nVէgsl@.Ped쳝ةFÅz*K؟ELEJu ZG}# ?Y :ëׯ,_7GX} >, E>fvBD"s~MB x%MxToťխ tvK,:M iص)g /Ŧ#)RÄ6Q؜V_F\C&HT﹬=:xg[Vn}7~:`o.u2W[ L0&v8G180 KmraJ6vB6?p_ *ouTQ uj:aq1?֏޷3>\ThRQ{ 6QNA98CoD8ao}n7~>*K-ۜ{أ^DD(֜{5 '{4B}~ǽW3wДJD]L=U s/R\!pc?ڙۊ}ε#+@eWߠ*N@(Ob`iQ"u˫uN5!W!UkıDDDloP|o(LvK[YI-ynwqH̵@@f\kTY0re1O]hQ@ňѝ@4n߱}o #!z2ýԇl4M=}~;oksut`- t]ƥE} qДc)OVb oWb_B%B\y,A6B(qO$[} <,LDҁbT_ꓝ`yU! n\A}:߉ɆgPsm؄d{' D,TֵK(79WÖ"~6MD| !}fmDOsZ1 yb᚛Ԏ1_tFS)B(p{>Dط)@\kXe liBSOmbtK[o]~ ""4}D&%m1op%.c%tZ+ Ko,~",r߼`QUNCMgo d{\IG9;fE hz2 ̀籪Sxlls'=gHex0{W|y7>MM`jj: t^ [c4 73,}`v"~oB5FOaӊXŹg_}NX>ЬcA>zSQn1j= gŧk)~~#+ak;ы%mD̾i}Y[ƾ=g0|98'[:BP6h}2@B0l߰M6A,s" c$8Wpo;$9_7Qt ve9w`ԉ'`w;`As=ٌ=IX6HW Q$h"Ncs9t@K \&|u 4^suf[ùϰw~GXl>.c|=ge x\|=gcE_#v ciJF74ke q p,sCM)as!6ӍUx3os|T! tx–("m}-r:'䭌gGOc=R@Li/^$B=|~6X.x?E>" 6DPfa̾L4 oO@-'JÍޫFAEW{0̾%,k+BͿsI9azY}JƍI-@i9&˷t!sn >=*AR J-]^@vXG; 8WA(T=p܎,B^gyq9ml_ķ>Ŵi[?b1}';a@ajMsOWǵT焴2cX£l!,/ƾW]@О^ܳu bCgB̀vC xP/`ZefY_A'DJ nB0^;J5p$B@d\ǸC,0y;BrΠl퐝xp8 B(voգdE*.d .s\ *$w:nWTg"wjp'KQBϸw\Yr<3z'1ۚ橯6bo]~ ""4}D&}DDO=3~gY>iU9)*ǮtaA(1;aD݉ hJ)5\g!4pcxnY-/uA}$dY- Qev{},w#eʷ1(zL> /0Z@DAǺll.s" c cQ|GDDDD?'>" >"Pa#"""\}D&}DDGDDD(LB(r1Q0 cQb#0a#" >"""GDaGD*}DDDD„(T"cɏ}DD*p%"""+c}DDGDDD(LB(r1Q0 cQb#0a#" >"""GDaGD*}DDDD„(T"c cQ0E.>" >"Pa#"""\}D&}DDGDDDDPGD~0J_9IDDDa#0 ω͸x JT/[pq&!gz{-'X@}~$Ѻt)p粿AUCKsqHojX@Fw` >>̹^~nwͣH zN?"cIc{*bQ Vg]}p/Dݑ| -nr pT8ЇIuvk|`c_.ps`597?±FPxO2+F7Ġ_@t\vevb}DDDD$1v=oﵜ^k`cSgHe22wY<9 F3vAM(\>f "Z}eGDDD(LJևsA҈nc;ËȎUCssMlrRdi֟_h9(eJ [0 , (rqm V]IU$5bU7 D\TZ[w1͎/(LVB( Yj<)l;YGQվ 9 TinDޚe,b<kq۰,b,2P(u}}3cAb|Z,mƇǕKރ(.O<rבlA>U Y$g}[$E*C}^[&6BGDDD(LdU#b`tB!!xoLy]" 7`v64i8>KP<i DuMv`]kPj.x2*; T jP1xėҕ-})ms(sO0xQطl;p9.`[Z@dğQVv0ii2OLE*}yU] OˑQQ774^*K[#')B߂YI,M-_ݹyqzطv %B(r1Q'K6|ĝBSƐ@D4McHVEߒg/NA̵@uS0\MI?FKZgF8S '=\(t1G8mXpSƷM6H7.>>Z]>D$W]sc2Zsʺq~Bþ 1f]L'ξ(uF,pN52C&X:;>` 3P0E.>" ]|8gZp )i9WN#&v+>\Cu0y'ǘ7;hUc.a@b?p [smł+ИY]ے0a.z!ZDRxivp)YN{ӵԵUKo,eQylJ(F-_G>O6:܃ؼb6+op a#"""\}D&/q4dhPb_<1גe=rLg0[;a`+) տcoo 4^| phS&9͘[bk ѵqo_X#@r>Yv`qj=WŖXvo5m?|lGLF{MUIDNx]c xۄ'@qMF[\d׿" > d D)P(4 6\H#ɿ<3GDDD H?}D&}DDGDDD q|@t$'%#W3Q0 cѯh?0"Pqs|­tbԍ#A z;k,GafT狎MGśw Qdre9bp´ (LBeDZeFܹswD% wnΝh>/*  g_$! *zysOv,(!H+EǚN?@leYcTS&P~waԄ,. s t׮j!hd'֎Mwu#M=ZDQZW)Xv` ~}Dg};;:\X!}Bl IDAT s)@|y7>y݇"h]sK o`X;FjBu /}ƖXw6 R-8: ,9q B@Yi;c%"""(LB>s8ƯyfۊOR6ouYc~P?eߝNG|lӡY|mk!̐e .c 6&+GDaGD*}6L4{bYmFϪ^y.X-]K˺f)<(DtJބ҄o}+nGDaGD*}C#g{ar< u%Ms=}e|O:-0!NZľmhc#0a#" d [:h+e| E?ҎuVnp :! RJPwrxJDDD1Q0ʮ}-Z*#Yx(s]#1Zگ.KjjYAwUt J+] 7ľhb#0a#" gGDaGD*}DDDD„(T"c cQ0E.>" ]#I9 yt!RblY KSx_؋`w%11(Ͽ[^N]ƻp&!gz |s7RqcpqHo[}5 Q8:n,.!{;%8q1Kr/BpzQG!Rfah[nk t(ZlDY\<.XZw?^g?!>"""GDa{bqcԱ^^d&ng@$Tjr$TB@}w*XDnü%nhqM W}KAۈEZ$U>qGWt`597?±}>Ŷ v6lMb_/z_"c.}I (':`pœ*hDn}܋/ENc~KS]/^sw3ϴ1Y0RvǾ~/8W?3>"""GDaKb>U Y$mַ5HR8t6J%AP@[ 6p\!>X54971p`q%{QPr4,5H)K8|c)4)8v 3T+?Oij<ǹ4,.̐e @Äm]q6Ng :ZĜ he.X Fƹfۜh؎C4T7T"/I !_ՍΦ 69Ly 'W }>#FLwp&Khʄ\\jotWXa츝ž,u$Ȳ_QPA}^+ǹό(r1QؗKD e~ f% 4|tb鯱774l\Kui x{v OˑQQ} sm:!P ,`ܛmɻ.u?ġbxi{?v J?;{U8 ?| (뚄$"\~,@Ŭ ~f6Dc_}Is(]UdGiƾ쿀c ogό(r1Q7bs 22\DA4a}< c!cF>w,AXh˅joÆ\fș;i7= Sأ>% W$B_3R b*1b5@7˒O1c5{^MOhؗ?Ƭ~aF1n|l855ά ]g'rz|\6ŪbfϽ&90גU]L% WASҏ \B0>Z®.tԍ.gAfZrl.ZLc_PDDDDa#0]O6:܃di]la~!IBoxn0>]KH77ᰮ/yT#zPos;QAJJB\knsɨyٍCzE|'%Pevǽ6afі/w2o^pE^XVk (sc|p6][}Ժ}e[Xc_PDDDDa#0mO؍ОFL`{Rbp V;LyPoo=ލfB=qDm﯂^.s]h;./ ^ pn=ABA.b}jY!hogX]2ԅ`vmts(2>gf_3^!xnhʄ2s+bf5$k}s~:}D&/ɲqjpXʇ:V'$uPl$H-W\gNX;q#p˄ ^xΝfԉ'`w;`AK~bl}B %W cg@tas\QS%,Ť}QJ? x3NĠǽ63> c|=ge x\|=g߆g@]|E$f, myM{HsgAu){quˌH[oh!zuվ1E.>" 6Y6ռX( 9exUmdC=U t{O<$ E)MʼnMBKp>{ʯP41qBs/kehCB`O4Z5Ff݋=uH(u'Q@RP0KqΟbfU^oxֳnq95ا1٨yi\wW67.R}캹Mxxh 7<ܤ-ƻ`!zl[cQb#0GDDD(LB(r1Q0vbzBcGD*~$ѯ„(T"c cQ0E.>" >"Pa#"""\}D&}DDGDDD(LB(r1Q0 cQb#01(R9/DDDD}][c#"?8(T"c cQ0E.>" >"Pa#"""\}D&}DDGDDD(LBbFUVJGDaGD*}DDDDK/ O?}D&}DDGDDD q|@t$'%#W3Q0 cѯh?0<g#0a#" >"""_ǣ]8%݉(Tvp+Er3X0uH 5*tKf l;IShB'!2"HQc&,w'\ƕl5SUqBy3}wQ Grgu/v]#I-AmZ3cH us, t(:83= psD>oqmܹsUB@{w4ܹ;wnP>- /;qe\,C!:ߙgSM/1zU; O{WģX_BgcDa4ط\gT@eqT^[WP ?.x=Qm2}}O18a%>" >Y Ka T-bccKGb+ѯƻwB طwgLi脀+ 朸 ! e.=(Vg&֌} B@|]o:Ò>izb6 kv)LCR #/ADc,KpNB!-zLa/ ER@Tv`'LxX~IJ!TW}I7}d,f0ˀ.J@5Ҋnbp94I#MA@ԑ6,~9v>ų~)aHŘ],0~# B${b}B@Jru]a<8d'@j3r8lCw.f#F( o^=c""" /K/$?2QidP@KmcJ( +uNv,Nt0ә1 1Z>mS+YZt}!kc؎qx"cĸmGW=7VΟӐmlG^IU$5bU7$ lǗz&+!{~,r5:ss>,"h)&g;ϷCw1,(ǥgh@tFS)nqk8RY[. N@Σ6>َ<9H:Pkq4B@}_\NA)4g`vKe&n{/>G6BĔUv]h@AG B#wjʂ~Xq=Tg[]E^4! hFi]r<S{~~mgQxޠZߌY)(LvwPMDY$nE&azz 1Ixg>VԸ3xƂ˂w%#\?̖(KHE2dنOR─Ko,k۽3d_>@'K]CJhErc,rWn/s9JZ Ή"6:!28>"0YqZ~(@n'Y!(> "Bpξ8|طCDDD?>" kɁ2>gf_3Xd OWsmň[U'{03?.ЦG_0Bw|ֱϵІZX?07}hʄ2s+'_se/<; Gn%4W`WcRۑ `!==GYva]xn`[1;ޙt/`ps{^ 9_ Sҳ5?7طCDDD?>" nzY{-9,cM(KiM5`NY7Qt ve܁ova"""_c cQDzZpX!,h,I%""GDaGD*"c cQ0E.>" >"Pa#"""\}D&}DDGDDD(LB(r1Q0 cQb#0a#" >"""GDacbQ s2_F GD~pfQ0E.>" >"Pa#"""\}D&}DDGDDD(LB(r1Q0 cQb#0a#" >"""GDaGD*ۉ}DDDD }DcQDDDD>" >"Pa#"""\}D&}jv<Ũٱ BߟZc}"cIcswsBFnvRg~8[pz/wF!c,.F,a!wU,Q20K.B: T`^*1nXo; EU :-mƙxY+c8JG'EHo&HGDDD(Lv>oD˸=Ͽ9Ȩ{ e;3OF-];`F~Z!mXañHa T-bcɽƾ;E=旉}̵#G8B|+`]w>"""GDaط}f8c=_ -"""GDa>U*ZƂ$A0ә1 1Z>-yRȆN!Ik0?1ط|b؎xYZg(Zg"&J@RGf|x\= rt-N{@m] Ң7V9W3^Bs9f}x8qA>+>֧BW3([=>Zz&eJ?\5!!Z2!`uKbJ'Dی-㶳|/b<}űn[ËȎUCs@5 /N#!Ot/(r1Q$O]6'S-8C6jq 6?-GjF-FRۜ06Cf\6wHC0KKp;4i]Z@D [Z[AWqe`/mT'+3dَٖ\h~=7\'͵`wl0 ^EvvCPMDY$nE&^}V8{t ᖥsmcox@4ߙ}A/ig&ee dV=_v>"""GDa>Ce]hQ@fZsʺkOhؗ?,e;i7;N] \ccHVEߒg/NA7'ξ)жh,>˴<;,/ef=>:6c:eĄs Czt.={id*P/%MENjj}n_ʌ:D˃eHH[ (ه{Si3c%FLIo}~8hJaƾkQXm_ik׆_u@ˁ[%[p0<ևGDDD(L~7\57O`Z&қE|6 d< y1qQıgiV6 )@s|=o,4blhD}H*6pK?"h^9'ZmÕd"7~d&d@ nYyބJ=Of7m6$o39s(r1Q̱o OA 0lz UЫe\\sxBRȚCX]NBrg{Rbp V;LyP'AmhFOpX_>_m$ڋuμ!s fEatFm m*~«S3 N=eAoSMP4cε3Y|fY<3􊟭  b/. l86wƾm΂x?U#5>)"c0tT?-e@g/&mNfԉ'`w;`AK룕j3Pn'7RVnvXʇ:V'$uPط'Q2`4}2^,iE2>qco2*G-c yP0cExT/\zn2ß%9Wpo;$}sqT{&hq9lp{p1IGᐥs$I_ӁcE{w9Mط:?,1ۜ~/x}DDDD?0" ;IFQ݋=uH7͉wM8rCwzs~eMd-dW=ec>B!TH)ã:?x>{/@ˬK8l.B(_TYWo)ABs76cmij3:?mG%HQ DiSq7sFK#-z(spe~!^]9~Ą(LvQ_sϢ?Ss_Gfy lK}DDDD4OGrR2˻asB_}D&}b/:? ;:KQpbٱWba}}}Z>" >"Pa#"""Xp<{oU0^"ڝBRhb#wqU` pk9ʖeav;W9*sLMs( {o ~op>|>9p뾮%^)eeoIPz ]6h{X tZFӤJB!^]WNp1B'(!Uy9\ƍ l81%w]WGAy&Ǔn̒ }54>8U/BRe]etGI :>!xU^N%~-9+?_&37ܜLFqg7 B!xe܃&x>!xU^iK>{8z,=4hm&<;`v,A?F;B~w:o5<{H6LƇ~3s%M[`jG q$rk4hµȺ hb^DB!(-2α4 ѿ+-$ !J>!xU^N/,mAh4n`pY7PF+X"&tˋwSD͸{乾iht섻 \!h4zLVחIi4t_zhYr&]n68}B!xIB}B񪼚 :zJ`5q%9p* дaѠsޮqm)3M!hN'4 CwG6t]|ky_jhh4L;5;}B!xIB'o|Ăו9;pȡ$ ! 3Uy9{ZɎ M9M'Bי>!D '}Ӓ8 oӏ`ME/}ex @͠mDf=/JB!$ !J>!xUc_#cx2p֏e m1 :^Y+yd4o_ɪv'B׀>!D 'Jľlrβcp{慳N}&2tO{+}9\-)62WnzIB!$ !J>!xU'B!&OQB$ !ī"O!%OQB$ !ī"O!%OQB$tiºmG Ԗ'B'(!^KdlHߑ+8G7>!B7>!D '^LRrV)87 O+"%O!%OQBJCӒv7 NOB{. { 7q|_ɜ@J1ƫkC2^137MI)\žoNtH~ؗrcFLccEt؜ B7E㿄גtl2^ S\p.)/=$=[{8ݕ)O%WFmIe'Izc"XֳB!\%c_֕ 8ƶތ>Iz>m:+yG}Oa.=.7+ עK$pzzLw|:g^bew'Yb߳ơX2/;}~Lcen`?KϽ-+zgeJ\B!\%c_vfh<q"56X 0~FyN$Kգ#7|-Xc%edvݽh:1x!bSEX""OfOĿt#$e`ӀN]i:1h^³Mb_WO-O3`vg2A" ]S{=Y_0_7 >=s -ͼnn-.#DznAޯw:|=aSj }B!o.}BR/7'%}b{,ɨdc0S7 #SE͌gDtlqy$2{ž])#98#Wc%eFCE%+||XǞy-/;ߥ˃^lb9$j0: x/{ϿgjɊ>?/FASb_N 2hN'R󎃖#sgI %Lh^x-8ă#q<3EuvmDFx.Lst]iaOֳc:omzZjQ$ !B$ !JH)}2tA3D׀̸̈́J!x>I}V+t|- F=hOݼYx ?e9ݑd?p"u-糁ۅ;B\d:b쿆kYO~MZ\{,BF?3@?5VMpľ U}L$%ϓ%%p:q>=c_r.oyl5S2dwk"<}Yu={$N$EY}vra0'LYq}EΉr$HB!xsIBR nΕx;Jjm?|V &MHNi4h %4o=}O!&=ü[ڧ>vKϺCygSr Qf ^#+9?7-ԧľ Op0޳3s9Y]tS˜0[Gq*} \Ws=9߹1^xbOij̋KwE.a(w|=sB_bgwQ(YB!\%ž\m0H,Q9`#.5o6ܴ9/2zvcWK$(t1A~_,kɟ˅slCyGIx2S/~1-ǧ9`37rnQW:M$iK>.c89QwOf8O0:w?)8v[97\#3Ө}$}1K"kEVt߯Wܠ#l=YDx>3Ec8̋K5`-7)J>!B7>!D )ؗIԞ) qtiDG@";<]ꕭԕyuϾ$ƻ.kN:o|q{j$)x <{b*y7}d=Z٠#'wbϹj{Gs{uGӕEwa{ϥ^X.|2b_5B$b@4 zdicqha{YWVӣ?'LȞtݳ/'/ap2u䤆pQ2=t8,,4SQ%5x%ݶDgjեw~'szz{I Ilv1ۏYoӒrm}X8)O=L~Gf&O!%OQBJ:eps 77 Ŷ Sdi:`7y.&3tɧiT4^΃+<|c/ #rwsw])Yؗk)ۗgIGv8 +OFB]w =2qpy-He?}74.IsfeJ-|Ŝ=r0|OÝq=. &s_dža)GcwcOGzq$Vfz3@[jIwKw'`e&qk ~Y{2gIzyD{D&{]+!O!%OQBJ>TL 6נeBn9DQ' !Bϫ'(>Ћƾ\]d1.Ztu-}m.`l kUVjXPœԭSjU+afjZKUʘ`ddB( kT^mjT"* BQ$}B!B!^GE(^RY˖|9^+Wi*WoݯxOg!W \*cbh@U162ѡ עZU'+90gwԬQZ5amUJA2&ԬQP{TQnEԲù5 =B!B $ !P\ъnߢq9V{ _qa9,Yt޸g'l˙SœjU=eLRU8Qɩv68Wrz5&髱}")JT*=+ @ =B!B'(BbCYpWa=׬F|k%<} /[JTV2Tt,vm)gkI*Zs3lmRɁ 0GOO044*E=5Vf4kH*Q(JbQUOD?}B!B!^GE(^/gKUGo1G|~ MЬ\AhdFj*P”jUY jUkW&ujez:zV)daT쌓Jz1`vСC åJZ~P6-*6@_OA64XX}*U*,B!B\E(^srv g֫%tYKGzn9}FX[cldeYSjPs3*-׷'jpӤ!\vcFr.4 ( 4iӧG{, qhK5Jx~n˂94tq^Ts ->.ի`fjL*N며K,ʘ`S3SallL 044,r JP2t٤괤f>!B!1}B"/Uvζ,zTT*C[۴v`]\$<.p#_[h%}e m[>|\_| <_Oeg]'%=6B!Bf'(Bb]y+LߚR5[|D{+O^-|v)cnΕ12é=jcme 3sT*ur\U|723%UIRF~+Ԝ,twnz bܻ>~ln&"0 }{v3W6Ӯ Ы{yiQ;h]e!B!xHBxҌ.7PSVu`fϨwakmB9[+lbfj>V|IsL `_k28TCOΖ*+S?5=ui3nFEuKGZvxrI͟ᗐJ> ] מ)}Kˋ}>$~( H?W?}=qaH/h•z1ben%&wɐ#)4Eۧ hӞ~t>\mR_-)(w~BN d;fa8q6Eƾuc/;׍R$BW'~ 1ͣC$_QEfO˒B!BE(^sp*OT0SQkkK*;;Rqt=fpZCy,`eiJfj(JRP jb޻OT2o<Ν?1NKHbCɽGrn YS3xbvz0͉o֞N]f`Gb_aT@ǁi5*ǵmK4a weF7o>:ealn<而`~5KD; tm\cq:W-A"^&}pa>_wdPB@4~$>od36s"Нoc`x Zʾyˆی d_?˧EouxJgJ!B&شe{^>!Dj2oXWɡ2Cxe/ @4ٱ<Do|l]/zdGi@2?us{25:Hc} ;}^cϔB!M OQJs`^* _6Tℱ!&ƸXsIIЋT`Bƪ cccG_),q.gʚWѬ~{=WVP*06dx׌gd> kӝ4 J *W `ڥy(U85pT L<>3&1?5?nO|q8y"}C97[X8 ~ tރn6ؘܻ.v"[n0ӫu#mٗEyz<mF)}_JR{u/<p}ƱJgJ!B&'(*;Vn%{ccjB@_u\X0a;πv\[=;7q/_yQAp322B__?/)UԱQ2|hoJm[ԪY[ӧCPPOGT۽?c{. MҾ,^jнJ>Baz}\iⷆΰW2Gc_{>~ [3pbMʟ7/#;>i#V"^7F<[pbbFg/ ?Y0n!sg4/F۬=M{nv8,eؘ\cٓ\;5kGCf3p*6\w9?Pۢ3Nʟjo̕}h}XϔB!M OQJ/Ҫys̕jXX<6eXɞ糣l%s8`FCTabb>* ===jz*>-C#K#5!۬![DARɺ_rl@r/PV TJJc!3+c}[ M[SEJԩ^Txf_Yv6-ow݌P~[8H1žkyдiOfiⷆI[$݈y|֝';$^=܁4|"=M{Of_z;k3gJfho5;/&=Ӕ~?6/tAҀ{Ӫs@gӎU?SB!B7>!D)UgVo mSchhD eXknĜ/{[mw J%(j*}*V@ #5]Ԡ>3Yys[0r5"ު>fe07Z3[YTSFe#ˀwaBs./BŕۡPxhP Y!B!Db*^351)FlS3XbkNX76ݦMP(QһO_0ѭ-ڗV~݈O+]&f(  >05 +ZZ2=gvJe\yH; )\#ۥ j>zJ6X Y!B!Db*^S)J55kF_O& ̄O4l3#hɧ P+U 0V*dɲw+wjI ;uD}T+-*s[ͪɋ6_hښ^]ڿLi0|ϠM=ڔvNUPeRIÚ`B!BN$ !JžB㪔U*VʪΟ0'ƲcN-VV?Kwdž8I#2z]30צ7Z3G.r"|\s5jЩ 4"={eB ?#=2u%{,l޺FU*,B!u"OQJ?r*z9Vgߐ{׏9_tg{1e=]ن;sFL(լʰRVMlJB)frCg7c'(o`LBm:й7̚ib] Ô^(~>ÿ_ ~a6N;ꕂqB!B:'( U*5eZPѿfCj5,l0g\X5 oAO%&o {-kێk6,9ߥ ab_'/Yi]}تș|f! 봕w>z,W~\ Ɖ3V( T OC,$lBb|};R: !B!x$ !J[ƫR)1R:o[ع]Z~L}*T3=Glϥ=5}}mU L([wmikdGM R>Mkabe+u:h6wͱqrr>ux/+cE >!=g0j!D)UاP(򃟂ƶxIݲVmיw20e kOЄ&|^צn9s-tHAKs޲5Lt0ԬXۏst6B\#|N[8+D_YM+,1~"]adƥrq>RIUs_ƾ@v|ܾL}URB!B8}BROiZFTeSjRĜrԶ,KeVQ;C*kn]6TѩU3Z7ъʛR ִ ckErzNr>)y=hcϾC;Џ%}0al&Xʄy<Bș~.lcC{Cw!p4m\ G3Lv\a ᷅hܬ=Ɲ!~(3<;PeoK̺Am'$OCYr*Gf>ƅ^oi2?ˊ}}K5ADr=/% !B!ī$OQJwZAr(_F&Lx#`KJ7֧v&T46}YmͦilmNKGS/l/[S|ޙOpx|`X5`Ο FύcWf$̬!:I>.Nuཇ0PP-٩z/ ":gnA–Qtژ ~ڑu=sNiTze 1KL@FzQkXk?-&AG<n#G%B!Bח>!D)U̙}*%eT*5.K k{>ْ'=lc!&T35RAZa jjthC;g34.s\?yj;1u_ܾD'!([)sJ xKjN9}8{V/^BRiJ=_6}z/[3m[a$ aBNfˎH|cϐ~.}kbMkwYͽ荸~kxs~Oz7eKW6scK g~o !B!Kb*^fYs3iROWh` p42YLZƜ&F,_&V oMЧY^ۆ6LKN5jjU[=isz =ǿێýt4.2oP1Lf[Ĵv|e鈾JBH_pO}v= žq akԯlj،ZB3γX&Ҵ٣OCosT~W{Ɠ~OzaƾdH'B!>!D)UW@E9CCVsah֣ XdlVz|Y=+֥ hVjV`ㇵ^9V-Gʶ7cKWfMJJfH W.ΕS׸%#H{&tidO0k,<0B&ahqر߉JR,޼ws+#`D݃CFQ[xM C۹rΆ{2Fde=>kV8/ 6ܽ7Eľ>i11ݏ6]+2^!B!x$ !J>[c#>rj@ً2d,&`&Zب*F*le>PumM;3~n\ζ5]K5 , ibik*9?ŧ#YWOr)RB⸾QB96s?Â3T{e\|k6}=_3Q~n~Z3c|<owy9.E!4n3EA). A1$󍚵A"Mܩ -p"]6(}]/ʳ7!?s*>=6ߧn _zLm7DG٠C!B!^)}BRxAY;޲5t"'A3;>M)WNcc[+ ğb$%1Ʌc\pK7W%;gaۜ+S0lfC? `f81z4w܃!H7[7\oT6(B!B!OQJ/m9z5`82f48:n#05NowЬ! Jؑ+Gq75Y܎@{3[|5>ǽjR0B!B!^'Tb_ޗ݇E);ǏS/ciِq ַhc#I !a.r/Wݬ,rɉKvjFGzh $I?I:IP27bpa.rr1m=ku}fzLh9.Mzѭ;TkS Y!B!Db*^;}8C޷i?zmSNT؊vom!n^q3W;wBѥp7= rbTnBkC™PN]%FdF&r'=r?5D$I9݇X:֎qv,f1ێt}˓~J8 !B!/>!DO7}P|]o>ڹ%j&wB†nP{ZRb$%,dCV!\9vI I(t R]Qv;r'=]x2$ ^GtIDATO/c72Y 0.osάB!BrIBx`?F֔؊[}aR2Ȉ kE">-w9]՞vuInXed@nJwHnlIs;.[ "ȊL#;"qihZ;qʝϽ.9]x";.'UWLctY_^xpgﺽ`B!BKbŋ} \aҞ2aؒeob/$~jfVD~dy{fA}Oq;-;iD)}3xr"?Fh %R #z9;GNT*νtڈdAAtҵQwLc\}Gzұv.\ Y!B!x$ !Pg[s kSJKLıP.>FBp l^w†fΜ̿2wLZL4D2Bĝ"LRH<FڹhB N%={rlfYIdE'q?.wrȍ\ط3ۃ1ks.d4,o <_ Y!B!x$ !P磯Q;|?hgh^~gΐt[&H ؞8q^YXۅ^vkiK\^.u&d^%%Bsc]o"= ⦿>vxȾLw~c6v(O0m*4!/= }B!B!^?:}MfB'/žڏž~NwZN+\b泜}s#d\ #E/^aI.תqM\۬!u!>tvxp.S?}Dͣ>q+㴯Z ;~'h.78j'iW#͝LS27?BK\yu3m NF dul'ʟpV_^{d o~Kz̫!ğ6<]\ !!hVV\E*_SՎO+Ƒ`'[[ؑy8Q!2076 CLxbE&n;ĥ{6M[C0bCA{;\+୻ ?Ȅs{ׇ^͇2ɬ !B!/m^!"wB4iE쉳ğ8~FRokGXCgn]o'‰8ngC܉'7:;񩐡VL*wҹνttщ$An Ϟ%b1.Ɓ[:nŽel[\\*~W]% X!B!x$'o' IENDB`Mopidy-2.0.0/docs/ext/material_webclient.png0000664000175000017500000012316312564422252021262 0ustar jodaljodal00000000000000PNG  IHDRwPLTEfg:@ |G((&vvv8" (+դ^ E+ !;72+. g( h>V}+ }PJKI5"QBB8PP)e15M``a'J%")N(Q9"$)9924xJ%ҜR Qg(]43+Y[N1L^.5FA ra=81N^P6m Q]ֶf`dԮiﹽ)hmmlmFd/1ɳqSX6BW8'*纳n ~sQzjnFRf͑&$K8Ǟ^ ̟[j+wSb|ǟnT޹ەYN!֢ɴNja7}(ΠN(lVBb7*,gjn Lx&θp@guPHauMpBըٺޤi?<”Y{\O}׏߳xwt4ԃ|@~ozW g|JÍ=,WfdٯbY܆A^7q={8\[յVVZ5f֙Na UB#5e<^.=IQvbKGDH pHYsodtIME/b IDATx |&_ȩ6" iDCM 4MYI)\hB(2)1GqU"ٓSxeb;&0tL~yԗ8Xoۼc\-{&f̘pr'|v o,wo3fW客f>ylߺ6qīn?O&O̼m%V]0GG| m\xyqӧ~9c{nȌdUN\q9 ã.PÉ98&>fbxXF 7Ą?1,b1o.FխGo~1X7yɓnrX866&>>>˙;⍱oM $;vcGl[ 'Ǣ? 3p&OG7} ?$$$uY8vԿ%[R_C(Kb],[o= 7rNz"&C?ný6>@nmqo<C=|[͌UnŠ 3P`j}9Kxlȇfi,f +[oC:e?LQޣn_f_Ȍ0[^Ucojf̮8qq;fܸqwǑq\@1?3fW1cv8WBM/2!/apя1c XK' A+HKmV/ew_^ KY2nj/exNH-|Ӌ)!zkld3cEM\G("p:WGoG%Q6㣻 Ԧď5t̔a;/q 7|1/`^Fwxl߈] N:O+ϙ:™VN瞳wc+?].y/JVH~g^ 'OF){#QXҫřh&9cK&"sUBC;7!\L'9o˽<̎Ϛ f؛7=p `^ ã3ܡyfxI Y!ӦsbE7]^?17%} kdE!7I_䩋g?ztUhehCnL-)n<)s fs3d ;~f8RZ}Mz{:9{R6w؎q߇ͩ+GP{--wwǦ/zV^nh*roG}EkVz1x.**JތpEuȫܶf ~FG ŵ>uӧ=)sBw1Wsrg̢ ^þE9kSGo?Ar<!Jg(6Ý8{FpY@~%H=\3r0Ʈ&d||ɏ?>IO>(0e[CR`wvm&[ H7.I觻bNƒrc6΁w؉.dA7媎{6:@u0<OBFRobx9;`|cQ>609ǭ\y%R76.!}|cxΒ?Qnº zKlpX) ;U% GhU)*`K7B?=>|sJڴs^;qh>oQ26=LR{rc!;-&p*9h>tOsn?1;q(MxV/};!l<ԑcp'}S\?wl$RHY4yb>=m*9:pb##ϧitâBr|nID\4󢯶/x810G~|nfwQ5p:p<<ùJ8$7*oÖ'O O90 z/ z_7w>*ڃ$qqPH`G x>@ V 8+}¤Ilƌ3U Sŏ]_wh]X+#R2=osw5E);ƏOFJz'GLv'!$%Ho3d[r\8.f]M)z @t/U̘}9"n/:8f̘})< &b]%3cKY0Hk Ǩ2c5DmT34Q-Rp;&Z<ʚj'? ==סQKZd%Z]f\>n2%RSTBrh0⁥Z:2 %x:73c3M0d"e x\|`Nc ZSQXX. olX=P(z+**0|v `Wu$ 9 ( 3f peaۭh0$W+jNՑop@D몬onn+,.NGJN\SYGuq`PR}}~p6PZW3r[k/\VY:,SOhr8ZƚNj)ƀʩַ9r &Vxkk5h%r/RW‡5y;}ڠF#$ԅXNX+k(lk#r$ytV6G*+ VZ8=5;W+k ȯ \c ʵ/pQA1Zfl { Kc]` 2 x|PY*N–k(n 4&/qZV1.31ڑ_) H@RV VreZWWD.L@|5c TCV#x`L6G"sB.BXU]^kqcq03fcX s1*nҠM)cJFTdz\-Hmz.7\j;:ZҚFsz{>ɚ,K̘ `,۶VԖ Q"Z[t4HAK."tt8 V ywCLj:#[ ,ŌX++ө8Äk^.CFiH ˵#==-e`D> &:L6v\]ÊzB^qy|ۢc3c6Վbדa0 `\²e 2LzDMoAn&i`;8TY%yox8"-3.̘`c~qqjL\෰0 0)l)-[l+BW G<DjMR=- Nc!5XYXMFdD̘]`mX3A AhG,uV|vh0EOEMM=/,,&%=bLIIJ|ݑqYtr@!N"n`ruro2&q[({ DBJ\h,A6z`RՎnj{;AQjq> ǦGH _|*l\5;;]&(!S_0}GK=p+`)M _l^Y$ 6D`&zs :g~}87Ck.0-^l[)H^L̾1V>ώؤD"ZCXY[5x鹤Xnki9=m`2 ˅T`p-zwVc.x %~cypx?̾ A"cE=_;U@$خr DvT ^o2"d4X:K#PM=ms۲‚t0W%sjee׋luzU y%(aI,m]K^' 2&F$5e-}jOE1Xe!$aRc%.2D"ZasWzeXR `{nzy+qط"=ɇ-|I ϶pNI-+. G>@.P^]I t &{ŘPhֹN_ç!ouIכd,8OY?P\  F8ϿtNM'ͤ() pUomC|`ƌ H8re1¯y "2$@ۈMa,\Ze2qpme{Swא.;VkHB\Nz?k t:LZhEqJF癐0-}o}q11_>i]\̻",BA);p0 G (F]-#맴;t/ͧyń]p_VBl 9[kÎFB.Ecbq1ˌM,\Ͻa25j]SL .H< x#Ya]svh"\kFux/WWA0\[UtYLjc)3f2 WD"`5)5857i'K 6p}}wMMe.N{`?80o,д0G;!O] !q^8.evi``,RҊfp{#!t&ؒІ\.MVCKNJOǕI:N:jEpv!&XI!EaRf W? Ab`#I$&]Tm>Xj@kCedCaᲭXr sE>iB5py YHO16440֊|2DSʚ-eQ?_)m|ɥ3Up6tD::=t3RN_:?:HMeyUSom$3Ƣ*|(`24!́J1M1ƹtp1DR{ۻ"0DVH /NpUodf6+#A : uQBL(/+lFH!mki'K.+>BJ6 8뮩$]++D;ʽԑj㭨wzi9l,c/Y]Y| qතFwD <޾QfHgaPkAeDy6#Gp{z>5m0 `|B.k[Y. tdB;BK2xk]e,(&9[ i ;sUS>\TW=B`mmmoc/.w#l Dh3BSlinG"tA6a3x~Ai.\C`0hwWb΃-lpKOOO Aj kkݾ\NR IDATkf3`\l NZlt!P-ĺPC+>HEkin`574j^+.w G7Gsc9q084Z]|IOه &,X⺕$M'.$*R qzBwfEږКe[`yT5o᣾H(e (WUU5ԷU^8PCgCC Q}_8j/[ta >m!.5.R#^bKE;/0 Y{7F.Q{#Ǘ9n4c].4bgokn$Rr4أC$"[\ͅŅMF[Nvzdw !b 흾ttu6V{=sbc\L-KI}c^7Wea8|R >[C27bVTq3܊gMyVL`س hy .R_|ٶv椼o7 FnFnDfզ?G37it`뿟K2]hG-E{+*pv.*M4mM7WWwtt *luu!#=`A |4 y;sg ٣ }&1l-V͙JnӇRC<9D{ݳ#{x1n]h$Qux--$՜[QK9t.7 d~88C\UU"BYH_$Uc q \0uakkg"GO:{} ^eL;@șxbJNqlp ҷ <_`<8n1}'w$ֺܡ䡔K O3DU`Jy _.4P*7em$L t]Sx*&8V2H$QU_wW *V0е݀n:bۂgϝ1\LDqģBEM7cP q3 rqǸX@>< N̿>}7B_.4X/5yKͥs #5~S1]SVqm{˖#G:AKuWGĺH7y>{?,^!=gϞ>zѿk6&+ycy)㣱 >U48V|.mM<3 W$.0Jόv`_WZ/d48 A˃`(ubUFbkzE5ƹ:__GG#] uuwu»hᆾ) }/VzRtFLBqt!9ތF :%+vp7N}qИ<Xw+Wjz1G_ t8m@05(#=-hnLOO-s:W_WWRL ]! 8صۏuНhy/RK XG!oARDZvdKi7<}ϝ;7$3V8+ <E,p/~  XX `f1BBcj%{+ ԭ Ѩ5r-y]l3t{NiZt7Ȗ#{exI8n/œ׉Dц񓢻}+bM91,tt|h8xx%̮r9&mmmamX@0>/~]zq9z\^Z PWr9Pcm:_= ,sMp0|~ls=o;1,&ŏ!ԩFI L6 7q#wGݓ~J1}YC:9+[XNgt9>mz݈b>g9$xМNyc_=\ck-hSַޱ8N0Eah>L&/]PÌqظQw8 Ulq1S\5iB@mh "5vkO^tyFx˭.gKO;.Gˏ; 8K#p-AF[;>,ʉ3%81n4B*.vT$)1u0plTc4K*G?#6~T\ տH%3f#6]B47Yb4LX;@AJ`y}kOW_ϕe٬OP!vr䚑sgTj;~ܘE'b/TGb6,p#q%BGJ9KCΣP8Vr1c3k5 ƨJJVq;\.>թq|Wvr돂"x5cUggğdK-{l4L&h( 71Bs8F$#nl$E7b6++XR7`acߏ;bl$fFkL&ab0[N0s9@ tڶ4o9ޑ#/F G?wuBRȖo>} zv-LLcmFt`XE[Hf)# $OK'2HwidPqxK,Efx@ Q6h{4p}8Nb=p!G}ԉ+]q28g/,hnO/HUہѧ;8!v_@*."ǭvۊ#7̍|L&D)x`bmeEh7y敢}<2 2TV2Vn3Ycw0LfOY~SHr d&0:{Sy}'8?; $82TCe[Ӂ𶶶}NjۏN+%9֨XbW`upOG߻>;x=輾O VĹD[4ߞ۶N),6HG|M-4aI'n5Y5>4 ZmʼnU#تlzX$ZD ̮kF#f5m4nL_4nh@M&߻}-}}ʹ9o˶%#7Wa;0hL艛HpsPӹ.`S V`E"fɳKru 0" 2 6q_kO n_\F'"Z\ \8ε%u'7O`ԀX,{pa$pBlʛʯ =ȩm3r}ZFFZzqkAQGz5ݼsp`i5 >tnnZ=N]ҷGØCX&w6t{ȬB;pܹUf=+X4xMW(Y*ϨzSZfnhx[Ysȼ{R19}8Y>᜻sbn\5&j&8u |h߲sq1oތ% v-Z9I?H@Uɼ:[vjiiFrp],ME@o`hv񶲨LU|}7${Դ@RѨvCsY:vSt9]MUb x|.t [,\~8wFPOZ58J.2 ''˥6Dii`f%7[9OGӠV ҷOo R4lMt\.SYYY'Zqb8]X\>mکт)l.HS`]saջM2+ l 466p-X6}$B"T&IdyZjV=5;i,nN7q8oϙ5nz|M{wg8w/X6/Ϛe 0.O<Ź!M<]MpuJCEy+;IᚡrQ15-坧[Yd 4f7 mxapZFcD0 t_MuΓ:RxD%X 9gΣ Fc zϜ; >'n< G`}@.J MV"$s"T*L ~;Ur0`flJ|#_n pmz L4'씟 - MUUMZ+{8KL wV۽jN֞:zBriqms@~ϝ?+O5U_eEWܨ@-)2!T!Q*$2,;,TdoҌ 9uFc7٭Vݚkը0+‚`dwZ j2]9V5}ؤvO-3ASugԇ޴ZUї ϝR]V" ϝ>Ř䒫Q#ʭcA\B&) 4ϔJҲYS]e^4e&+{ q4d|-7:tL) G{P/\AT"X C l0UjmvA?iSBk=wjwV+ǥR䪍Gf!fEr{$k80C+C M*MRdI26|f5-f3$썘hںukz~45SrC]H& fcٲ|{4 B|<ٹvke᧡KԧGgOCf!Ïg9\r b`[^ 0X*̙38Lb&DLȰee|;l6!2MŲCk7ErH.TR`:˜Q6Ur@rKֶNo5g$807k!=+ A3 œ3SZH~q@I:[5[(ϔ3Ų,~wCj0egJ5~720-Y|WO^߀-*v ^DlgۑSkw p#ap8S& ,ʥr4*)(/qI٤7R-Hd2IP(38LFa>;ۼ=#uw7nq0e]h`ӡ61m FmXX%hiLN:Z lcInv8w 2\[1sm?54ʮVo-%XwZtu]h`K\h!g$W*/Z!`LN|SS7lHݾ!y `fX6j}0z=jYǡ$p'FW} t:'ڃ6P_H\^^u20XF :lƬ3MS68YJKai_/xԋN¡_9x2T),2SU IDAT,LW檴{RoO̮Ay5۽'5JzK]i5p=NIr^_"V>uk(uTԢ#ohb«_ MK*REZ&ʄ)`^*6fl/MMKf3& t:wb}N nܰS)B ͦ5j |6K,Ε̺(x~IN*LYh:j$,JD. Jc9Jz 7Ӫ222mO^}XO7u|Av\P( u,?lN 64;`k@Caa ?4u8t40#/e-:fH.VS %f$rjrDoKKM.؞Qzf0` 0%%O7XNڳى^ tjɝ چPmw?!r~,ka!2 v~] Z$i 'xYtD/Z4 (1] m(--ݰ=Ufv(ԁ+Piq:r-d*+F;Ad-p,?q"W;w*0hptk0nH$2!f:JLP_ )G֭45̶6^ `fz=k0TqHM%X&\P(RP&c s T ۯ03EX"$7+0Fr+XXI-!5UԦ6\Db,$,XRF嗘jLTfc@LǙK_UUQ=&m `fpÈ!9GC0Y +T3ɨ$ 檪lء*5cwW1-tH ' B+#Kt CK2a#X])c\Tl UW߉f3c_tq#T"fO VaIw h^a۾!C2زi׭]7`5)%#bR(#*2vxD*ѓVwf8f I֧MW(,nZ7]\,2g.‰~Nܛ\[x[oCv BT@JÒ|]H *tڰNBp2\Z_nq.F ۇ80)̅^FYHHp̒!J%݈CzY`9qcM<ޖzug~ͦ.rx:ٶv*e7Xkj*yB_sJޱ3뽏(sTg30zXXBbX2v Vf)\&ӛYfKW4ty \k e7K2(i<ρ |ӠD fěf \6 M%Փ|+,z%b*KdB+8YYYR:C *h+Lh_+?kqs7Tk^z10̕L^< ^n },R)8X((X/ $%.,j+2>DKlY6sSoҐfr5S/ىGB߰.}MUe2!4ΎN)IJ2HTJ9B͖ .-q!蚹_|luWL"N`+,,Qq]ڕ:fCt$J!9t Bb3YT!^_)ԣJ,f_-+. pDx7KT2qr&MY%љERLOPcI$4P|0lқ_ޖ]-L;ؘg3} NLFuܤD8q Wt,JȲ XLʯxtFdeB2H0Tfegegd$`}=T9]\I*b3xdĒLW!Ub.1Y֛1C ‹fa2:+weedBZ5b!b3.1eN5yrbуqɓo$.Wy*$ISeB> %јT%wUV%lX!OZ}XEK$0E% I$ 7KI* D(8${]YRջReu;[h0 ʼn]yg{II*V*@d$R(w?+WKQR(fz%" Ί G0N( rUUmbvTH8)DkWII n]KfB| ;ϐEN"B0 q. LPJiY@4p'G(Y%]>Xn"q3Q IbD WufW`~  2+U@ 4Q&)0>V)D|V|pJ2g?g.- ?,?fS0uT!RXH }KQ/-!uYE-3g^$ W҆wL18{Y*T%8{2T^6>RR6/|\Kfϖ -nR:4Nj̾ !3~R&JJ,\,Zz-ݼ9o꒬Jv}NE-K\:~ihr?22VDJ UTYJ)e,%NR._p… 7|>4z¼ H1[![H5{l[03Lb B~aTG(@tI h/#G Y"ep Zk%%V %//3oݢGf#xؿv-.[:*C@#*%)IRKq޵DRA y͛tk/df^pd G q'3U-f3cbq}D!)Sh JH1qJum~OyyyYy _xa~ɻ\7S![!_>u `f +6q *2̼O(3SJJRT2 ^{m^.?+fV-R(fJ68"d ̌| hUHbCJ)_tpw^JJR^$H[zkK0#㖇{wS͔*$,#XWsS:H&î9X1[vlMv*@SdW8"Ex_ei'>߿?RHg*pZʮ: `f\Z!M܉Gv}iv{b)W!^0oyf*o”$2k-׾;߿c3TX -Q I _fq3f7}Rm؀R1s#lݱ'ױ')J*R*̅ @ɬevv^޿Wx;KMn DѵJy͸f>b_bȕ`-_HWBǬP(fn;sl"- kT~dQ߻n۴iӶmK~&Oy?ÿ*-}e?{ $qҎ[% ɨ> `f%899^y@Wo-FYB 3M` gpTnx6ᕓKb}?'HK O~?XџOZ$1q~\nVJdYq$1Xv`@_ B%Smz%wMVT nK/~?g AUUUeLD׮]$wɤYu1])0Ϸ2Ã`.3 Jd%dRBZfMܹs&>ϕƏ#!~k,`M?zٟo&M()KRqw=c3_W'vE$-cHtR|!2dK;7ܹi~>17%'ǃ Ofg Ap}>>o%&L\]R".EJqe~n4-_HT`, &צm{MmS嫳32Rnذt_~8oyۖlK\Ud,JF>1]{321ptE ".zdLI6ۛnMش߽w>%ۧ71'OH!$?{h yy˗w֮]]|]zR}D3}s&ubu^G;owi+|m]lg.ƝIW\m+RAF"fuD XVH9IAN* 4#4ңQdЊIRnKJTJ+GuZ : Uqv~|~!jhjbLpk}}-Jb-:&eeG&W*++[=6.ool_IrF&&/]շx ^$I bFvJ\vtv|>??k,9ãk|uZTTT\\܂i!Z_kYVa) sg^~˗7l5N0mR#wvٵ@`fxWx{p[ln?<7wVUm56.7Z #8%}#+fҕGWM|R&Io Vk|/X먳+oys&`xoBܶ:g\J֨Pthbɛ[-ghOU63ob^_fr B ?Lk6fYWWn0魍RFF)u6W Wb^a,}tQٚ-6OM{u\Vt>`f/Vѓo2K9fhT*JIΙ)RKJR767Cz]]F-XXgl6lփIG h- B>A"vh8$I=)ÏMhܲ55>jM@R76貹ܓŕYe|SzdLVEOp޼K]`ةܪ5]w(4l(IәvȠ-̞sF9#̞ӧ/L!ے><:ޖ`EAvd:gfG J߂c0̏^\zRi4 Q>'p#BT;֠g \an[+pv+SZT^2Vp^<(sF O"` d}vOzj556!t ׮p?X;Yr"0v9.񉬞=]Zz16Դ&9+gKXb"w`>A)959mWcg0{b  佭 4꙱Œk-Z6 8xϲeIiGBo&ߞӧKᶣ%%ZZcy:+gB#Ng{lC|bNdҐfa '-*`y=JrjycFQ FKFtmNguof\c Qz<OG%[vlڝfp); p;+|FIo(KxNc+cuNq 7ECx*Ikk䵼b + x}}qD?8`|tϿ@T> C|RA`?w(ZuKGO1WOQhM_V bEN[B[[*;>fC~B { n?[ \Fo 9(jj`8>==MA%;*̑E!C-zq mÝ@Wb.aDZN>zKW_ t%ccjf+y$ 0z\ [H}5 X bŕ6S߷B߹ _zJc)l IDAT8$\zrz3y5 uCP=x\i򽖤:Z.O=_F;KThҗηm:@pߵkA=zx;9}th2 v_[}.7k3ДBY$IA"ОOP~}_FrY ht`zz FF0>h7kœ>0Pg INK9 k`X KoUȟw{kN_ 4p4=9⫸qsX3Hl|C-T wjW73Ho *)BO4`{<6gG/~{j;}pi)K J [}>X:pj_7[7nﭱni5扁/ji)L"wHc"ۥ8[A/tb;»w6zK!.8z|r-oo/Y{zbrzoZѺ;Z:K"IT wZ oefedG`Hmq }mVǠ1 ">EUۻmT 7ԼÒ kd)Pm̌_\Ҍ 2spAG([;V0vvy<ww2tcw{RZDd7c8^+!Qfk.9߅VoppɤɑV_aBOk1KMz5u-환wÎLXZ( bv͜Xʑ呬KWM6Sh\t{vҠk ;R9ixSڀ.K77˗YFS[Zf& `0VJI +Zm3fӗخoK/]IӛLmPNON' |vҁJ;gҢΥ4xLyS1:0$xdž.I*ɤa9ˌf K/; LS]>/i ZH7xϙtiUp\ZC7@ܹfwr,_aX 4 M"w`wưf,bgY8| ^cͯʩڗ\m3pMM{ݖI_lְ [A`$zoasV pc~QoqT9^gjsv5`mmz;[pnj%ΛPՒ[MNӧ-*晜Zr($I$I != CR,R>.ĩNDo9oھsenssYs~G71m^(KNKf^-Gb&=@0FQCnoO/% ֶ{jfLf{f;:pw%Uee& V5 l%G`F۟;!LFtAޘ%W%-.6x{ގ2(sbUNN΢ӜyYg&yΡ^$x3hi=g3o|3xpNJ-]kHh. q4... eeKr˼[TJ'(&W)Kpl6K90[gsTOlooHkii(_lnjj̍::f{b|seA"ތ0@Nzz~|ó=ubl|=;gGE(([YhWsm/9ѡ!ajo==96 ΔW7 w9^֞/Yh~2 nE+!mee=.%v!:BXb#\+B|PHbmcYtc'*m''Gڃɋk󋋳Hr5n4W5̺)Z͉efyg;F/gD_ ɐPDzE[Y/.5=gc^._pLlyB_[+ )zVk//]j[pzxdz nX\_m~FyR ˄):77;qZ0-ޤ: pw !bSTV?{'O՛L1a9\Ě}*ZGQ+5AF+iWζ@.^pcz:WT5-viԲK ki/u}סv~BlRngO9&]\sq{Np#k#Ey de)4WqzZ4ZL@ng$S*"`) {jŃf(3#DO5\]qS^XWp(xlq2@CX~peV-`ОŸanqWH% K>7ugí20#g1DJdḭ x<>bFpSp8 VC_5Dqk,2ƦTC biuA,-1eVj[/37F{$070IX_/ړ #7Η?ƜO6hPjÉ`&EjlKԙ*Kq`7ct`/iU(X2rCÝ3? K[ \'$o345sn,վa8+|G(F3 ˫Y%Q 5pQyk9t^OZDu!_0xֈ?pdjA.[eu^yΆx#i۷#y&EFΫq&gÌpєE}C*X]sjqy"4 9!:ZE"77q(z (pg%ݏݿ+b b˜UO9F <3-$;N46ř՟ѓ1vMJ*;;⨸ k{[B k`0oL{E^7u-pLDmVWl9|kxxtt@Noѻpۂ.+sh)\nOӜ7?p\%I ^̤:#)C.G}Y[GG3*koьUJJJZPc[ɱ&&J т_tIt[EEECJyy~o,øwY4:yɼCp;P[>Ы"{Q 8Z%o&B`M NwBWץM][_hoHHOTTU-؛-=b1xllVͪv4$xs)SI4n&23z \xvnz3oQc"%9eq!U֦xr54WlHh` K)K&+Ž \Qp4iz)i304 nԕ%3yު򤒤`*l-$x7R$fy``X>:9775~m}Քk_ݏΡP04wu~<[֥&$&HL'V@M*9*_ M `HrWcdPɹ񮮶θxB\-4<pDܗVe:Oԧ% ,..&W?{v:Xѻ6xc?0ѼX0q%̦!,f6ƻF\w'ڃ`gNHЧy޺ߕ@l7Ǯ _`] j;r ^zp)XIH;; >ltڗ< hHA{@_}w+IŵrLb;Ê`F"!16̀JP˦4P aF! ڂ1 z>Hۛ66NPojwpjqvmy@[`+_Î7*8:7 sfȏ1v[t1cQaЊmU(ֱkl0iqknnbtu"p\[9ZdCƍ7n3=ןOJ4thq?08&]JӞc UDc9)Kɏ$fIFZcE8~4PepWB)na\\] OT*kf<1%qh=ޗ4281ؕbEAgFx?sf,@xB> tz6؆~A bmFh|4>`J3Z{<퍕+II#>˕X0В0;inF`L9!;1g.9iq|Sz$Rl #O0$͍nt qtWp=.Դ1SZby|>rjѵyʱ{H/373 '{}.]$"[j+/ݜǧ%D(tbk;.0m񣸚.hyrtttΤ;_Z___ҴRkbFQ+nFo.T [ڼ-x9lVw'9ɛb%[l(=/h0Zl"S@ 5U>ږpdo.!`4 ZK:75:4l[hvj+.o/K8"wR7Y}zۿ)4arȡPmƆo6gF\\p[j^׶:!xJV9*jdV>x095558=_bnf{=./Nu^ E!;SnNlAVe=)P#]]sm&o)ι4[>҃e3wqypg`eǨ~e'7Shٿ ?yjor3gV_;\Ρj \Ny<6΍CșL}F(RcY ծћãNnrs3.Ŝ+w˿ .<_qwon D-{\O{S{s]D_ܗXZ)4$>+5҆&wG??͹ΩՉǶ]'(~V=՘R CCJ8 23<>:zmZ 0 Z+W+FƆJJ ֕wߝ}w?q-<^$Jw-Zވ3=Qm׆v䷵񨕊`sT໫=zssة'V{<Ǔ3ލ,Q(h͙Ag qwxhD 055Ym4#X4445p: OxnDJVnPK}ΒP/N 9W;f xg iኳ+CΡ@^ JgUU9 $xʆ3hڣy!k 4]]]Ý!gba30<籉\}%uQg :v@{Mw+ `;+9B4h΀+10kWGWSS&}~(ض_WlV|IuWCvA)}U܌&C0I j`MVF9+ WWGGS qq֩.5}sP%O%!&[bA ƕFYVސe΂P JLz}@Lk"Af3mͭt\,z~<1$In5v$Q6 6gVkAYL,z|K)4` YV峲ڑq<<B^NL5L<;8: MiCiz}>1@s}ws6`KJeFZʡ1YfV%kppdrek&ZbNL(5"qsb! JIN.PQ*l x7`! p( SB!9X[,X*7gh#iqbľ}SSzw l75l&;^*rHt4RW;1$l0g~yt92{l3`3b@ZDNņZ1466grz:,UV$xB+Jf-@uC̬,c6|ZymﯩJNm_ ;g2{)F^J$xwͮX3 K5#PfG~ykZAZR*R s^R'o +)%Oi1YՔl<7bgf73룏6YTt~5kk-0[fK0$)>A,6 J5aYF揲>a̤.}: Rks_+ő<,M#4lKrhX6xÇ!> {YV)ßҤy.6Vd9f^.!)f_gJl-,m1XJ0p3q9K88Q~YWyح#\pνb;=w@&Q  A3O$ux93xqgXxA0FUJ1~؀Suߟens81ػ3WBG$>vr/,ưJjƁ  IDAT_aBlޠqzA^f_o0,ߝ_3`}AqgdeBGf R$G#5n#4b( &eQ+ ܯaCY]`Y_nngpˤ:B@ZF nmPیF.{j޽|KsPX-\zsO)t{rR3,y j^`e/㐆%~5^t*ZؕrZ%SHɯr3k6~`V0hl=4Ҳ8- yFR'KE `[[oeO[k #A0&l~4 &Z ӫܜ V^LA,i<Ν5/#F`A4 hdjl!4j`lg%L"w#fQx3jYVlDȥ! B3h6-V* qҒ J$xW""EKAfrIX <Тhxzs$ɳZ&_^VR5U 7g^khk56>Xڴ Z-UV#~g1\*7Q[h$նF 0Ie_,g*g 8 "UBbx<ny`6 |ٴ %G"?v|(3@#=D1|8.AAx t&?! \tlLLt,Dնh@ DMx#j- ,F=KQ)} il ިb,jls敞eFs<\ 1Guz{>L"_WdVkPZٍaE= S'&nX%V^vbU5p@?>&&FƠNDAghcc¸ (E-V-G ` wƠB񁱱ћ+0&e.:ʷc*W!břR(H-DK r@Y Ek򥨨h{%]q `5|!P^Wk,RJ9Ӌ {q `q2; ~D(ecqL"_ 2Dj*^RPMᵂz[ q~Spcw|$3Zk4 A8=I!2c*@ E8bogZQ5I(hjyV,*5F/]1,AH Z>9Nc '߽)p,+)6FE( `*# тkS0$ ѩɦ J (B yep`v06pwӻn7/XI8Gł)0-!EsBBBy6&f xvV'48NF! p~P+Sso`G'v1 bEˣ\ `mtfjtj`j7F<")|q=Φe hhHlNvp7c_n ?ޝ /\[0)$sI Mx}U ٌXJJ OVh5F{0o%ʄ!Bo2mE]Sg:*&KR*QTlLluE nLJZ3ĭ 6r(ZtQ.+Xs1qh!966_t:27#;D4ʸ(~TJMUq75nڈƈ ߖFm*OIcq|rʀˮ!,\8+b73eff㮟ZT1l e0h}iƍ6 񷏖4V65uJVoTUTUx=2]VjүJ~u ƚr:;DaU=v ?Б\^ ոC[Pb:Aoꛚ|MKK JJoZYa썅߬ `˚+weBݽm?_V{̀;_ FN3$J#`E}a eֿF^TT_Y9]:]P2}~bJ}e o Ό74re75-eL/mn&jP:C:B"duv`vAǢ 6\YY_ߴ4YR2 V6DLDUi4~7l?܄A]t5w wE_lnRfgHB.+gW@24+3@Z WB}{zd)RYY|?cY}44~ F:T[E_ Al3 `vD) *0.~߭}`Fvb d:EZ+*Ko\^ U6TB#4*~wpz-<TZP :C`;Tƽ Rl<`9ˡQvoL".*O)w^(w1}yv;nEXj,6&nڂMIӕI҂ӝA lc !8-+ҪUjw]o`Vmn쬚"w76vcD_klrdzr))W.=:TBQ36TKjQWmQ+euG]]&?`9*ۍJd*\weKp];QWa-JJ\jjl_ZZ*9:YPh(މ.4✰` VYcY샃Ŭ`R}%O 1x.c[:8@,-nl.H4 '%--ML_\ u:B85b浈âlB+k.fT$'Da*?v ht0Ө| cmmw;,v&z|G C.-^j_ZpN8C!̡[oX5F&Z[YYfרU.{E}cᗳ'$IBo3SH:OJ[I ݖKd(\ɕɕӠ; E#Ds~Y|< FzO@>RTX) ﳂ p}P= יSpc1 ࢓\uwYS7sKp?& K844K_?un'!{? .ԑc$19'E\xHw)D~T44*|&G` M%wTV-r',Ǎ4K]fzp;rKב!D&s3J(߶D<!U&6kC&%c;sL"_OM{Q`Pgs{I7w+ `\%Uswoa6Wl,s%.rbna5-$u8OhFfN̶o> efN_bW- oF`;{+EIkBAai+rX k`\&Gp1V.7L"ujѯD$&I0L`D" `L$&I0L" `D0&I$&6grT,l.{~ ٧S7o>㮋m܍ƒ OV>8_s1|]| pEp&ԑϰO37cfyNvMGB4 7b$ɪSp1 `\J_anyT"FN~vQڛeA2ѹYWuᄉgQg=Hr<+"BH8 '1Az/0=>v|332'g؇]an njTl !/,C=rhr{BJ_\\[$.N(.{~3#\0;2 `;E(,,F㳧k`JwZ?L.03hWc# 0T&cy\d F `J 37+Ÿ#NRhxGSh&I0L`&0& `L$<0&I0L`&?YOa?m@w쩋%chewyf7/?~_~vDaG_x-pLs7h#Io;_qW(>ķ;+nb`sX['3v쉗xG涖mw R_fn+EO?wX5kR^T¡[OQ[fI6zbKv$mw/i."_^G{4ʈ-$\Yv홼'ܣ}t 8s7}|QܑsKo]=w/[Eb0w]{kEo/,.>1`x9kϞx.0пh} ^(ĥKaȖ#H\SB!dŸLR(fl"fttoZi5.Ju\©<:B0(  rY?+,;U…;c܌=û՜HqZZx}&Tsw? Syû/Jw hJSG/|V _Vxߗ #E2s{r`vmx9%ݡ_I. k[3|l,GM2T>Y`)) ī*(-=2S^fyn(:ih~6 ;0a=q*wxA,%%`5\Z*2q'f^b[ d \B!;| vg\6A(X pKcbpWp«fYA Ss.oMǛ 2 >\m"42;/Am_BXQݺr'y%e|38uqvkj. $.b[`J)#H_|<77[*>'5ވYe0%Iˊ'ODoDݹd WTtWŇ\<'s>2kwn2'؀5g)e䄈&̾Qudwbv$J 盛(&_D" `L$&I0L" `D0?L" `LBI/L"#0\3DG,Ip|DG*?&,5`D0& `LH0L" `D0&I0L`&)26J3EӻsQ^:ʠI 0D"I$&`D"I$L"0D$L"0D$L"`&I$&`&I$&`D"I$&`Dz>&HS p|,Dz_[uQx<1>b_[!HL"0D$ :t_D<Цa)>(&" C?a)rfС~ѡC=JiIDAT&"`j/'#"`iʬ7J_L"E R٫y"~IV@ako(?4+5٫H.79⾉H7q#ꇷ"\o;d}(WEt IoDgǡ_hD;nޒ-p8ׅ_ܟމ7w: DzQ?Sz04Iw:]/kFZQZJ/ i] O!'Tg)^41"x+},FG໑8u_ט I߾ыb0GcEL:P0 LzSy࿵Oʞ< Wr|@78VXlE4GXг,!O. $R,ЇB?$R,p臺(Hf7}0IKIiԡGO$RD, `D4nC/aϧ _)b&H0D$ oC"R?$j[IENDB`Mopidy-2.0.0/docs/ext/m3u.rst0000664000175000017500000000417712660436420016162 0ustar jodaljodal00000000000000.. _ext-m3u: ********** Mopidy-M3U ********** Mopidy-M3U is an extension for reading and writing M3U playlists stored on disk. It is bundled with Mopidy and enabled by default. This backend handles URIs starting with ``m3u:``. .. _m3u-migration: Migrating from Mopidy-Local playlists ===================================== Mopidy-M3U was split out of the Mopidy-Local extension in Mopidy 1.0. To migrate your playlists from Mopidy-Local, simply move them from the :confval:`local/playlists_dir` directory to the :confval:`m3u/playlists_dir` directory. Assuming you have not changed the default config, run the following commands to migrate:: mkdir -p ~/.local/share/mopidy/m3u/ mv ~/.local/share/mopidy/local/playlists/* ~/.local/share/mopidy/m3u/ Editing playlists ================= There is a core playlist API in place for editing playlists. This is supported by a few Mopidy clients, but not through Mopidy's MPD server yet. It is possible to edit playlists by editing the M3U files located in the :confval:`m3u/playlists_dir` directory, usually :file:`~/.local/share/mopidy/m3u/`, by hand with a text editor. See `Wikipedia `__ for a short description of the quite simple M3U playlist format. Configuration ============= See :ref:`config` for general help on configuring Mopidy. .. literalinclude:: ../../mopidy/m3u/ext.conf :language: ini .. confval:: m3u/enabled If the M3U extension should be enabled or not. .. confval:: m3u/playlists_dir Path to directory with M3U files. Unset by default, in which case the extension's data dir is used to store playlists. .. confval:: m3u/base_dir Path to base directory for resolving relative paths in M3U files. If not set, relative paths are resolved based on the M3U file's location. .. confval:: m3u/default_encoding Text encoding used for files with extension ``.m3u``. Default is ``latin-1``. Note that files with extension ``.m3u8`` are always expected to be UTF-8 encoded. .. confval:: m3u/default_extension The file extension for M3U playlists created using the core playlist API. Default is ``.m3u8``. Mopidy-2.0.0/docs/sponsors.rst0000664000175000017500000000172112575504731016542 0ustar jodaljodal00000000000000.. _sponsors: ******** Sponsors ******** The Mopidy project would like to thank the following sponsors for supporting the project. Rackspace ========= `Rackspace `_ lets Mopidy use their hosting services for free. We use their services for the following sites: - Hosting of the APT package repository at https://apt.mopidy.com. - Hosting of the Discourse forum at https://discuss.mopidy.com. - Mailgun for sending emails from the Discourse forum. Fastly ====== `Fastly `_ lets Mopidy use their CDN for free. We accelerate requests to all Mopidy services, including: - https://apt.mopidy.com/dists/, which is used to distribute Debian packages. - https://dl.mopidy.com/pimusicbox/, which is used to distribute Pi Musicbox images. GlobalSign ========== `GlobalSign `_ provides Mopidy with a free SSL certificate for mopidy.com, which we use to secure access to all our web sites. Mopidy-2.0.0/docs/Makefile0000644000175000017500000001075612441116635015562 0ustar jodaljodal00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " text to make text files" @echo " man to make manual pages" @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: -rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Mopidy.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Mopidy.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/Mopidy" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Mopidy" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." make -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." Mopidy-2.0.0/docs/command.rst0000664000175000017500000000644412647245254016303 0ustar jodaljodal00000000000000.. _mopidy-cmd: ************** mopidy command ************** Synopsis ======== mopidy [-h] [--version] [-q] [-v] [--save-debug-log] [--config CONFIG_FILES] [-o CONFIG_OVERRIDES] [COMMAND] ... Description =========== Mopidy is a music server which can play music both from multiple sources, like your local hard drive, radio streams, and from Spotify and SoundCloud. Searches combines results from all music sources, and you can mix tracks from all sources in your play queue. Your playlists from Spotify or SoundCloud are also available for use. The ``mopidy`` command is used to start the server. Options ======= .. program:: mopidy .. cmdoption:: --help, -h Show help message and exit. .. cmdoption:: --version Show Mopidy's version number and exit. .. cmdoption:: --quiet, -q Show less output: warning level and higher. .. cmdoption:: --verbose, -v Show more output. Repeat up to four times for even more. .. cmdoption:: --save-debug-log Save debug log to the file specified in the :confval:`logging/debug_file` config value, typically ``./mopidy.log``. .. cmdoption:: --config Specify config files and directories to use. To use multiple config files or directories, separate them with a colon. The later files override the earlier ones if there's a conflict. When specifying a directory, all files ending in .conf in the directory are used. .. cmdoption:: --option }MN4Gb/-7k$d6&h9}疷U6o"+X Zb9vء,7?џNßſSWbHTgsgTT=I)D\مZkH}-0Y;o%h\xKvoā6Bs#=`0xܵQOB )d!D̀i=HLWYm}qIUV&89P?) <s뺦iJ) 𛋧NZSSy^SSM"z7p2͉@P]\sG'Mska)' 0=0PdHmM HrR ky… ?я{ォV6mZQQ@aIqqUKy x)NڳHX^7-A/24+RйvOXJTTBȲ^x_y} NFJg2is)++LӹXmȅxuނb;-9!1 A7wEXpJ'ã.-ffN"sOt(dA.0=00ොCGU/K=Ѿ73e4>rBrut8a欺SҶ KKʕTR,)+۲9/5@H-"Ө,j9Z7gno@e2w`kn1KPK.5n3DM zt5!+-/șN/h48Ϣn4CU7q*Qyyyqqq*:ZSd.M&Z CNYuI%Kށ#F Zj"%`81$NԨ4o44 *++GFF|_jx_wށ>x⢢Yfm۶-(أFde͞=k6ZzZ ڊQfs*E&b >+:ŢWY4 +IwNRs>}={ q\#J-oyRUS)0783\SʌKX֚!K)HVDDz sa 8../ih7-m !S>g *G->c);-4WLg2iϓ9R`T>z@X!ȁxwS]0}&bJJJ …iδ" %mDLz$ 0,1Ԝ*0 f \'ု-j~d|Rjԩ*oZ[[GGG5NJ$W@txwǷFy?rS D!,$21Ԥ8c!䪜fUWW~j"[mjdv)[m5U1kU[32V-[.9kɬw$be3~5#xg$R>io4ݿ[[뺷rKGG'#N4$###>X*jllܳgϔ)SL= ~ Q.f$X`mזFb^JBP!j$Ҍ0D HLjfmΐc@ ]+V3gNoojkk.\iÇǜZfhYfx GLW"`"'Q:d.SdqqpcG[Z .MG'0SZK).]zxI_޺9*cKl%J]$5\i- Zh/@ Lf4h4zС'wyebqHw R$F7K?8:nuDmC#3e8g49D~?WQ.o-[6o|W<088y˜smu71.B dnq` \iYhpGYmMY"Rȴ\+3Ӵ@)Xb qdJ4! cR*ۿeYBW[wc' n҉30\B,0 H)NYld䴲 1~eVY4V_}fOOwF1B4Bkv3޼uK,@ 0 w#ޞ5 ! ڞL x# Bz,1t:YD">\;;\k׷hѢG}tUUU|[1 1B9ؚY5͏QڤTo. .w A1S1ɴ4&9C"@4cѹǥh42yei16_zȉ[1vls_Ʋ8hEJR<75Y #:nmjkI;y vYQkH$v[QQDžw+S`MMMKKڵko馗_~yJ)+w۳3f̸ #GL>[o]nȘD%0ƤDrjuApc;F[kV]q=P'T4g&eS[T%LC~9yNA8zhDJLEM˔RћC 4eE4CBԮiB'0XfDW18YCc.DDD1c"hB"`\7̓|ot٢g lxy9e zq{u /]:)9GցF 'u(jԉSv]sϬ Q=qJ^~exTxC9sZ)0+GtL #@{E,hi%P}lb\͑c iΑf@tZ$8_җ-[6w\!ĉ'֯{!C\$n8ѺG"e)0 Ӕn;\Xk'WX0%;tciy|ss'~LcSVP#q"կ~7ߪ7m޼Y1~%cֻ3~s9~饗&N@k9sgc&\ρZ=!MM DP2LkKY K) %Q/-551 u]m3g\/ P7h tquuiE y*Fw<$zYiSc򙐓$Ҟ*i .f9M6@473R+Wn߾ݶŸdUUi/*).v]ѣR`*m f:m\q뛚r-ӧo}^ "ҤEs>8 xANe^@ <2 %d${5 1e泼+oOHd A?2{DWbޓ,˺˃mۜs˲\&_w5i]N[:򗣏 {S-?g6X%?ݟCgOZ$\#P>CjM@54?5'E;^oNcbž2^g# 﫟y0r2$b'W@ r3P(,9h:rԇy'VjPJr4hx0Ȓ?A3~1&m#@ *uѰHkԊ1Q֓!"|[ZPGg;uw#2igՕU@m; r}\Y'Nx-mٲ~_ӐO(Beko!ae:"BNeZH3J#>y( _3Y BLmhZ{sW:xo|K2!rfHLg=6vP4Nùhsֶ}K.!2koo/***//u>OsW/t@.#Tް- O39%B^؂`a$C #gW~x:K9*9ݙq{`pp%4CNΌ35[;XG: O!dɒ\7Ik]XX$ Ư_U<4.'. *=DDi 1$^L|jϓ>]p1oR0B>%eum}΍gil@OO,X===Tʟ\$tfΌw~<8䷰| OrF#͵:d2>04o`֞uww' Mgjf!d3 ~ݝY*%U,9+sn}!L[Knܸѧ%VJi1Ǔ C ,ܿk@"&pЗg?٣Nrdx<׼#2|̚>3m@44ɗ^xQyYEg{Ǻ /'c @w# 뺁@Y_3P]]k2oon,PUybCF=3ϻpI/ (Wҹ.+^z uG?j`?o>OVէ2InȮw-5{9kϞ=`зdgwn7{g9cΌE+}V.]ƴ9_{7iD[=لLƏrv0L&SRRN}0@ `Eٮ'zmN1,g{y%B)9,/IȽI"1X? l`\wo쐃U8єa03'5 %N}M:- IDATZRTԳ/6Ι.|nY:**+Jt̙YvVAI<*UWt?s]7p;::8$X#nL08?_ x|s۟3jvy[j'-R[y_<`7w N8 OUF܈ 4"5o|A>{OpF_L)> @k@@$06#Na'a.ł(ȓ``b2Y7i)APg"D4>t>ͻ77h/NPOXBKHy%@_p;={AKĖƹ3!eT292 ܹulD$fNizm d`: (Rd43" ݬ"4ā)pL)d8F2$H eGhX2ch5h:`hp$Ɉ -\䠤 AsZS۔3֕ rAr3evȀT}<̌_|w+cH}}1.)^'ToI veW23fBX\TFE~jϿLgO0/_}$Evaf ЈJI!$/ƹԤ k5]=DTMЁ ^ '!I F&DF7Z+ &%ː5>CR 4ixo!v>Ji79ǣMM'ZT:7`#ޭUQ2R*JF";wR{vvzJe;}qpR*h0NҚaޏgTT{k_@ H/l$sOWp/s]c cºh2vI+y50D9όqsra.!EӘf4h/8"RҘ W4q :k&ZQPWDb}ᙻVmWRDZi"PJ3ZiDԀ2 PL:JJ!PJ;hE1'x^B{Id2D"DFcD?hBRzsR|-I m4q,>qZz8P]?ۼq]4:@KdPYYos0rlR8ku|o44O GimtBjHSNd;t2ҵ'yN&c;ױݴU^\׵F*r82l;%r]736ksRnG͟=S|`k;6߿Bw#/[^_YOvP.(5NL- @u?w\ }oy yjk_pʪzaSfe];|> VFar8aL#qOή~`i|h[m)-8;S$xHO2add\ B]uU8yr9. 3qhd R3f ,_s5?*4iӧΤ3^z)clԩ#JiR&T}X⽩A2W^<{{(ەؼys]]ys[|^8^ښښښڇm;vڱcǒ%*jkj|ÇslێbH$DD"M= da1qrWq;.Vwgh-.,<'A L6ӟ-[n8pp޼y###ӦM{ǎw_4wu7=5c̚J&?RY}3CiMS(3\ V JJ㚐F$N PKy h2_ֿOy^q4H?Ѽ_{^؜&v}o׮?mO@ vCx"9ڼ_7:rdֿFF @/pّc]}Ů;m~e8 rWokpʌt`W7~_ߏ]XT2iѸ|#ƏມakDR~wGo}?k>Oϙ~$ Ǣt* R3 dc"K)FbQ1h TF)P+R ax`T4F3xxx2\w8kcwULy׽p9Օջ9kmm9qٲe]G+=ϳ,H!%ӧO+(WU􏸮+= g̘IST2>J)u Sʖ ZJ|L: -2mzl$bVx h0D<g 9\}ΌD"aΝSLkjj \pA&6mڌY} s斗v UVV544tvt+%{x?cʹ|Y&4Bs.@ض!Oeey8|F 3;i@#'$D@ ,!%&)=)RKcJ>ħ>Bڕ'NwH:`e<)^ _ۿ]֢ ^V^^T$qVQ^uA'R2YVZ|;p=Oq _h?v1dLaeMWQіLdv&-7JTäe/+,(ƠaWWUs'?cρ;>Y2-) b"Lf4gl8L,0a8644ȯ;wN:Νa\z'O Lnhػwر¢ʪHCtww#c0=vH_h[[W\/_hqiiآGg/Z,mMè(jniٰa _H$===S4 vskk;K~U,PRZ 2@`, Upans)@mĢd3e@͚Z8hb 0[SS} 7xSO=uՕWzz{JJKxYYپ}9yf'Ycd%+Yj`#Ln"{@$ޜíAl&.]Ձd*DZNqNڧ~O`.ͯ2,bxbM]. Ae;"XH1~[7DE\uᣏ<}g?7\PQ-^c?P@ Ke\?/-o߹O#~w͗01L̰0/mx?0/Ѓ74WtBv~||vۮd2TIht^h0zT0,1N:a*=ZOMm=3ͻ_{&T($(W[[RRucגŗM8qVyRYyW,>%=[hw|Ɩ-[N2!/J# kPl ?RN0\jB+@Z@sKA:yJ6w6.NɌYs@fACRED#Z&"`,DHa،wSJ^h{ou(..V0N;ڎ?8 կ[oEl۶,듟}~g5޳zY|xBKA!Ȉ4#d- M^r֞'9gi[5B_?[bXNsadӊ@[6lXB*FĤf iI}>teuQ;XN]p=kxccd CXbƌKy2]- BE/ܻ ^xHӪE܎s?5wEߵtk2]{YR^D./+^uݪ) Ktd#/_Y`aK{ǑGh{遟o XI NYyŁX"љ_]*ظqT.*o.s }<$(ÎV<Ю(耔'{[Kݬi 4ͰL_w4*Y'=Ȇ`b/RöS?%Cp]%`ӑNcׯpaaZID|i>5< `c{<5g- ػ=?j=\c:t)A+.{@ ,kޠP%0dY6'+=O:SzNgK$7 !l$[qnAG/<[6lܮ4[_غq޻7O1#Nt:ۡd24ѾȰp@fVA*D"ms=c Lp|h$iBUR矵[rrn()y/kk[[Z/lK/ q]Y޼oχ>g ,|;w˛(110" Ld0 !O)Hm"ȊSN BzΔR#pܔŌcMǛ@ YVs3֠6cM 0NPs2ȹZjbɘMSRsaj9)Md2h2s4?W1B1p})\ g'Ss)?47Dw1KFʒlS) ?ϙ1tF!1GW!cuVmzPݛvVԮ~۶m]lq&VCC")Ƙ 3<:^k}̠$"XLuEDN/I<8}Nܒ"+,YluRD X@L9mセ8(&yc^@̙szz׻|?$&WM<} ,QVqS)&xE===xsl;?5"Ƌ,JeYYsRޞT1?q?xxmGw4+_ |螾s8q1\re6og|jߍb!u-ҨE1zp$T@⩐[f3)˲m p0uxg{BKaE*RnBXHrx`#rLS =&Dm57LҒ-KKR԰&wH\[@+ʁ3ZqJMJ Y,k]ʿK_G>~@Jƹ^ oD/uā;36~=PAPH.Xj-BaJD*=ԙƪ^\_=jҹs7~Cg5@yy@sAs`RZiF-!5 IDATAM.'+/-TzJZftc)Q9stٕ 2+R%%So߶p쎎mm'O>sfqy5WT>xo:x8>zhSI$Hp[Y׷@/t_7$P>aJrvGE̖ĸD"iS(,F6L#)M1 \%ג8ȥ˅'%Q1@k)G N%9vG阓-ӑC3! ,F! *bȄ#FǶ@ ,$mS% ."_uy˴BctFp 8F"/nVV_Uo(A_tYuGnft$ٍrwˋg";wIG Ѯ/oywPII  vm{RŽZg2L&5㘏3nYVOO뺣D ι$:߲+.EŶw0_QQֿBii>Nlٴ%m߾]M c9'M5(CWC8wVɡ=%HD PGJI)C[xR BjCkĔ H eQ%<:fLb1~Y3sd_ '߿_K:w(I#p΂ oY~`W2b/={RTP0)8TOOς ***oYa|L eYWXaī8ΫjsZi@ZKMε a gc0Ҧ4I)(2hh -NR---DK)p$`{HpёnZ |h[0>w7\7^|f8{^GnG_,.Ʉ᠀Rq:&hbUmOƖ WJgH]$A<"ynCA ˆV~zC7_)\Slwnx ە7Dc@?qs _9sXxd$8GD'9B nBr`,}xnDDJq8Ώ_k_{I,+eEQdvS2? 'J Q3=ql漍wd)TJEQdYUf>D{"St]8r1~cfМLRO2'3/Lp00 ?c88Ad[~/I3SQ\+dHH6r5/9mdBmJ)˲mۜs3gq l`qlq0 ͱqleTJkmY0LXB!DrG}5#q,ta¶i@#Ť]kq r(@$dq!&(F#\ 1Γ&0o,C@g \Nj8 RBۨ,qԠi C‚R6BP$DbȅGę 1cz˾#F쌥L/I@iTT8;g֌Lu S,XNnЪ+&GAn:\е\uwObu7] q\7}-9iRS:;V/^ "d[6Q sTnOX!Wzܹʪ WTV7Zڲum+MHS˰`딒ֵuk[ZZjpavǝeW.]RQQ~Il  ܦq=όKpÍVhl.؜5@ЙW~^1W-?UIdu,f!5lcH)]M Zww/87;d2TJEL7Ng2JLAG˲mF f\!Ye`2ia}2ǐtR'FL%_-b T8Q]7wq-c_}%e_׾3H'ա( B xdxc|+#RƂq`;wcFls'`!j0"+I\>foeˮ\0~r(|U(۵+)D{Wк>V\˟[gu\~ՕW^;ٳg8,ևB+7-M’4+)Sqԙ3MU|TzM?߳q˧N+hhqF9ȼ0 m>v[n^yusNwzxƜ<(Gϝ= ##G+ ByiHssu o߲eZG`6;",q)5JhsݿE|{ʤǎk5{w,*;z\/Rba׏\c&}q40:I:|T}eeYf776h4 n4!C0cFA"9iif`3PwWwEc/(ɽ8~[űK`梏+8%1%"DfL:l,Hy Ya>! I20 1i.f̪}l C~tEt8 )**`  ` G17c%H)A/NDNs\ޢ(r0 3BFky 3;N7G .Zxgϯu5iuvǏ}! Ғ}*ޑQ=akVy/]~Z74?XcZˈ.׀X ?=a `-};!șxW8'# `!SyˏhZM;x;rF!rΆ[п18jߎmb ~[bd~RY?yO~!;[_g'OZ+!<~đ&_Y_-A@7j/r|{6 r$YCofQ#1ӨcM)d!icLpedH(pd(!0 ؅8.VgΘsI@AM)^i  , iб'W=+B`p6 XŠI-[)+Ҝ +i!C,Rز\2BR@)!qJEe‘1*Bdxbi =!3rH8 Ɗ]zDEIUE<"m1b۶C>R獶km2{Lp$a'&gR=6YLB)U^^nP(HxXEjɟ\׵mqs<3c=A/{2s17!90 0\Ps-vm~l~^eG_WkoqV 9_>@ .)$&BE VUXܼ'羾gٮiSg(MM7_lケ8cw}쑇ULHd+2_*&M^,U| %V7cwf̨+j}|mFdze/sBL$3y1VYQ[WfbAhi9{I۲,a/MFD9 )*hqHR1"c86jfvJ_xa PFIz"'2s+S&AQ{< $b,@I#"8r@mԯl;I^eRQ1׵c(/t(W1lg|\^^?VmN.9(tH&9Z2VE(c@̤0dZwҶQC(-]VV&UVvH+) [(z$G]RҲ0?V2F-,ͳ\<aTf*;;;3hyll˙bm0,KJ( (!L 5b6Nc(`mv!L)` @II ctù쁃en>UGگY4͛/>}3O=N]Vf/j5ÎV(cYY3z̝[7:%̿b MM'͛ܳ ˺R%>oShK-s@"EB(ϤȘPj2n|7M1eo("L!RL)R3NZňĘ$XD0Br}F` h-cE+̲LIT(Ժ@xy,0tdSgΞEw.X$ ;k1kˏ冊R}yQaDNp)oROϡ\j;;[Zce\#U,{-aڒS'O':tiʔI/D/[#O}s}}}zho/iهvm5|gS7UxI'>?J?‰k::O˗_H;ԭ!hXV\t۶pt1|ȬQmf5u~Sk1%"%! 72%%jdq Ru]45E0 V ql;2*`(1Rf 7B2 54_>Í { _~_7o, B.DF&yDZ@-8s(p.rJY,Ca6Rą+D[!$D !MB؊|-chxiX 8P0:R^:ZK"0(a<<'ॕRh -"_߶" -YЊLZetO @Jk` 3ቹ9$` P1ԊL.h<.pGZEC/\<4m;Moz~ |G([J#0( lfE@ 81a%4ݽ֬-))5wѣ;o޼F|/ 3JMqپ}M%'?_r5 uvOXVl!?t`?ܧIPsG |7Gq?k%M2z;۶/|܅Ul?9G/lx2}TRIιˮ\񮮮 RJMXeP9rdӛo>}̙K*ّaKX,#{yX OmۜP)}IhZ{g,L:f H$ABq52KX)S anGG0u.SJUTT r9c B'7$F]j5< 34Fya2(i #4g8I%AEF-UJ؏]l١N>jժ4E} B|sϞ6v+Wh{Jou`II/?7cIm҉c;wl8wEv"eO:yPYIڵkG О{gs^mkj4^z:-!n.yc{g, )21ZQ6NRmv%R̉_}5+<;|s9ڱs]F?w͐ ضkOBȐ{m|)u8}T#DZ[X5wGN憳 m|dhdhhȲ,z79{Dʶؾ{@%PR1.%]&[97cteFA` yF={6IX%&@Z:A2%˯vjY9gYr\'"d< R/8ab{k[k[Kkk.pph8WTT0ƊGFF-P&c8P3P%`HCZC(csE{..a4xm-4㌌ͨ H312@ SZ1diE4 Bg tHCt9 B lf(z, 0q"0F Kք1#'F@E$)eix>.aBP qH+sP7@CFΰ ҼF @l+!" :7N_nWdh BL7_pޙSvl/]jc#e=MU奶taЩN:g7M_ho{c<͆dBౣGNl (*1i:I#u;e:cb2$ Aayւcٽ4εǎ;yiέЁ]wܕ/3_o=g^g{;!|cZ쮮8 Vz$,h2 Wa]~#犘GH[p3$HƖm#N K.=vXe)5jiPOBRkdfY7USIJT$s((~۷n_=ڟ;nXzmk磒W| |xbt:]QQDZƭ6 dt셵KZvբ ,\tA()L&jժ{rO=/I!\5d<|h̲nh̫MkmNs1s&cĂeۆp eY~C\я~t]---}]6l&X3f9~5pq3 s^ 3KƜxvŗt#ln$&0d<ޛKӥFa92S(- Èf ";E6ӚR!iA  T;VHXP1q1P1JI !RI Cr Kd̸9#1 8cudi0YB Ԧ94r Fl_6Qd\V]-\ڛO۩]*9ϣn<̛'O:#!`bK$cB;ҩևW JT*U^^d9̓p=(Ҩ 2Lyi;6I cWO8<4@'TUFH*t&#\85<UN, CU(J܄Sr$纷zۋzږ3U&'۵gϟEWln?06ceaT@FeUUU'OX-^8^1`}?gYN{9r`iyQWCd Bmx)l;rz~ [Z)jllLy_mذaS't"X]Y3UO祥׿^_oܹskk RtZJy7߿Rk 3KRʬbloVp#3ewgZ~Ք)SIyg63$؉ytZ;w.c02lD&g.( KLGjeɀ!OFDF%#sALQaxMjr.U*He&N(gs9Us#~.4!g??;vx.rGOkV,~*j #m}yս;77mUsҫ(u~LQȤ*F<;g{,:ȫ[L>znq›;v JTTXSVX+2DRSMv5"iHڴv(ȐcE y@!XBqҁb(NƊ!֨c9"5l)}1 X)YlӅ $Ri4cfoR+rժU~~phh϶g񤩧cu-{ 7|][K(T .pؓW<^ekosmkdxux]{wY@C!He-" m=z4#Mj߾ݝLIYt-/J^9t`6ݻooOO/P i\יi{닜H]}YŇF!ZSeeCC&T(76~ۇx5.x:>zp:]j(S\R(6mZm۶%jmif555B 6/35Kf|k_}~+ֵja0u5ÅΞ_s>;o+䫗^s߄7C 'zJ 8Sɧo-0Io{i*V fa;@H嬬1CԨ4͈&4nu8sYJmI qAȨnɬ"+ypt&HsAYsG04Q2"4KD@fL" ?^{ k88Q3qxN=gƫ ű ڣ^f0ؚ%̞=[FÇR|ʽ#['y^rxЉY3gd2ޞnzwttu950пzGO#!dZ`3G1}}/ƱX\TG;O7iH#2QEH,D+JA3Apa=)v=7HVbmmJ'OX;9uTGr\}'W^8C2淦XB-0ͧN+FAYsQ nRܨW)ۛtT*Czрm]rDf' =2 Rjԩm۶c}cjQ|h0!EA8mr9D7^,Fm`ZsLȰ+cbdJJQy3МI%_rjnVk%^{e휙 h~mZ[Κ;[kRxgo>p.Dsg[ ڎ0<h=`v3׮\9SY^!8۲yR^ٚqÍMҫV k͛Mav`(S<֭_=M?>4)[rӲן}:nQFML.CFM v@Sa,P 5"^ ZeӀLk!bf̈/#ѓcG!2FllmrD4:ɯѲ%jzwD a#35"їIl(9%~_[o'?WS#G _xO9vXY^1WʊJx.WP3fnٲmۖͳfϝRSp93fNG[k["}H\*H؂]'U]=x|Μyzc_W\Jj~ZIǙ̱T-Mb!"KoX=q9%e@Үs,@&$=oB+(7wڛnx㟂([7cv]L<@9jҩT!㦢̲9S -2fLyIIzqtGpι@ƙkY ,Lx=(2'a*7f5˷(+8XCCC}}yĩ'Obԟ\>/xS1``8q3W &8jl9/ "GZk-_{l1O9/l|5S|곟v |o&g,#ҶeˮlުUZZ<֦-K,t;+.[$NkgΜ~ElVmQٮwv}5 EmCóg̤R_{5   ۶6a>] 4 l BA/׬XV7%fip$ ~vN2 ֎(1 Q1窷{rBYLZ\g2QWO\9Yfݰ{%)A ml$Sm .&sUD,\4bLP;`+ ^lF0sl9@((( ,bZ6Dд! 4A, A6 5XcKH*q,㻅P*P9`t1RrXö%#NjqKgsعpmixƟ?_*8[.wlo|-S%eCml|K_x„CuVpUӉ5Ul?^VZ~ _>7Ŕ差U(/9m(otΦ'+.x##~/>ɐy~Rs?f|$1?3Lsk@ܪh)/d86XEqTҲIm a(XIsC(rmc"纮=06!q{3NfDa\r"Ɣqs`AdiEtI"@k'5IvJAv͍'G!Cq[֤Jů:}*S@1N .ܵ=,{>3l;_~b[CIT:O~6IϮ|qqH迺EHOt&?D̖`6"CP4h_ kV}b[ Qړh<3pON"-yD>mWFМb+΁mgĹTV0Iɵ ,GX:9㬨ȶ F"5ШI$㶉~(nj5)b @'#$M< 8ʩF-? QDhC̡ӌ@hJz3M+2Ķ6" w;۬]o„D-[lWv쪁 럶LF@B^C}6;tJKfLpi%/ k9_K(\v$I,-)O~TO;w;b38kl/z}҅ ]誽6 :k?3?ˎh4iO~tkSy EE%av8 8,Z|{qee ge,a$}Yy&700ພe us5i1hfF$E#LHI`2rPC9e(-KN+B"MU4ٽ_gۮ]^y3V)Čg#h[;)RH³sqfNۊE ػXĢ.$W[\8%Nn77ωȱVlW{+@}{;ef~`NbOϺX9;3-J0I)$^8LH(, C L@B 2 C'91gؗO5W>^1@˅A) 9K78ASDxFPAbpAUP8!R QI {nyeY6ԁj 2F\ljDD 1%n8AA$H$͢p)!\Pp䀀aQI@㍢АpԑH xAF:@KJP1 0I$ c(<(HA)% ephR#D!é?!Q.2sx3'(\EȤad|l$nXDPBtGjj(\I -HI\A)%Y!${  XHHeeeeeessS.J=.#TwbF $E300BGSJsm.޲>=6:Y[p tFɓLZ:#˗4uvG)ratbh飇ny/]}gO*.I$*/3Vg?KLJia]ͳ/v2t=ɮXԩSE qיG鉁t&1bH&ұښcǎ /2t'(o!u4q~ I3{}*zm۶M`zQ fj;0LWr]M]Sr 4-KxXaBl6֊|$b1?::ʜz󪇾& IDAT)>J[A}7XU'T<4_ܛij|U"FJmX8ZgwatM7X%z~ub(DmzY0Gdj׀xm@RZgK ]ttDB @A MYy11y/|%B(DžiH)j܊]z8(>ⶑTz! r'(<2@HAfmK2V0w޾wߞ `-7NOR0#gڽS0K Rn߾(roϥNJɓ')H5LdN/z큐CVcT:3O3]B/[٦Q(ү2%EJqǶmۈyyOf}BSrj#܉XP34r.ᄆl4TK.].8™P鬱 Us]h7?~ ?K[YS֜M\@D^ҪL#Fo?ЗmhD峤&L @N> 䲙Ԫxt2ER<L'4׃@)͎jbì{n X݇>z'IHjhp21s=o8~`T*J"??O%&KٽmP(s=ۗY3qYs9-1fR49v;qJ̠&Xo><{S=]ݷ.\% &8RB:Rx L)l=!2MIxBEԥM&W4XV]HIC&Q4Ft8B@'T9`"c49Rv|[c]y<1JIQz\ z$MJTlDt=($eFYIirb*ݏ. |R7t.Aud[# |@ }å%+-\|hZk>ur||;/ΫaL~+ʊ A !aZQH@JNQ$H~XK =T+l24I\'TqfQ TR2C1MJJJK&~썡DfYnЙ=T^~UYݲ?aO)~=]l>PTwg}jҦH0tu{o8}n[*+zO:|O_{o_;~0eJ[O_zӧ >ݯWdUUx,z{#`[YQW8-J[ds?ECBjnkuPϵӘ?:<_?P):00L&X~vK\NKJb/>yn7ͥL͗^/-3k775xR[|w²Zˎ^i?W/0+Eלs[]EjK(ij*m7|[>34ԟmvY_1mTG>] Gu PU]UZwYա:`:O/\42xOۚY] u̓ON`5unذ+w/hzxc`Bˬ=M-lI*{y l!-[X, 5Ω\.Xz bsOߥs._O677?v>ۏ[J*e(d Bu(S+I8‹p]umpnlpo\2ɓa(c@ G_>e/q]gpp#kkt}##ï"A]׃\!sc?E T!$fk3PXR %M߯8c nE~#7.ؖ +dvc,vW aK;/yORF(Pxb {v;kNW-Y0e~2 j%a<V $Sws[۟_4oj7^\z`s_-鎎Tn55זNC%N*@4O@uUI?wnǛ )DiE^UM믿nSe%+Vjv؇Zp]m]k_[ߴ韜pyeg-\]LK獏?~CO =,)buU5%c{Fقر{^sU2morϝ[(dcn{y׷XEd ~5<:8н[w]Z8wYrйW ,^4ȑ\kMӯhaB( Wdn E3'քzւ.(,^N#O<|*Yuҹ LzàD~`efRWPò%V[Ts~{٢UhW[giptG/[xZݘ8q"iK[Su?;$ X[[ֶ.fVՔڻŔ[oWزƪrciK#4Huu6͙uc '&+*KJJ֯_ORLGh$ii̵%O9M ӧ+**>! ӧO WVU%SHtcl: p&Ӊ68s&*CI,˲,+DHCUS!g7*\|_p%Cɴ.?R[xLQ}}+Du$F- iEM:%?6߻@E KveJCm^|<7̛B溅R}`έ[OAAJ¹,Y<6rdh,8hnZU-uֽG6¦k6I RچlxPXp>i \kOn矾7M$5wsn(X7v!8ss[f! (,S@ g[5i2:x2;qx2 {oTAMصkDggnѢElnʕ==W:xP(L&wؑbXieY&ub{x8={v"SAѯT(H?_JP$室dp6b*J)UU]zv/h(^:Pr]Hu<04I@̥A3*3!!Di&HhʷD2 H%WG}t횦1LHJnh!N=w5rK|r$ R"VTT0CfPk( 7v"(_C ("4SuA4 a=0<ێRp.6B({V:,)e)=J5) ]#h~ PBjs3V]$|݅߆(RzE֬Yկ~ݾ`JZ[3 gJDdLD{Pj !$.j= ʽCso_{C3 2Q˭`6,kou'':=VDFiʰ5%L)$(dG  Cg2R 2PfEY hPD({EI0L+Ұ\``]o zI #d  ۃP0(h22W3@ 25yvS@oOi1WpQ@)) AԹ"C㸆a !I^3*!(-WJh|rufn~UyssYM׌0Ш.e>zͽ 'nlhNܰ~CS[e'z-㣣͕'ZO{*jg6Wk:s_.3;Rɛ֯9z~h<:[[Z}U]sS7V-Z'Y))"D(JTAI}9o7[K}#'O (\eYK.#ѨHF"ђX,e뮻rO41 c/bf0梑XUU ADHɃeYtZ:%7_mȯ?|S%(pVpDU+OTxV}9 ZWf:[I.I%*$OS$H}E)1NNS[R ML=v  c넭,0B]7ɥbF_YPg*!E2]m)=T"%E@ l/i@$`{H 3uWgÔ *" # AF('s_v!%zH=w PG/u>8NNOّ b?%)S% H0@}-pht˭/?^lgJ$Si)N4VWBONFG Ӽ}fG^}.\]im K<CcӵϷDJc]g:; z'oݼ̙ ["b?ϾKu?u74o_x=H"1>24l]t֢$=h?}e=n믿y m{׾iUN5@Nq8ީ>L?T{ XfU~׆6/euCGAz{^D2̲sy\&ms⺶"c\l: /lme%%%U8. ≔,h>!VOgfU/8WC )"MF)3i?WǴaU_߰|ŊX (e󷔶 IDAT\h$*`LkkkC0)9 c:pW$\cXXT؄M߼y3gFWuj٩o)yS!D,󾐏B+1++%rJ8 AH~Li>{rnZ:Isdрykc8i (%8!g"(-%۶[Hk+!r"8h I D*ܠRD[?;* ,1]uS>U "I For@:}k'Oc2NvT#mkNw0T﫨>߱ ,* +A`j(R.adT$J0 6%:gĕ5U&qG7A S~RSD` C"A Ŀ.;Ozz¹>w CLF'g^j?4@$q`ߞBA^-O=OP=oۗ =qvȤW^|eG.XiCN%SֿhOy)t=GK: C5> 1HW@0{э7v_Lgݿxႎ3{,[Wc{v>1& BBJ Cd󞟗֘B (ʞ1uJ5MsŴր$|fT_KD - Gs/LRQ] .B<9S].T7('cv1L0]C tJc`i0]/:._|ڵƮ]֜TD7=[5%R~Gn&L)U+;ͭ/n\Je筼NmYTKPnۍݼ7 =}@UCln՚T&]4Fl,x|@N# | jȁT Z0pGz 4R~™|6եr˿XoW'X~m7's8!gt~YZ:ChqfE$RA8.(dLJn(l BlEh9DFC%H&WEH04 P p]RF4 H&ofsEع$4 %uO: HHIǓFƁ ML'O4 Q N&Pؽ{h"uuv_{0}ۺRa}o}Cc{P͍JKc?{Zp~M7UԾ畕-S@47Qf C/ S|ypFPB)EͦQ@G 76ǢKM)wڥQC{*ܷ'%@fBv!:hi?E}۶m{챏v^~㚋mUۇs> +W\uG\`}; !GrEr@8K[fb2d34!AhUuUEEE2{pYYYWW2s%d *)h/8SP(dZTuUw%VxR|o6Ϟ;4"v}mTHR{ wi~޹aQmQS"4"*tŁ3O]Xl/A5Ɛ&¡ ]:0VMHJMRGr@Y(.u. K_?fvSSc,+L/$%>؟LJ; >*Z2ww QronSrUڨ|… <۲e /pM7[}Qի޻<'LY@XѱsS{ 6aj⢶*&THPRV]2Χ@DШbdKL z(%NDaЋ)ri P@ʐ2o|@&DHH{ۅ5*)ݗz }2\2ԃQ&y[ HI N{8J8EhJ"MgUh빃g4h };sOy_WvE')䏪hY0(v U/R2FBBBƹ*ћhΏQI쉪PB_P^םԉsϏOLf. LҲT|"_tѣӭrhyYGAR3G Z4٤%%+3Db%gϜ .ݻw{fSXTAT#?s)%Z.{mK*YxǪE. Jʫٴ88gEGK ]/=sG9{-T194>›p#I˼aTU 66:\UF(tv ݫε_GfUWHjdž;;uˊƱ8_ܲpOXRVVF,=WUWm^ZZj0`]㓓sf7T \|Db]wF;|{ŗkU+3 `0(Ajjr/0tU.Շ)S!?(B44۶ B:npԩSHH,ˤ݉D|**xb"SJ9uy|a|G?Q[[i###LaH9?L*CuKեx)*TUWwem@ XZ?ʄHD1IQ6)]8 "G #@@:|0P x(: dhLAL dMI%jRr LH =Q0TF'{ZT[3{9@APC2-Xu/>'NzÏԽ o1=ϝ;sQJ@>7t !̱熮 9PsE͙3{޽]wcSO=/}7No߾=Dia֭n^f8N G?'t]1Jrui]c`0ԝW^!i/^4MࡃDӤcwPD!u=kHlf[[Op lqb hXV pMa@;˸HOcUPuJم Ɂ(PQP}_FTַQ60ѫ]ol+is 5v HbWOLRyđcL<`vKn! \#dweGB1*FG†\V .˗s\.{P _B$ۻ//9L9ᶴUs0G/22RT&4MH-v% b";w lKKM7J&cccfҙd*991H$DZ,Kiv>RYibŋVoڴihhh>V"I !JTQy>cLgDރS;%!ΌRJ(c=0u !,ӚPlZZ}7޻eo-Z≯|/o?mj֜ڴ؋O-]aʍ_?k^=vS/۾Ҵ{{{neK/|0Jx˵wmן[ o^|ǏߴyɑC>zmk9po342k7||raYg*RꚎ J%-:s44j:D7jj>;gΜ?} Q2 $AJN @ȘFSM)S. No{UR_dB((Pb.A\*ִII Q=A%fnФˋTN /&!%R|>ätKE6Y C %H9NQ9Ee}>uvƳf浑H:!1z\Ϟ=zh4ZYYjtFY6ʫz.:Ǐ+8iS{G:NF!-iWT% nM/Z2{O)q{ s[Z+B!H_~e,X@0/~~\֯}k_zoo.]<gO",R3?ݝ%9RS{=u;cd9 ,irٓ׮ؿod }aG}u~ N#IU3M} >IR*ojwyd'X^^2ıv3 m1-iҢtB]mNpXD0O#[@8y bvCB!OleQ(b%%vwlGwπDK׮wt' Y޷=9ZQYkBǟ}Ygl0KAx_WOp=\sҋ=vwoUy9 dR Po7@Z0̙SYYdƦl6W[[+:yԵ^[#d\lk/W_ҢJxF#AI/;66)DȜy^RZڳ6_3:41{v,;/6t[/^8cZ֚5kb%!0\EOή >¥K֭jݼ:*E{u]gkO @M")Q]E*qSe9۱;q&'8L<$qĎXV.YŒFJ%bb' q{;^a,ǑvpqqY]]tɲt:51>qM[…lيk˷lzidd?}?_g}}Wښ%K\CStώ;3|ҥt#?drʖGv7pz!~T[o>sX$sP~` u\?tvvg&#FY$@; R| Je ahT;$U[SkT,̙CyL*h"AR= ES*k@DNf}ЄW8 ."n*7B)mhTy'??2+$*uP] . =.Dm;;w_z饮/~6mZ~՛/m+/ZR;2ݩҞ'3T\{h̉p3֚j_q g9iVCaV3'aI5)܂W̳j_W󶹫MRV硳M.8Q-"?XtܱNrzA1== AL#p Yd s.HX7%Кblyն|PqIȐ"?}JkQ["y_[}"&aZ2 %)ͅo͌mw=444<6<8pnjμ{myj :rsc?~)=Kc[L&NčWt9t鲡ٳf  7͜fPK/-^4fR:Q? ~>~"*ʠF%nI.U`$195J~wH?H>$G=KI#w^{3 "-%c1p"B!,i{7<49ԧ- upy3l>CP{}7?TI3A l-`+m1E/}KBdrl+!8 ǎC`#,Mh,Wˌ˄tt,@@$}6(#@@a)pVECȱ] F, Z9NAEhdI@@65*C&cf )lp 1wuwuM;}y晊s3q[ᑡ㉑|>)%ڎc~GMsԱ{.`;ξoolKivl*fΚꫯ^yѪDf"ߵ{r-3 ]ryc)/MY?z䪫:{Lk\6RyܜMW._Ȳ}%gF RwTمovdb`p荖B>,Q(.ןjz޼==}}sfm}e{]]ڵ+m۱pD Ӓ?ٳfٷ7do{8ZI f W҆" <8zD,G'Zy\׉D"Hl@:ujW_}ylvxxwhh(B5P"1/ԹAxas2X<SUF8m|V00zgęxE+*FEeT7Vh#204Xb?zck!8K "EhZX6M"nAaC ~d.$b!J+瞗_~  iW.p(3DQwr>R*)%/m&r)(bFBL#$WEFH0d>WxT e7nܴY@k2N EΣ,G"+8vDPYVTv;IӤ}r-70dE+cB u.U!0+ĢQ)%haXl%"[2Uvb%];h2j(,I@9% !+93J2[˶miDSJ,իWZf= aY* 4a&_\cY'm1DMMMx V ΝH$t*, x<qeqOW/lɢ#CbX*bfF5Q=2j('#*E( H$R1T*v3JQGgaUV~ m[ʗr(m; %bP"Ԃk 50@[4詴q9If)"<"B(e8g0Z!c-7HX)?x%N? :g&V4"D@^Rj GF;II\zA45r?R)+%0*,ǡ Je!@Ϙ=86:\,xNC.m㩄?zp(i1M Mȶr:(p/G"b]TPA9t*i^<guvS+eFi P-Yʱu>B2FkkZ B-e%8μLmYe9Dž|gH03{8B- Z\A9׍diP2n){zvwyLp{}6"T4 & 7ARs^(,2TSqj g{uffN5~ƕHSϢѨmF-UawܨZTY~9n 8&`I0I C-Vh 0,ɐ& ʻ")n5YLa` &,1FZq , PAR4W6p0B`ڸH299L}}l%θ"E.!hB'ɐsBb5WiFh*^ݺCD2+41!,Ez+ѹ0_P1BF @kCɋ0hΈˉ&mH$Xl 0BBQUsa}NL F5T~?pA#7j4wOE~8 L$m ޿rí(R3=KYΓ.}ז 7؁|,ΜΎ3s. >U\rx޽k\q nsχw㩶ZmKZ3%;{Y o?q0o-@ll4L*{WvvBevm:g,l[sKڰF:O .a^uup'&ͨ=fwexණ.3fK9*3?O]=Qdж-6Ɖ,f)PZ09rE[B<\`pkPbNꦷ▽,X  ł+l"tݘuMR\5X S`Q0*on0*5{N*ddZ|Se*?F!!#@#HZ|Dbtt,X.|2@$KPpV+@]NAUWm(@"C YY{t:!0!UɎV\g֮Y]CƉ#9T (+mY fΚO[wEKۻ5Z 4 ͏:FhUJo9mM7?֗_XضcUH>:Kj%!0Ƙ- ݠ2/"*(a aoR@><75/kO6=L| .9 R40882:`Bxggg2FL0 ٬cVMȍq\ 4 6|\a, l0 +0 cS8Z5?y(זK"?﷾ymzn57g Z~W>{vv҅xϾe+tG?O7OΟ9_}oXym9Z.8Sk_S2|鏾/iZ?㧿\|'lmqŧZ`C}0rˏ7BNM 0U!j ˆ.(ƃK◬^i75.$XQ$DI߹`"s*&WMd)@bIr@frjo2څpS{F"?d6b4@ ID 5ёd9 *2rpjjHC" @b(DDD2d~\q'oyc>dž՟8MRXpb)9dSJ3']7rPLBJF@zsW)62uj um7?ܳQ'bّM|cܾXeS4Z{W (0 7l&z&Jy=x`+/X+ǭk۩t6ZS@+p>g3Rc>C糉|!/5#jx] d9"LiUI#p!* #J` !eW^yU+WWE:W,_79\FncS?"_}>=™s,moi,]ոl-O bu"73w\==;wO$؜xUwg,/i˾5Jtr#Gd\omܟ|g^aZә'2h4 Cd#@8czv$[O4b[ aY ʶ : # R hLF 8'Ci1DK(`R 뇡XD95LrMHLh5nS[:yT֗^žJ23 9S?q~qkV/Z\(PuMph_G)s'-C}S. UhH-E~\]nta#CJ0TXTX.,"2fJC&ohFryӃv,*dqb- L@;Bh @@JC2Θ#aB\؎%`R:Ntcckv%K|͍MMll#I922W 3{ddDk`agv& leyWUUU̚ cv]A0 һ̇*aSImʞ{1(1m~ˆB#wl{̙ڈf|uTfLbbdF\*y+\SOSi?2ߞM=?mR:6ϹkF݌={ ǛzΌcXƻ斖m/3./9۷+n㞽{0\7{]ϞP]KV^H@&[\.q!Ο闙j߾}l@(e=p2˯!ɬZ6GX'Ư>q_mM뺖"l:AXUa3f̄Ls1FB !On~뎟8F>HY&H)128XaUvL559ծ M}g#ЪϢ8mQZ zm[s<+&YRlgN1t >MʶÇȊXO~T}Lvbp+?#r?*p&rZ1-JՓL <hDx^{GEkފ!0J%biDȤE=$;F,UTbH!0|fֻoH"ْfBƒH\zC6h@@ۢ\~L^_%jX ZXH 2$R޼%{k]vE˯zˮ-_}I<Ҭ({8zs.lrЉ΋^9:zLۯio-䆯peԧf78}XdgG?{-nbaȌ3f+֓SRP1$9cE] IDATt d,0 MKMLkW<󪫫GFFRTu곟ïkV;{^She';ۛNgdX LRd2Y,+lIJwXuʁ?oU8D6PeYw`F;_@fTR0.Q䙛M#W;28JZ(T\GR)dkК!F%P+bP6ֹ-邤Hhʹ-yH/ ` 2 EVq%-m!C@3)"&H2B粩4g+3p0N,ͪ 1 25r"Q+K*6Sÿ鏖ovye-!4k|kp􅪯s tnjJ%fXrUB!g(Yb2P*&TƮiYZ:3n$mM޲fS$- bxtҹ?[IhrcJ,֓r^pΎ;_W_}QAC=ҭG˶vSv;ᖝONtt\)ӚW_{=)XziTz>?2 ›2@&)8ߡU蜏Qj*lnn9׶-\ H:\ʕy^‘B>uǢn JyhΘSW[sEflH͛?b& ]gږ]< 7}Mk[J;?m;f p|lLkmLW׾ m-USǺ5"p˵Q1HB*:m.b[ϲƱlԆ֚HkyR1.U2үHVJ q!*"UZ,X4vWUWW5/auHё!W|9$D"aLJ)PO1 gŢQL&_'"qP k*)9٘UaM1cx̤!8IpId5>g`Z*mLyFD8ќF"hH%Ik H "\<و4Sq}~m$̺N9Z+`<7 1oNR0&&$:*DeƸ$W͚s~Hјe o/e 1gC`T"1Sх>x=*N FmgktaOvVwGl|tFk,^!/Foȍ:V!D\ke;I˩k~lfAZwX.+<3:#W`}}=²cIR>޶ciG420>! r ƇIkme2R-vާ5 akkz !PAưP(pƕ$ /RvvvK;_  PVȬ>aǏrR)f_ O#'GX ew z5-N /ش?<"+<CCC{]p[̞=o}U♽;hxā˯zú`>Zlo!XtGūrv[PX/}?xuJ>{n7Ee(CY6I~9a:#1}t#6M Op@jˡ%mA?ɫW%'r"5Q\/09̞L]]۞ie<CŰ,5^ kZ#e;??p$Fg 1_2,A<=ݔfͬIqkcCvg>~]u]TbvGw}{[дm}_xkn߰_bΤW]uKٳ#~OݻC;?o.v?ҥ3 ?ZJ$j5@ |/kؘ r(}+|Oz`66`qG6PS}x&1ph1~ك 3v&+$Ӯ3éЪL +V)jJJu8=U8}ojPD0<pbUO5o '&bNPucfWp ׮[|E/l]W,YjkI\:]|o:w8BdD2p,mk٪k/*e@ l&#D12APo|K,i=C ]ѣ+Wwyر_Ҡɡ(0$,-vs( @A!҅˗Ocer|c~? [BEh-k.YƁc*_)DZӳ cxnUӥu0 вeM݌;G?UWtٛ6qUxW[Oٵ7u#8K^;rRVYn]XHj>11Asi?} gkSgWG"[?짣r9TJ=_gyzk{^|t=_,yekF2#'[[s4N&Ssf;v$ E)blwoZ9?ƪZ; 5U` B7S@ PqÔ%yueιC@BP.;sҾU#>R]qM C@][tӵE"59DǠ* Q7̀0QQe pelE \=.#*8krdXre:"~gsDd6<̺[' «[_G5gƪl.7m;.Zֶsǎ_kBTRꗷlx㵹lKWTwWtvn5k"YJàT7zͳf, NfP!D]w]etӨ1BB-k֭ʛ-'O˥yZ1j)_3O=;rp;J{w~w_~'XxE9a]G6nܘH)巴4?Oڹmۖ-!kXTwz&F&zzzK~mms;|CF, J5 wv{*XML}rhgRexuɽϾ;f׵Ng٤Bfw~pSvp>:=ՙ?{v*Ϝ9hhdo -JRɰ r`T?5m@,ڱ,:W,||/)<;scGO'}7`C$ʨ,J,2h4ӤQSC^nYu=x̞;IxsOsgy}[( ̽=yRܳΞ՚/hUt?A$yק0W =$ިEvK Ck׮miiMR+"JԀdqB$c I\:H$Q7_XP jx+QGBH(2`B  BdG[bH̷eBsbA4g, BwI)ǫ۬h0LhT0"&%zs "a+29'hP,obX$d sZo|WXǏ%}ydD8 A"]gvY:-[ʰ(ƓK |ɒWJH=~**r?zt8J |`pB8w?/=p\U?dNP(9RGdZB>=0j}smg׶2hx͸[ڸP(7<׷r$%"VZ#(=,%$*;2quw2~r$,(c" ,˖@ R2,Kf ✩PӶMHKڎeBX\X3νv۶wq̙h$RKR*˲OHPq qUTI5 L:h̲,x}}M#7qm۞W7Nkn>cuk˖-gZW  V[0ZɒwF{]^<-SQo$WU66ئŀ!i4Rv&{MdӸ_HK/wc^d[5}۞ŗ-/ij;y9 PX*@TQ!R#͙TѴi@4I3sbk 1 L;*g Y,PB*%ߘwQQ`LTێP@<#1G I0)Qܴ8)΁ d"\f(8OF)P@frY4x*f$<#bAoV~}oI {t@?pJdܹsuB:>oƽPGҸ%N^t }}}]疃e|#\.e*UTp! iZ/%'3B{l,N{ c}C"\C,!(،R)AJE(T9F"DܠrllL ABg0\3^ l̙c3 YKI3:f8J%L l8p`t&M qCG_ e E&JRJ@Q SBJtnkP)A ։\Y7i^.$T߮{aȉ&h3)lnmmmkke,z=$m(H|>_2='JIXqq/@0M3 UVVj2HKڲ,,v]/ᲲS89y*oiF"upzY(RH@Pp ERDHPPMo Θ!O!E &JC "QDuA>^;Ջ0Uń<"VP%EwF :i5QQD"P )(yXK(,T#Q yO.!T(j"hjyo}xEU$ϖ%NsIAv:J\|Żwm+Ϲ;wccɪIFcCꪪt]2ȁ wp:&a1LVR>޺gUg &;{l;v3'M=u4Dp~+N1:?zpcqQQiiR60K0+5r6Wa.4hhAOQr  B .-6ޓ&MkiU@B X,F)--)Ç TBמ-bEBPhȷ+@[wr^u%P%^|ʙ.78|L.P2+_ʮ-š!G-Z)Oz{>tvl&kP9{ 6(Gce\P dɟseQB*YK _q^nӲsp`GH4s!H$10r,\T*(%)aM{ Gp>#H_猶# 50Nb?Z$P# o*+L˦ٵ]{ޞk̙GPBJKfϞ _|ysy3W=vҋ^49|:$2VK/󹪊y- *1czƮ~zzz-\wޯ{]-3st=4LF{;.ZX7H*V^G6-X2ҟ~(;z/t笧m}` ) HBD)R VE)5m\] B @Ρ>F IDATM,0@mҤXdܹV*//#J׵kԛZn:w&/l" |zu;mn]}ʯ+ܲ"kk~j{ڏ]~Uɱt:[vW54T:~Sq?,9l9~M7n=lٲ;wi?5mmU022"K:CC-@%-/~t*O{6Ç81/X0;˙tf=lesR^XW ^{ņCG_?{;pqq/(@5q\h4ZTT92e7,߆!MBղںjT.;p?|1t(WBHl6 ١P1#s]GJMMM"A)d89sΞ=7 &:f2!eY՞d >}ڂys]A@BH$m{hh/~dOl޼0 *Bv" PI@C@ "p H)@|,2&*P8ET<ɀP%E)$D#CBYPJ).@<0Lr&r B(SJr=PhaJ}q?!H6Wlq5s-ʫrF8uʔיRH7\XEEYL3 @_*rʔ) . uu.pUQq !fXqEMB 'ipK.B8 u@Ss 2FP˂&וZƥQHNE Bf.]*K"dBX*[l'O^b|`>~?P@I^>455袋 9zkuvֆX.s]|̙Bp8 G"8s}?ѨiFFeYM7::>%,;eYw~W_Ѩ.o:-*Fa3v AQJ(J3e@,bmSjBi@(B:.'1Lf̰, %h&fY0Qö4(PJ4" DD)%UBiw]N##R3 =x팩ч^2%% Pj[).4:aIM&Mg>NT<`Q&? UAZ@P(H4P?*urh$[di\G\$KHL(\^a؄!mۦ3F{^JXlU3c*ev(-66mzqҥ\ %ps:q#ٽ箷bJ7^ۿ Nw0X3gNIIIKK˦M80Z]jJ)APTT.x>hoo_p lBeq1J BDG  k hs?z_\t1vPPH@ HD@9<`"r"487PnLX9췘yϫL4B2DQ $Q\'b0 JT(C D A$PG&LӉƮִ+ݖ=D̐24)sA*<40@q(dQ[[7-^3КG/X7f҉փɌ E#y_Bйe w~n:1xٗ]B{sͷX_ { =\Z?K^檗6p~q HtW~>UQ<6^dO:zgϙ7mzI-e':xRy嗯ƅ E ooop~m7ݘff̘we2rk[N۲өWۮi+MU( u۷%hh*-7ܴlבC/od#Ja4Fŋ&ܹs֬9h,=ҤCf"7 Bl[c떔 vLJyD⊒~oW)W8boRw.xY}xtO36# ERS L3G^O G$P $aߴZhrTJTDMyTJT"Yח\^Wc aR^ Hz!73!'7 ;rĆ WR鼓3M:<<(0 [(d$+ږJ%mD|d2_a޽{t?ڭ[F"cǎ]q-ŊùVOd"-;SZuZ+Z |6JR7tڶǧM/~NtL A)ӏ5۶ Ӽ[]ދ֬<u, @P#l:PݚObݺu>-GH|?i]M5(($j5ꮻnE.^OmZt'nG>}LJo9sq!R}{sԭLojJ++k#- 7z?lpe|J?t$1k@WUQ37mǟR!!HPR*]@RBC.AD3={kn HzӉx`o|x%x^4P~D$(  KD P.M<ڶ}"5P$Fqhn󜪊djŝvڶ_oOFeh5)7Deǿs4,+<88r!-[PF`YRR,(v[谤R*!'>Ғds{$cV=R*#?mڴ[M7tw{?Mqm17,fJ~YfimWVVΘ6]ܷo:4HgZ:&a񥔈#S:rm/}SToIYY5y?/Vz!|JH$+M&G2rK⥮-Ci}tmx3`[qQ0BQW7 _3<;?8v`l`< )#5c49."d@LNQy7Y@@hrBf%7=J0}eJ)RJQf8k*J3HŇ_9@>pߏ/ 'R UJI @yhS"!*i1l~JgĴJv?72V~*={>tmÌxYY 9F DIL(eQu{ ףS.YiW{;I*{waqAAB(N $\SVD 3Ar#RB< y7܉C{RVQa?6sy}c͇{ (!DIΝS9 ,+{z %2A)D `2J˧O}c h GYaqnn1zyހ %kjj|>#H42CHE S a 8u(y7 :ڱ}?~9?)7lp'>I%,(&XH$g fYsq$EH*S*ÇWVV[h2!mkB!;! ȉDnz鬎sd.XP0x;)L%Fe+V?vX*Bauv7Z~}Ψ /Z<:&z@ FV PHMrbzM-}PHE |FcGl.564zAI͔vzRuMUx2oq"Qу CA SKaC@`*ȨB¹|YLZGY AIQ)4 ]@E(1%D R"yPlgAm (Cc}H"~7C=fн5dnjB0 )/؝W8ڹ梙_?K.dU CkVF6iCg7g};}Ȥ^y] IqŚKIXjoW^M%@3g>S|=߼{55P.]=yv˦g7qǖ.[=cFzߧ'>SޜFhKyy1-+tۖWn\s}uA9Դym\g;Fg.534{t}cÔ'rm}vJ͉+4BZ nᆆk%()ey9J y0 s}P#0MS*{>Lp/yʔ)o~||X6v(T^^Y\\<442} !$]׭/m0% B7`Zd toC}F 2K)i3Nnz饭R%Iͳ̩&+˷nݱbŅ0gN7nx\$:v*HBRJ UT@ !I=J P BRqD!*|(Co !<#/b3YlXW@@  )y;Z[L&K Ji\4{Ȯd rR  hcNMbbT I/PwJ4QoJU(AR$bR( ;?PADuuB7ڗ͹^}eѬM<4&LUK$(e yٳM }[wpgǾ~?Ɋʓm\8y0U<>`ܥãO8J9yf\l{L)3ohH|^aڡtjxttu܁Hw7{lҍiZOkldVEEEɓ} F<9<쥌;rja̧ynrMl!ڦ榽{eBߺv#믿W>^8BJn |@M%hBAcSΝ;/L^>|xl<˅B!\0M|tte޼y6o_zϞ=k׮]tɖ-[F̙3}tF'q:ڂ$C/V64 \Hr{aHɤӯPύBaJ~Cnu!a4ZZPw4>znBQXbQ0KӦMkiir5! )-S7jgѲ\W L"K$$7CrP^m1Mstt;7U҂>4 Pxy R Џ388卍P(OId*2y֌^dYicaҪjB0/BA2bŊ#Glݶ+xoa̠1L&ϪJJ3L}}}x^4NO!m|**.z&}Qf_/R'hnnZrSvJzk/۹ckѫ6*򙾻?}]pË.hYrӧ52"~|Ѥ{.3&dيO<|f&h$5ZSU}M/P8;Rb_K{[>KYv:(IOPdʸc"Rqϙ{|˷~O]|5N=y_~L&mϗG+|`dǗ h&RkPJ}ߏE.[wkzj0 $ BH)087^D\'X%\Yx+N|7 #OYpY32R{={vgg뺕3fXfʕ+?,[oKR q>DLഹA>#TC4-@"V f2OuWHLܵs?}MStuwE?O 6WrI z8h+/?$`㔩6\sufKϼ+ywʕ'NSJr swAu]wu=ܱc^xԋݺ)d2^U|\Q3!K`uqƄ4@ ̧5Y,*  ^q܈Ɠuea/d$".,Nڥ˰,BD)AS` q/pP Êe@:J#I^_3hJ{ r_R8:iO>}ܞI}K(555555BBj~Mc54KG_b,9D*Y=C_y׶Ӛf~m3V$j7.Y'8rͷ_<0499'kmm+Ou_bNghw(܋?W<̦B@2~q*iʥ=82rj'e=C\vY3KZkݝ S£c}u;,0 v˦MWLݖ7kμ|˂U_U L9=5tC @p$%4Iϙ7JA:K|8a( &'ST˸i K"%Jjcjllny) G'RTNh%qxFQ *P@P0tin,] ٳK'kj|qI}Kp;~)k?ٳSԯ~+4= C#][6K<5F~ fH8yaʴH$Lvb13AӇ+{ ٶ,b{nDY*<窍ϼ t[o5%[W l2KW6e/=8svuOqaD^?rhʴ@A*\2ke'v1INzK/ͤN~W-בͤ+۶mmY4YusN]g-:hιT&jnTj҉PJ(4 JƱlX97(ӭi$}`:5kh?p8L&tֲ`l6/O~nW]uUSSӄ-fs??M:-͎(8N,+v@k(6MPJAӅ,A =tG+ ŌCc༦z,?5Ϯm[֓mB$MCJ߻m˖Ƞ.}*$Μ~L&?᜷yBɄ~]]Ӥ{mkkJ۷ASϤ3Tb Ob4 CYdpm⋌1 @&4[:zB@RQsŌ9a./=YIYcãcy^ eS]0~ E(-pD">epP@P/>DfYJ%RR*ATU,XV \렇u[W\ys?L RQjPDaE-8&t|4 *oċ[pniT Ωa8xq4rBJj13,R&0'XD_ hV8VFPtK*fV!R4-$3L溽۶xEdtm@#a;P6upsn!d pJxJqB tKgty.{Lf QwQbBQ%HlŶDZ8f{VNNJɏؖlXH5N $z`v?60"˖E HL̷FBFBVCqico,ڔW)"#!I\"CBU2P)E6bggi`nji1QOABS ep`ȣIVEɫJW H@eeeLMNM"h)} (RJAk%SD۵+b_,)׊T9,_'B 2@(f=ZΈ/u}T*J`6F8Z{PhB"/raڶWW$bW$j$*<8)̣(`%N U6aaBY4TEmD$ %&_ CQ8*Ee ֡Ol0tl.^AQ`',(Sehd-b]Q !4% *4(%h!ҠߺnfY#뺍06~hEQDd0l̃kBXf#$Ifuw5=ԠpR*0t̛ beK/RB(J5y2F aDD[v"8,A28rZiҠ-> %Zl3O H`J# 0&]~s:Eh˫t8a#1뇯*ڭ+p*;pspY.pՔ0oN[e:Hq)QDJ}v|{K_j7tc:޼񺦦m۶6WƵ{'?ǟi˖Mv_ g )4!++N^fltS3oi)VlnǼ;kMHW۶HHK[K:HU656T-[qmՕu]c{y̯rlwvs]ME+0>/ʠ0c-s;Zjk*+RѺ40r "aƭ7n[ᆍkjk]77ؖzME:U]4S(?pS]m6_sL55$cD hPJ#j"5j-Mt~bo>smܕ96-ضmYc;d2J݃HQOW_}hѢ2efJid_M",$8&7>~-"n {M*JSSY ߚNaQg5su4(wnKK*Ui.Pب(@D"D& Ty6S,4K?G~Ȯ5P!" m͍ 6ƬvFA$H@;ח+YМ0 "3niG*nwME~WlY&HDk DkطoG>a)-rz@}OU7lz|]{+7m=yrx<|兽d{ 3z`")!e R*ƨB(,뭼ʦ5رyꩧfe@=(Qv7,ݼu%%սM79vokevv"* ^o|qD|oooa+yQ0wdK E+@T|rRJ L**^*W5RJIp_ZQjkl/\oLU _Qvbv(W VW|ABoʫYhoܸ1?kBqO?zRL(PWrs>nwؕ#GCd23PJPDjmE,w-Xm?O,Ǧ.-]Cvy'.\p[z_җlB hj@/oy#'N~/|諾OBW/ܼ}H@Xȟ}ՁƼbvpxtуssO=8GmǕ.\z:H{ӝ2d[j?}{s;rlll{o &5 hʕ SV _h˹uS"ORLy8BG}<i沓S*fǞ{z'"" 2}t_|E-d-^j_n"SQ_9Ud|p{XZWfde. e\{ǁ(!@F1B\;7tSD<P(R Ԃ3T7uϐ],w/(] Ӳt]0\Bf23x<2˨krqߝwޙڦ ! "|߯ò~I-3gӛ`1xH$|+ԮP=Sz,RZ0 |2,˾kFBŞХ+WPPJ۷lٲ zVUQ/&8 3EĬ@TFX j\߿W, ;yv#x~IATJҿ<$&( 8J;vH´kkkhjjkG.207UcG"3U  2Euvl4 Tj30PjM1~WdlY7q B(x-,1u[ךnǾ*j;q9Xr?nH?qꔖT%ȅN,D^P8H"?tk/s҈9 B¶eNkΞ 嚵ΜJVUմqW5 'dњ#'N1\n'c1+ED+0\h-m[H݀KMYX ^*D "%|m͕T19 VKwn81^\>V]~`~փ+MPX2Y#thXu\9V|sų/&Ӹdɖg^xUD˒ W^\3wHEht9xaٲ7m8z+{*:Ԅڞ?oWon]r;S1V4QЉ?twSjTe[Uí1\yo=0>#(X2irBP66| ]B+ԌA`ʲ,$C۶x2HFQ`D@ N)=KbEDRr{ֲqۼœkʥ8BH@Z#$b4sf4 %'j%4b7NtBRZq,B!M!@DTA @kMlZRj߉SA2=ɄoRY (jpF7ab$~ޥQQFTd_|L@(@3@JQ' @!R %}I[dcwo֒M|aYφiOk@g׼Bx~ /`ݶ[݇?/< ns̶c<g'&n=swcb*U֮]T(-Z|ٗ_{{oN84gldvްZmn6lƿ;oasoBo`VjX}.\x+W/AXYY:-9N]sV^ty?[ܼFfQ_\3OMMMdmOwߩ=ㇻ))!3^ދE̤tF(~BF pc3rF]Mץ[eMMMUWWO[c+m|ީ&ڶ-uT! v-m[)?e2bضYeRx1Hb)?TMKT"',Ή$VJIj< B`8U SBA 0h! P DU'hP]q"3E SBA*5%)RmF06cm "jo_tghjSkys{^]#:H(gKp!GbB("=v !vvc,gԀ<'$N0?̈́MP'4UJvf1#D?  #J#JAQfёiDhl_tcX,3984ti(c)9clp(L*v}j 9(e/#'geYZ B8/y-v࠴i_JRfVbr,[<̙^'#gI?,|ߧF8sE^eh@`fJJ2ɹ<"43C\o30K( ᾟ=ZQQaɲ%ZD iQJ)Ϸ9`[e mT %P'&+y֕$)E@ +&BYđ5*.mWD,$F0H*KB<E hd/w`:oqNeeI$%+N;0\\rkb"4,@#9/"֬#Gg-~jڠhdHt3Q_ӲbVoLTU_ퟚ*,]qN {/Zں|re5Mlp]sKؒmO=͛rW:gEywqEm~3}Nd\{مs{/^$D1VT;BR}/m@q[R*@\nժW~d' X)ŖmwJKa&=Oմ- 6^z̲hKd28K{r=bmټvnEѩgl'[8ЃR\7onvrT*lݺۿu]=3 \\J%Ӹa&!f?L& AC5.Ƙ^Q(' /,քrug2D7.aŒR*麮²-fD 5p颁I)=30 q ¹s>ii6AL|+GaT^ȕ%L/8q ʸ-A-hHYb%ЖC&(`Nb)U(ҎPIjKVMduIHF0RDkDwUlͽIyx1; x9DI% ((3A4JpfjT9t{{4ֶ !xe*e17mI, h,j&Ε7Lx4;Z~c V~d~ S HI,]lͫ^xC=9ɯ^b[>}hw?ZVuG[:W_yʕdw˨unxq r lZk)TXhllB/.^mLTm1X0A/uww^`~ IDAT`-WU57]xu/28؟J;z9ՕɩR'׬YW*~̀sU ׻7m9Z]r`3d.eρ=z^=۶cH $c,_ۭ.vXs&&&/JTיK\gggՑeKOM{g8m16AsI~˼-84 `t\4F zL0\L&SO544444![P+&!Ki#BFoeJ !'BPJ ꚪ( 0 KyD&Tqq5֠./\SSSR0,>??}skiilf;v9x㗥%mΝmiiYhQ*]jQ% WpȥHT/F i:FIn*:qIt;55?0n˱$/^~)$:YaYTKEƈ}x׹@#~׊cdV\MYݔ8p`¢G+a"U(16<4sx<<˞;w8h^_JA@z;lT^ȕ:.AVJ!1>6Tұm%ucSݻ1o'"J*C|>AY8Msʁ4CCCFJ%Ҕte7qh(tQJ啼b,<cxj dy!DV REET>_}}ÖBp! S"J!)%J+C8E4&\߱{͚5Gihhܹs?񏏌#)nHSF-/LD!Oyp"5#Ky8?8H!Na[j?F̵yDr"J|N6?Uop*4X~ pVhb<}!P4Nf fQy5PS[\o[tb7Rφms cS :V\IY*'īl]S<{?۟=ٲYj嵋G>j@$5$Jk*I@ U \ˢ H ը,"+@R$҈T TH"E2B0{ن LO#b5%(OPDpIgAKOJS@ ܲ)#%s ؖE#4?9f[H˹!RF{ۚ甦K: F!Ci_̇*ɍXh J !s:CT6#AP@bAjEd\8b xI}s4 P)RB|hYT(՝PZqtTi?e\khB @U +,p\($˄Q32O.JfR} Զm#VmOH)!TNt\5wn;!PV@X-y! ʄT2bHbxYG(*%PLOQ bQJaYV,]W+C*J*٢׼]7oRw}w޺u"H5Taw:q+Q³N!ɐ%n./ œV)DODb6K\ E<)RAl_C6 $ҤP!TN%4jMu:cdIUuL`d! ϟ?['~<\./Vc<,8e+#ynòc=}GKL % SRxRWj-*T(%J)qeYG~bϹ 'O|=>?83~wt:'+*&`=٩I}[ׯPQYƩSxն^ڻu/iDfu%т-G/^槟yMWfusSぃCW8=3,ȀyRN-q&zPx2re Cb-Lu hNcOxk}8R"^B!"JJU[˗:tp&3eMݝgN,BO>O811_b8xuG;gk*Jc ν@פj|5諩\Len,vێۋxTL#QzSS%/Љ>wㅩR)^1;JXNd{֕;W~P,XkԌA)?eEJM&R4-$!LU^i'1v^Equ VpBr:QEAZ݈FeMF$$e9}0YWB5eEK *ǬDkzBhEjfX/]чĥao)BbeW?qҞsvR#g$]+lؓv7^<\iOC&_Lr{8u1FFy{gR3Ne͵]{"鏖vE 3^k/f>h}mW׭^::Nj.uش\4PhF~[!4,Q5pΑ*T@"M& HJiY&Wf3߾3\oذaŊ}}}{Ms#0@" .ikHs_wի^x% u2;ɥHe˜98K:zϼqX,ٌ=rd…/@SgjGdͷ|_w|CM՞׮n]C`,Z鳯o+KAuyNmz3?;2_oWwwuy7vˍz S 7l{`MM* vD;XwP hA뤐TI,)Bp~L-%5` eF=|iŪ@v|tdj*ْb$Ml#>hMPw;$/J5T|i #؁W^<&JI@!..SMzz:56.jb=&fH{a ~q;mv]) U"Sq] hju ~W"t&lop5RnT*d+VITǩ:|…9X*fa3ݲe߸A{{C=dY1BO>|چv+Pƪ-ˮrJL1x%qbZuVZJ/>{ѢE]]]nܴŸ^p!DhѢ0ߏcBa%sίsvӉKzdžhLD,FJ)s|*]5Bm?Ph.`(JƓ^*P*HYIYZF=g +.kSܴSa& mah4~L7:61כmINk\pd2K.u'ElvlvlT0Kf% AO1qk,w*a2lg+ YVX0 }G[f,u,[xlQ08Ĕ6b3]g;,[ke"*rRu]|l~a$*Ko&p9[,D922zӧ[ `VƼαY^K`uȀ'GSQ*eS8e )JlXP! H4+-3-I͙"М4{o"p$@=?ПBu"Y2\KX..y> H=ЮAL:I"ICV 8]!H .T}˥G20HqiB Q\ @j䄯D`ʒ Xwηm0Pva5@0Rf0Ȇɛ d%0E\q8MGQ~4_L_<5׮ek@ !|5K& ?::zޘx%1Ҏ˴"LMuXJjd12IёՊ. Csc~-ӮϏQF HI'EQz!}/a\qCd b %`bh̤ żQFŹ+㒀4" bq;`}+i R**,&r\th~qoqlaRD CC N9h60[kƘ!RR`\b<Pus,Ԣ8.VSX&$Yᑽ0قg xʸe*cEQH$,nfEKV*d;-"\Z :u7ߌ0q9(P(4c9 sa>/ 7nBtvvN2l=/yn|@ +&nP*.V#^Fp(g&cNP@G\2K=xlR'7x$}>z]X# {KOeu #63#@(T8]ߋ%"&4'Baki(0+/Ъ 2$HbHyYEu*4É bϙrC*dfWG.\y#Ri9=n`(.>˫.w xSիs?nՋ ]W'b3gZ0o [^Xpٜ8=1Icp՚՝MS##혁lnOl暫ʑ3†ÿ()nWg_pYf̜͛CHrfogh@̹l&) (U붼lRGaDMotpԦ)Y|Y.wlkF;f\r,4sV\1_݆كRs+.<7P TUx%(r.q 9s}OPQV:,"f|`J!%f(J* Y*YϹ #!6Ki7Қyk q,H$7DQh)ݖa!5c ,mh)ZK23Ɣ(c4};tKoӪD7UN\.Yȑ#֭ `|kx"#HeOP9cjT5,-51 IEIX*sBE؈Gehh`KxN8exMX^X5isQ1+=  $D: 0RHhP#c S(ɁDo 3 q  8pBD R)hr\gcsbbj 5#L%d@@FN@@0@@܃y.؁"_E0TQ@8܅ 20##%G;&28ppP(RRm;] o clB %ɴÃW546>х)r?)#Цw3{3űR>Z0~빱MOzes)wڏ9/i&/Kd'_u5-goaCgg_ooתU1SLٱck7?r+Ir4 IDAThm(/QL}}}>}ɓ7n<~xCC5 Cp8KF3 E*Ž 2g#P,rReWU\cθ)?nbl9dEp}U6F]QLm0"Cl|34 f@Fo/Ɍ`gjL'ɪ$ ܐK)Kq s`p87/)m'4@r\&DtkøE;/-ϤhlT#cQ @pb.r<tE|םЅC###.GBrTnik_d rdDy!(mqE>e t ^?2G^ݫ7m]rǁ3YpZ%w`hT'=\2*U soww]LGGG=eȌ1CCCcc--͏> $Aꚦ}{߆R7'޼u6 NϤ\̱Vo/w*sjd\Ne9vt/Z0 UգVB _Nu9APRXF/ƊAW;>SA aLy⬗za)tG].꒐^䎹fOQ&t%ѐk`*Aխwr8?x^Vb.%-@"#40%(B^tuu*O% t~20FtlrcE&~G/ T9uwߕ!B@dD PMZFDB81>~ح]?d9psAv Q##R]=|̵{^rd(9vҴs BMmͮ]F\fY݆I5WNZ=g\7|I_}ږ35ew)eWO&֩&7#lot~cdԽm\ ` &.d3(Dm1E08Z766 .a j˖-[pawwݻ+>JPRd5CQ)q8 ƒqH;;>cn{{Ggs{Ϸcn b cqf'M.p/(Gc[Iӡ'ںHBo?gdd̙3ϝ;ǙPZ57730cgav"tE{6Ȉ)5xM-- W6i&Ao ~H8.7O>pt=tHrƎ= g yz ~a߅ +"7cƒ%K"BimEͫ%Q^J/kjj|kJR(8tp$IQBf;kIgƚkkk ? QrG/m(LvwJ 吡I -ʧ:xe `pp0JٙSazW]yssɓ'x \GP2 "G4'(2I _ G g<πg0žW ʌȌR1 t ު~Վ \y\Z81HG@+{ݱm{멓 G>DTd m-肟$%rj /WQW~KkuG6\qɒ׬~Tܵ᚛O\mBe/_U.9n(gNxBjXhAC$%@ܴz&-ŝ0TZCZvx9H]BP],]c"T,K.ל7E1GeeT"Ƅ1DZuSu-EkK@@fP!iBt\fdr```pp 9\hmO 6[=44422"lw\Ắyﻮ=dju2]ǩdo}"d>ePԮ,/zɞ%|87~z_>W)0Hܐc(:k(!b84 ֌G%0Bdċ.IhW JBX yM}M6鲫 b9;6UNWe$刀Bd34=;踄 2!H鄟X4sAl5ta(]#DM Y"h[HEmBAhxMT~!.8LQF""жmlYr;n0T?C__z }KEu}'k?=pXgv68Kԧ>O_cU4r3Ƙ[-,Wee5'h͛}s_s-UmwyGgoW&]-sY3zj}W_ܙN86cҷ[niiͦ^ycڵ]f-w@v~QZy%KJif_sWZ|)MO>8N3kJnpxK/ xH$JagQ8qL։D c%WJ&P__HKKɓ2k9<0 :<6:jC6}\ۧLr髮ZM&ѕ" ©mщ'TK,q]!.뺥5%2TUUy{c[UС?/U8{`7c}>OdA)xW惏=̏0 FkD0_ND9:q5j(5ӱ܈IIS̹/S־#GP$qÄy+yv ZuIփ\Y!iH\U4a aX!z(K|eMzRUՆV.aN}CK.O@tI[o~jkq$~nNӝO=,%'޾бlZ]bO(3'W_]| B|eє~cuY04 YMS4 \~iWGqHA3Eha߹(j$`ȅ@DsO8w\N 'D2$Ǔ ,Ro  d*f)Ytŏ V.C4:pt"`dt82VdS͛U.+n (Ụ%hǟgTXLRT[)xpI&3YM0Zq8\8\x+G~q 8 .g҄ FjC,$QW<\,prQs|Sl<5%4sy9tJbbiK0Fs\+8Xs5w%] 3TAG`dDpdL+͵#(P KHx"ŋ0Ɛj.c`N vs,q'UMc ?GFWV2 G~K2Q[3/nE$?*x(4v%XJ-Z8W?=]b˫^cM]Ȑ׮>~G?NYxVZ>eƕ.:e֊˧5UmfΚfeSRnܸq>}g7|CΗ_~u]ـD444qsƃ:z[[l߾5 |9yCf͞ۇ͟csgݻ 3FǎQ*F@|9{&}}}-Z`R;{,̺U̙͝&_(m92PA=4}B1GZSuuyG;Rgphl<|\+USl[[\i=>zl4!$"x٦uW45.t<+<{-?tR1Waڥ=U^1\zE{kaI.CD"ZaQݎL"D "i1¨0eL1`֍]iheOBf=.)it4eN )Y S,0pb5G cZ݁L]X,ǣ0d=UR͕?sͷqvS?hܺ{mi]q+V\1wڡ o?cIsϿx_/n#Ҏnᆲ<7~#y'>'s_f7ܰ(ߵvuqÇsAǎƉcmBa@ĢF(G_k!u=o~3͖宮.!  [oLww!%umIA@aW\ڹَ6改ˏÎ ]#TϾҋ_}>3xvDU>gɥ##mH*Q~㍽+L>䓚gS;}oǢ<1bm  _<,mq=sLWWكe!œܽof[;TgZ?' Ɓw0dRR[*wx*<0A+JZke\Bz^D2ȘyEn "Z{ gl%S(C}BPJR֮]y^ƻ++hq(TXTJq<.G;"8l 74}z$c$A+i>}wA& .jQwӹ}onٰjvK\l˟}/|Ⱥ >|=Vf:On=seϚ2J4\LE}85M?wm[")gLoΦRb~Dɞst&㎎Λn4}ԩ孱OA]yo{箹X$ޞ3f%s}} ;;^|M7Μ99di%~+P sF$@ Dh8'Ή1`Z@$Αs@Y @&fnN>91pdQe0;"!)0|(N$ө !rH3ɰƍq۽_>9Dҁ5 L I+,fd2+Wʒ[1ݩ^]D'AXuu#"?! @  0 m2%X*RVcL&cV( 8ZR4PWed2-[6̅f{2DW~+.p\_jhh8o4(m.b}ovt'>Ck^m*a]}ŋW\basfϫ4?/>[r~-\>~W|3d~G,ct Xu 7v?Ɂ|}u9}őDCBeܘ B3G2C6^әdT6-$nz?V%ko 1>$8?#ΊF ˬ6`#18_㵽C].KoeZeG:uԧ_~ipHd3S?n;qQX#˦ 'Њ5bgiNHp| sXޱGT1`Z w;ɯ@&(f͜yӆk~_ruc4[6t2}vryg[E ;_⒥5qϫ۳]\;͟ou׬ڵzJD;fM;^~ox2GuK~׋֯Pu_}uo7Z1VIҫ/~i212Vx;@؄>T^ yN(dUUU{!DPȤք4HX &Z}߇ #1VT"Ӊs61F-h98dSS*+TF5#:s8޵kSxj5M?c.""1cF"H$ e GYO?O?W/~Âl x) 4_OǼ‡SE~n6': IDAT}Ivjժ}Rb0d6lgLuMϜz{JT͛a.I:AQZ۫C@ yApwgrJWepU#p;8&@OQDjt`<"B8h0\xPUUQs P"?$6p!NЁklt,,2BqʕphȋpǎMS hkfo?[>>_x]׭9~y٪Iuc#[l>~Tp35SfɇLMS92gѼZ_Tڳ3X޲mˬ l{~~p8wE?UW\_T8|'dڝ:u7:\pF(_G?ڎb2=EM%"lcE[*R`Li&2_m9FW~dA`MOlB $o+Bbyg=IiE>v#H&SxBBf+3raIB vO(eYuS0"yg;3leAY2H$vqM7GiDP*xD29!dC?r?aG~] ?.fޝ۶yNgo78' RŒ8Y? Uܱc+/{`xm:u <.Rwthp#8@$9aKq82#AH(4Hc@ W,䒙څ˲TCC}&M8*)Fc'ZchƁHiqSB ɉLl&Dz.jN' EƐ E\(8'È "b)EqGD0v1F31"$А)IR'2TRK>ӞbJM0%X *& b#p T9,/jٛ;6\ߘٲպڎ\˨ ȔgeHȌQ\ɑ*8p4԰'*^ro7n\PkG*yt3fto,G4[rtzAnc֬Ygugk'}횱] l.gN5{ީ8(Mjv65CUkV^sǁȆf͞RE:k]uԉBXpO "11RK&ÄSl4hMpVs2 CC?8x^$Jey8GL҈ 6ZpaFraqdҞFK;X(8 y2,J{8J[>8B6乮8AfFGGcA`C2^[C" `QGz}J^~GDFG '%EDw 'E'Hm< R FT^!Nqg@"`јE0Aó%2H$I/G5C 5'ԌuqQ{sϑ3ogPcysLW"$Cl|H2SZ;f@fs iM\4#h8`S 0SXE ~eM" cw!8HQկo~_ӟ}[g>bsc}p۷G|~/% [/|#oo/Www˄DTC#涳'¨$Dz2F0\0 ʀa%2޾s#@Zs 26U EftOqӛ H Ώ9rqPJGUg;N+E?}dUQkǞ ,叝8 xkQ*͹0V۷@n4;vlawmKŖv$,>tC '.0Z޽{틘'|^j&9FtNyDKXkO8.iQZgf D2zz0U__kw}-[n-!Ҝ}~{₂Fk!-%3LfH*PF#c47dhd9 !q R2E9\2X,: yF8sVSID\+#/ `F"m%.-d 2ƑR!CbwK<1 82BTKB9'9TN:%JŢ5 դʑt_Ed# (RdC,XEQkkٳpǎ 뮮$XYulj !H]G+eYXB<Ǐ[8)ZV2att\.L&S*0T( }(J qn筟yA V{>1=qℵB(U&1J5 [zŤJ2TfJ`ktj6k&U#9H&ub n uđ )--NLXaBsWQU9\lαGN1 :`إ]Jb1g14q9 ׌!p+=0,w& #@& c\0}f"?72<醍B;5Ѫ5Ĥ^r\-癗R_j䰺 9.~G;wǎݻ{=h,6l12,LΘ9ixdA Gh糳g̜y-[v~.Uw߰uT,ڕ쩪rk[AclΜَ_aNTWwF#nԳa5O744lشԙM L wsoGGqYhccùsg[sΝ3lƱCN=;_~UM]d vZg ۧuMORt4ׯ[xϮoܹߑ;/BH%˥fӦNh&uřN;?|<3RB@Dvtm[cddWSQq I,v/u Ssz/!Co D D_WT|^x0X,&Jk_{`Vk[n!l^{_R5WZU*-]F΅AũT88yf˭YOHx[[8MMTSssU"V5 yvRP3Sb+Wk}}֬Y[flhUK֤b-Pț8L:L^7~'<Ç5!uߊŇ/;ԇ3O&%0+K%)躮.&I3FUܗ\隢05u] s. R` {r&[")A Jp"aTweuA,wQ *4+!Po\|СC'DZǵ?ǟZp))I)B@u>&2"ґ@ 0%mDTT#$*+v4={c=rn}ێۃ% \* 2P B<陚/_dU:csP",xM)ZqDJr)dƹM)S`U\ 0\_ե;~Q4I6( )*qMD *__i{C/?eKԹBQ%98jJ (bIQS ֔2 Sp!`/LߦmhYU[ZZa& 7ք5DIiH$Gl6sޞ~Aŋ`R1 CJeNUUUmFC] Ӥd+=ӎ;nj&{k L[\>}hm[HDJd%B8q⍎ iX6u/'A,A UT<'>=u|3G^F0szGw͘&hރ~9e  sK29'nqGKjI3[aVݴisQAA#SAQᜱx 9"BTT 0 NcHYg@(1B)%CPH"2 +DcFN6)"9Vź2/?*>ˆة?l}q_Zbm7* v=>CFb˭͉t,=<. c;ٖ[?=YQ]GG,.fϞ޻N='1 g7БXu"Yjn"1ЙPxmGw~XsS#}'K^C,s(q&'K5\vq\DJ+3f@_qZDE{os E0S3 p.?Ř9W3m~8smӉD )CPLJAc1l=Ź*|PK.t.n a i8l#PPdI)PiF㾮JԙJ0J  R@ɟׯ5sNkK_d!\DD&b͈*E8bH]ss):%HMERT}AqX)DC4&3aQ4DZU!T9b 444]\.:"T4CJ 9灅ϴI7$D\/ )BA*!xl <rG]%@H LPdL |SSR"HNBn@WQ.dI I9]^Idd@4(%Dsܹlj`uβmLnwVcfikӈR9{G+;ٓkNZtExD l:}趂7!_r:. ^5k[ p@0)xҜ(?lܰ8ުxD͊k_4ov]'DU[ݞNO;w_iڃ<62<'wzkm۠`td22j|쑏_8Gʒū2ّvCk묹s]{[nd'Zf57mF~?3Mn>`F"3gukoN O'Zgjnh\`G>x}{زVJo%=rmnX9DK$At*36kr9T*\.766wާz'җORw]>򑏬Y&J?~<$ TP,+&:l//#`uݧz3t,+J%NvA8yh!d.mf._vmPҌPf p.+ rjg @Qs%x##) EE'Kq+)J$*O\} L@@)!@lY!*d dµ8RD1C\U4@P!*I%EmMD}irR0쉛gXlWt!Gž>BaY&R>s1-?WՖK|绽 [RG~,c4\GlO};߹5x̮ի2ng;u|E7W;f-h[3'o1[~ОL.?zJV-l E_ȌϦϿcp(suGŷph&FU^+nם/ +Mt8vꍋϿ[Gi5}߰q݅ vtt̙B)- 3AsYnHDL$Duݐ1BzjR)eYSSSb`^lܹs뫫gG, Rۧzg/_^]] M 3V0x@K xҞ1b %l4Fmmm`!tb5IexmZSS;0ǃҒNRP(d`.lJ\5)TV[D)T>?a˂g~埄 neH\Ju k_~Ο뙁3Tњ9JOu3NtBO=ws_a}G{vx}p^{>% ?}G&Z#1﯌o)34Tµ(A@U<)sY>pݦf/\z{!RN'Sqh%@EhUq_5eOvtT4 Hi];_b4͊QBݳKa*2%):w[!T\h`4 o}W2l:.\x!OI8Id{$ѧ~Z"deI#ogZg^5;|2?|tCTzWAh˓iۈ{|yj2@ ؈966tؙUz[VXkt:::K޾\xiwϵkٳA1m_BdVWuMf|_6^m飯= .g-uu קߗV]w`qKWoܱc{:3T_pykB=Tڛg7zܴz{!q ׽K,3qcw޺qӧן;SOmWv~v7xS{4I%It'XL)%8眄a!D>f bUU]wݵp†㵵Lfbbرcjjjjii R f3w@pǙ?}_utc,G"0cmێ@zYDx**HP@Ӵhf`jgF8Z`"}+cr&U~W$|tMoxĥN@ @!dmC?tz_ׂO<5<)φ??W߰aU!- 缃__f#/?:}D`A4/J;;#q}т~摾QǶN<}1=麤?ѱo-aUMݷrhϫJÏ|Ůν/IٽΝAcɀ@?tVDx1KӨt"HR"pϢ(b$ G(!T @s "A_0KRsŨ| 8ݗ6A" "uW(B(CݝUP)4JADDP\7ow>F ׵3[ŷOk(z+ @Q)%HQ(:>a=quB@)r^%17XFI~e'H gƦ wzErRR?pȶ+fzbh"3΅vvuut\lԩS(/VO@`k:WTȕ5ڵKJiKPBT KxrK/ @>308}}}t:Κ={YKKccܹbH"1qT*SJϟ?oYVвo˗[~Pi2"h 0 hi3]!1j:*e fr8h4( #hXxÁT6(KBaf[&lԔ?.YuF/k?$( ŷI@g~,GSZU7D<:18&m;^y'ECdAI_(DT}KmoΓn~w=CC5),"`fE#G2YrŪcǎ%SɦT=;ttZ-YpWg"C28(8߼)l}Qq"B1"ܷ +P )Qv JBJb!KR4T0ZD$ HPA"PD@:V @RT*R / 2A86#T([pQW(} h\_R@p4V<@;\ ZVӌ||%R<ѣ  =tCp"dzX (q2` )t*R ƈLӅRJ@BpCB%)PTpG@q@J ei?G P@ TzҊr="<ψ[rn| 0J8BJfja 馎(t˜|\T6(ն3;.~G`AL߱wwŞ)sf/ =zhƺ/12I R}:{̞0Bc +d\$ |?*Owt`e[Hl8Hיr .c`@)E("UJF5PcRt]B6 @&''})m MMebTJ&7o^`"4 L)MO4t8N&)866vRihpp֭ -x:Z D@lllt\P,bqښa 34M;s 466:ܢ&A HQJ``RX$Rx~WW0|uuuQU]æfBI(%^PR a3t$P~7:81T?{k6W wS?ᣖ]4 ]J+%=g` r>v mX0WN?O,Q2Y=}0WpȣG{9ӴߞA__ڞvB ;!%ȍ ljܣ ~(@{xU\:f3˘sNr "< MJ*PB   D =9J@pU ȔJQ GcHI@R1@QF|/t bmJ D(N!q$0\1J)))jWJI˾3ѿ'EH&5 YrP\p.Y*$(=QD(B*cseT* p}G(" +% yɧ _15R@o;42BM)}DR)`)7ׂfH("k軚b>vŒ>*ȎM}ӟ>ֹ!CrV__TW_WvE_Uc㧖_/}fThB(Bɡb0d[ܰi}$M/]Ƶkyo}sOV|Ƈwu[61VvUyGFFxzʶS]?Hix9^}U7_g?nw@W4r( |Rh m[DTCMMMBa"4MBT*vc}t:=>>><BauT1 Ig:mFWN:* Αѻ)K߁APJ8B )lǺJo/)B{P&*墒RI$xR.WS]]y~Ŷ\%5E L&Upf"yR$3T >*WJ&BF4g<U\ e[X _|xl⍵rIY-s/^:{mtv͛w#˖OOMFcgȂKR H\Xb|b\XMMM.X~]'d"U}EsSÙr묶3ZtH6LEՖlbH5O0Be ޺$&:.mxȁT]rhZxo~Ogom#95_WI3o'ߩR(涶!="6JDc񣧸=7֦s}';]HY_SJ-5/1Sc)jk[rBi*5]P߻P($"2Jј466767 xZXf132s JppвG?g[UUUUUU\4T.A@!j* 44RQF(ed22M3H%D4LӅB!`UP$3Jt.Am]"4 nv4崤`ДRkjjΝHħrdf==Rq]PAR ȹ4=@f&@DI')'B:'.UTzfO*H.L@"Jr4Br1N{s0t$8 omq0 A!tG8%+TRQyo/-_GOyju︛T|{k6( Ezuk׮N 6.m lٺ{Cl*dskrRmm˧&G_Z{ɢ'^j{g0{-Y|]w;vl޼yJx`eKLC*\!f?B5sK/fղP&JbIM7f77/h[ ;Zj+ 5YmcDHR+ERʦm۶]9zuT7x6_0iGjimI$jjH*Mݎm|m|ۼO$Z[[nƺeW56\11WE#D$ʖl{ӦUcÞN NR}wsm]]HR X͘u۷ }xlu^E3D63<<<)eFœr 뙜T*diFT \&-&1CݝǎL#W*Y%X;pxit0ʌi)%0<2:?8:/t]֣*_ꛘ\JqBxlk1M1&3C=}RN `R"!!/nx%Kf%K#ҀybMǬu䉾 l;/Xеm۶mذܹs#8wǵ)}qJR.I!rSSr\I`J׵p$LYhQ6u]wժ[-#S&B ӑhLJhܦ *  4jjl2p8ֹ MB(`%T T2%|T S񶷽m͚5CP>J)R .|H IJB-& VH5ݬ8 P 1d(@( כ.n $"e|VXbyy\\zg^{n]5(7ǿ$4o٭mOGg^G?W[o ~6.>'J(dMY-W\r#跿9sSv_|曟T*O2u5ī{xTX4C;xbD9!K3ԯg ! a٩B:v]/ Q#_we`(>(R6;VH%E,-Bz5=zy|J) /5*>$QfЈL [p+89Wy=QM*.(z}!.ҥ\ju-|%B>hguh+Z#Cb樂PFJ2,MtP}HXId _u2 = \>˹hYV(d2ܱcc$3 IST/_L&=ؼys.K!i:#r 8eTpeWNJQwJHm;ضH B*,g*+@JJ4cј8BJr@!;` Ca뺍MMەR=]SLTF1JbT(_oo8{e+aJuLkl=$! IDAT EO \]vuhs)'gNrp ch}ݗ5N_ٷÏ\ T*x(]A 6˟9*uYKxXg^{Mg ]{mS/zB!>֭[׷o߾h<?hښځ=SygT/_]U],Vݿlӓ#UU]7x݉GV\q񸯢&G;֭£(AQPo:$hn~õLg4JTR1DK pGOL L.sH)%Py\. C3 aJR6ԟ2iTusW"DJqrqǏ1$3')jѢG:4S2Rirr2V Ls\uݵk&I)PTz'9244F9z`BH$"R(E}OB\b/d8TSBCcie R Yɓ+dd'v-uoذ^(K,ill7N^!MTJ]۟e^Hh@BZ#'>;6 i&Oqo>q ?㗆.ٙlkd\)Yt#◾uW-X7d2~m[>dv$Tٻ녍 )®^*LL؎s19 gϭYӫoN?\Ȍ}ƈRut2Ɛ(T϶oE(`GӴv#g#8WNF"<b[OQcPB 3;{՞yG GTJ]>W(HRCWQHΥR\)aێH6߾ٟ]\fk|Y#wq{8BTJ|ʀLRDH7 c:H! H hB MDȘR ?Wez(P 𐥔d(r<۞}>WSOt,Yn셞ys[,g XxR3}kĩמ}}w}OFbXd4ZN;{N}=_U TSO~2開y9rh\kUkVtfP6,Q2Z@S_ӝ?z{?wJ_x^y Gmwy؜t]~ϋoHJ{@RBJTqmP,<϶ٳ[KB\0kٶJ 6666B s&tzppPhR y"a۶rjAȩeYQ@F)3t0 JxܬcX]¦3tJ́|w}t]X2&:x'=swӦ`;A$A")%Q%˲"|elj+ߛ8Y7ql'r,đؒLu{/`L=m~?0(YےÙ3۞yrQ ,u=ڶ[5gpÖ1 ߵ)PJg϶%L&u]3u+H]P'W-m" JW.bg?-T_<VQQh(:!3nѴd 4(00_ b@ \؞m{!1W ~k ?dR 򕮗^z9P[Rs྇v%K 9:a6 +)XP}BxM,w2"( s7mY8oɮ]OYonɜ8sܱSgv܉3'=/gZ׬Md;zƑPHKOg:._._8ЁW/$aJ(QTe@Kbe[9]RRyddOOWc\_l?VHBgON>W\ͦc>uxn2 D"a;N2浴XB(mG֭[w7_Riz>r##y;$ZXPJQNuquKKKSTE>[nv6 KMUUUjϺukēXq:\6 JR@7>v$aD"p]wll,}ifyeN#9A5{}: TyX}T@)CDeG ĵ]ݴ|38*thT(۸f_Y^.`@<$cLIB}>90)2ҵќ^"PA 1R^W" LxPM ~SdTo!@M* (V NtLKҗXٺ|˯xbifxpfF6ͩ=ו}˺mܶ'+-sE,6Cݜ5%2g+UY:J&Sec#͋\t}R]S3OQ+>x` 7ί5P ;=]'ܱKVR訦vX3)6eC}W/^s*Xq˛L:9EzJnO/PQ]ӧvBTWUnSoe⌇5uѱh$R\\\VVaR*7mOL&ɩh4:>6:>1(VT[byssȈypRJ9 ;PBPPu=UJyE"a]7 ##e2.aĊ ^`pf#u[nܹspx*9JNRSIױөd/p΋Eoiq-kB\t˗2LPbU+䙎 q3\%U;;zM;:v̩#D7QJeE}gNung q˲%>qs PnRQi}c{,Yqz|PrTQ"(ѧ-vZLގsY1x@WXyFǏMg25֤u8pqn]G{{wyw|o|d7)E=R^^޺z/>z7m9$Ҳdɒ]hʕ˷߾sl綾^Jr9 VG,RwC߼ydlaΜںW޼zD3~cqFv.^@)E z̒c=$P_PD0d5/>̀)ߙ\ǿqv]wޱB[{_ 4FC\^8eMK}UqBUku^(/cM;u_@ ZjQ/VsCFWTWoX^ؙnHÜ%+V;ߟߴ` Lȑhd mye(hqI1M7;LXOefچܹwyBG֭_repTÉ-bFn̳}~x# F4O٫[[rAg{PJh埧n!c~29I'''Ѣ042dl<h߹z?pR* SJ }030/hh@]\\lf,}6/*Y!s BMR8# ,S60}{ҥKcccmy!D"J, N¡R`YV*t] !P j儒?W3@\Y|ŎI=vfΞ_aNoZ5>cx_hK='?vh]WǗkZWLMV._}ѣw0аx^x1s=/.n?~lbGEE>7p sRI`f"p~M>> s=8~> PI "5W(JY<4V>;n7CUKV7qd󦭟_súշ߹S]FMeՃQTV?ܼ[?x>e|G^Dٶu[my7jLyk Z;wz׎ס,]\jYCɼUEws{zpkhfNC}V.3~ѢQ%Y)HzXeifaF3K  &AGPNwqةc'瞿Ц*űυJ/Y < o~2>05NH$Fb+<220GCe6zKqKפ‰TF({(tiF69LSSI?OWU7ܰu癀a&elF ]eƊX];opB:;A 7eơ`SUӪM-CJڮexqwͪPhVIqX{PBPf{5̘@_WO@WO_gwсѡDbtrb<>0ɧS?7>6~Cz;<~_Q*A t]///YH<Р$V+*-SL= uKUDBaCuk2kӯL# }#PP12H( "kD:=LN$PP%Fa縆mJLx~>smS6!WtG~FLC=7_7އO;,]w+˞yi)K/ʱ͕7?ɨV5c'"-7lyj<@^W-vBE^௝_ΖsT+.M{٘cj0+z]=]]#>qÆ TS;{f4 ϋGϴN&&T/Jf)ve> IDAT!˖=VNJc5us֮[ޖ-[ϟ?_FF}I_!D8tH/Фbl\DrtT*ŋ9"/E㿂zSlL1aI޹<@!%xؑ^&W8p}4q+W(J_ җ}b2iDk||xkd7RD)4Jb޾Knnvvݳ WDž+;_"'rCо{T*EQBڅk:@J ˈ<8=/ҁʿ_4B:8]H%Ӣ;\J)2QJ ݊D !E}ˤT*55ǏtɉɉOQL ^.)AfV3nrp>Xh4O:p/7hwttU}ᢢ@󼉉 +ԓ0@su 9|  O-Se3{ؙ=}#>{o/_|?9sٵ+zeETʵsVmIo_Or23o+Q#{G4g,MM9իהe3XiWYq̫sbEM=;&D xM>GyСCqzXZg2F դ=eF7xt8TO:sŇs<1kt1b>2'`rM<&e-nnJwpcN]G]{R'R.V5keeyc#kLdSv*ӴԫjC gKJ Ӻp1.B(||_j__СCgߐOny˳>i)2ʥT8UV4ISVȔp(ry #LKqN` w _r 0ÃY. %QBiSUcAsHT奓)Nu%D8RY'gs` OL)J!Hנbx[)F=ΜѱAu! ъ޾1F!lBn*S"iXUUU}:빎ںRGiy\pA,59)R$>})`y يLP@ S0ttUeAmjB Oovo6wTUM2}sG>+q xGwޭiU+DvL lms㘦^,w] 19m.2#e(Ĥbhzpd!u* qNyb[AӅ㇙ak*AdyNulGGۨ#X1Uɬ:ER9G(E8)Wع'gDޱ3t.0?2 s")%x29%s1@JSer7MQOf9~.fd)uͤ##BD|Qkb2>O`0}wR]]}79"6…/_9)Koغ!9ڱe-?GLè.--IOfhڵ!˨m4߱ogqӶ[nNma[|Xq֭Iq˭,/%nٲ5M\.dpu[R)+T7ݴ#uT5f~?ݺje]Mz[mݱ41(-?422VYU6lXOܹs捇d+V._v¦+[;2261;vSSةqct4P`~4Vbсousbb{BUVVTUX ($.M(=ߔ@pϩKSwO}'>wxM[sxb_x1W_n֮[|MPm|/+n.5XI5<WZ58xx{#_jMM]uu͹s}]8ab2˧ E,w/_}΋˗l޼ OVnveV =ЃNOUT;S@߸Eanۍ~k_x{E-Kϟ۱u\t_| ]UWU׶oԩuuKW8 7qnMҟԧWTUo꾾^x)IukN}55=]\nhx1eSwܱ3L~ʼnDP(5(|<ۙӼi _ei5[ HQz4""}ITD$"ARD<+){iN4$UwK} BUAC$*F: <"$ \߶ BWƘkJ߆XG5p-v*'56g &#ς"YT4eMPtM n@L~Ő  c$/)C\6$͉.!ku iRμikW?uڡMwZu/!"%W'aÂ$jk~=hg=_ٶ{J644ι75T0"]% lhW[[wI7 vts^(7p, +׸8o7~{x?YV8PI@xV(t #ccc 5#󶓷)7<כ۴`ruS j":ׁ2UAJTa!K")T#HKNW7JJEB`H%/M,J"vR(QPQ"h "q&i O>TpE(CN(*@$#|&be eGƨ B9(E)%>AJacBk;3mg.]ϙq3gε,jF%6[s'1igs"yңGΎ&&mٲlqrgNДʌn6=U__=HwONfٴxLYY{cC555xttts-LOLĊ"ϜM>72w۷/9O%##cE==ãڻ[,:u/]ꘜt]<؛/ܾ^4FFF'&&&&/_z]m۱ҁ>F压#'N,]lll,99>L[[.g3@<L$TZ!0NKs 2o=w3sqT@@V\\0qbBiGT`z:XCxNsjSWT,{||ddd$?'SJ4Ns٩ORJyHJ7JOfxMrkVoԷdyc񋯼x0^޷wBԐE%A'50n_s+)r$}\gUYRLne^؇?g=5mtӑC/;u;HG=ܴ}{Mu领Ou7TnQKˑ}/ kTGR> ?kJGRO#8xd_g[~ս 8%H(A j?RJHpAs>>>KKKlgpJ%''Uյz~ G_D8qأX}}}CC]>o{~}y{hЛXaCYi&%D7(L6[u2=YXodj\ػխkV:N+𚒚rfK5fy}=K*Z1d|k CJA=#J BUC'Of{;;ZEgG?2;w,2^+)(wؖn#sSO_zpևpI,qb|8?ɡ< W~+E BmgO8T N^yX2-+5sMm3;nw{>POww1L;=&1tshYy?|{oۼjmS_~S zinƒ_ 1JMwJŲv_}U+Cc"3_]ޢ,?>p pem9{WuN眙魽!eb~pcuuFx՛k˸y+";FWJԟJ>B(# (cO߻AJfuNQDʧ>~"^N+{>5"\U"EFK_| sL{\zRK1XDew:3{~q=cLAM2JJZ:t"FbFCO(Tc))GBFә:{ɩ]<}RH!}opp``{MEE=MLL[\?|>uut+ݽ/bCm==ښӧ#6m=*EX1U`@5@F@B4!\E-},5_FBxESQN@@g.p AIZ(Z"H #a-y()L* ůU 9ֿ)bYJFQS/!?_Ɠo}a \7TFz/%^fwrדa<6ju۱cG~Ѽo}uzQMה$&}Γk_}L)366B)}$Q! QzE,0!a!2$a S4 SM Z B:ؚ!,kpP fA(HLC"Z: s g@:bfHM(9 }?`$+lPt\J)fLk_`g)KпPGSJDZ,KM_<5~kBP$RP )kBiP`O;e PU0 #1 xa3XYAJ)Dy*]>@u)%"zi6nxMLj>\\_Vpfgg v &RAW@ $WL2bF($i%8CH2E}% SLIQSDc=D@$ D" 2BP  (&GBPy-6. Sfܪω3igLf||LJ]aWȼlK^g_=rxd4J;9H}ȸ^2'c#vNh(@!R(Rܼ  #I) _*)Q@T|^y$JPHZG Q;%HQ(kz@T`TMbJhVDJR R%QL@(\x gt*TP@WM2haeN^3ȶWEjJ)| 5)_as$)H&tÌ:h:"]WBR* EB`"Ψ@JJjUy{ $~wWWoj9zW\ַBo>ynȱ ֟|'}gEiG5ۼOݿfyiW>:|^~ҕ;XOǿ5ޢBx\l۲VS>hq{wblɋp䖛OL:;vE,dα%Ku75,ڰ_:i])[XK!Cy{hl[mk׮OyCl޼rw}~㉘W-^S74ϞG/[Wn~8wmٲýeeP֖OxˍiZ8V٧yy| gNذG֬|>u\():vyӦ-/mSܾt g?j҈7}nݷ)p7',Rqiv\D呚PE6ؑ{*ȓO>96:V*h5(p[W^'Nٶek֬G )4>::}v)Վ[nxƒyQ T=vm_loB!,))]beYP؂MK/,+dYc{gv=3<pk6^7lim--KO8~wrU_{+űv^57NJi*oRgguMoo+(Z,uE! 5GV[˴(%Y!D{_BVB2z<\#0 %mm_7:Xoqs7K|{ʅ"BF/[lphZ)d A31ʉ H=de]t"OQ9RӔ'(>#T L#3`RAQD DQ ؔx%z^^)ə DJPJ%D1>s%AIA%賀ߨRUUU7q\׽2 H$H, )(QXŖdIVo.rIq'K/I^M-YJJXDJH{-1 jز|,s=Wm*·{gc&S%ga_z9Ռ#Ajer?o,]822<>A-GOvnEU+WuxnPɠf=㹰#2k4\V7ߐhmm]jMRu{w@K20d#\w㘲~‘uduZڻEZ}dՍWsΎѱ")d&Mͩ8o2t[{PYY9wN}Rʾ…v)-I5751ι8aαQlzbg<+/^ZJXIiq`EE.)3_>`Iq@WWx֯[0w k3=]#ݽ}6?1Q+72,~`c4?5ͪNXߗSg6mj*t%W5,70<߳r媖#ͮJ96PUI+\Gf--ga[K&Jױ6>nXhq"hm-ZxK/=|Ѐ%zӗkokCKB1:2𒐇i"mcڬ sڗ=sB$X==@Fj.wʍMplً7̹mcA  \}H$C$ K kҕ83*Ȁ5*jgRVЁ`1 xzdd?`Dzۂd`Hb/os'+-%}}hN \]rྎd ̲3[Nްrcch-!4`2ũ85utD߳-Gt FLk3PB{%"EIۑ& aetV{azq cܑ^>Fŵcg0#,b$FWV23L1nm@2 7s"5Yp]d5-h1Cs0*T$ 5ù: Gq\c :6s]aI0&X\b+] Y"΍]L5bFPK\0U~ 0DN6*ߐc^rN>{R&D“w2x 9:RANbd9ҐQBIJKCXZ҄\HX 2&(T,Iļą8J]g820VK"B߉9J)"mIDzBJWץ]b>O%B80S/ct܂!;﯌!WYF>Yx! a.eɓRAbVlg;=BI#-^͚HpXL$eNWd\V2om(#WIz+RE ah ΚroDo}!e8wkOwPE}O^hXT\s[w|iw-zn}Jjs"\MOOnuTC-Zgyю7%}צu'Ԯ뮹jgUz=٬Yv?g]U-*l|?g}).ing!?ĦFH3?$4La$˅J:a[(fYl\# r" )~:.b )Tk݀"j6ɤ \j .`q(qE2>cY,z)3JI)2~֑, b"7A#(?hC?`jc5A`#c$E: 4#$  Zhgah,Z`bCy 1`&6B;!+ɷʳ"mCh2V+a> %-AP*2^krkl!#B `&T& |% rB=1P$H+ GM$[@H`U^NUehb H6 ='XƘ40"`ǧ pTx(='=hÉYq0)LucùLi5‹&< U:cAX!WזHY4 ~FFBRXsGq8W@Q#EiHؙO% .rS<R]xZm2y%E#Ecqb}pmC\1$ƅLX_1jX$YZU>#7>Y\>u=+xm-%˥5^s=w|ع_!j䇐{m8-[ZϞ=f~Myy g/r䐪^ze369Q5OyŒo7T6;Ms%Xٰbª U>U䎏G%95a둞E 'Z^xKNg'3Ool|ߙxE~c$kuK[rzdJ@5.\bμKjwo1>6:z{{9x"DlhX6::On8CGږX잟võWg/~:J`,C[a)yȍ}B4@ ,țJ TxqIJh" M"r8A"@J(XA$7D$HֱH0r$N1@N(YA!aPrМ(TbKq5B-M@ gLj. d7~Jq6|ߟi'5d]Ʉ1V,T &\Y%pC ~8XMʉ 5H Ua\x>-2=tyEI|q݉{zW._պm>+uI'ԵLċgWLǮ!}Ws׮^[Y]v؄婼|7_kU[D1%)#]w]ˋ?q:8[6o9r9s~m6J [ظ|KM[n @))XFPH@e IC%+ajcA>#P1b9#̀0 +f&'`bd "Ν$XPw Er C- 0ȒAňd0*Y]/#7ž,ԖV3`G)" Qux^UQ  Kc4AS4L(j1& 1XҮ#c` 0|q'q yËChtvtE<眱6T{G" C9~< ;wA`) VS["Pw{4EVai=Y`޼yB"UWPkwoM7o~.]"KL&ٺeKI:GjjjZt #7|{lZfq`tmm}.89>tXubZ3oc\ n2ZWYՕlzǎgK.?/~ V=iCVV^j޽XlӦMcu>!a_BO-ȋ _'^j h F6EE"YdTPDh#P r$Ihō @$`d ׅMT@jZfEbĨPݤBiJ4#C8J1CDSiB7vE YjUi`5܄kW}K_nu]!S ViXlт/?j,<:Ao[6׿ٓ֒_?4<:'SS߽sdtM)#܊u~_z}k9yk?_~_z+fi?ڴ驮'Ќ+RoW3yWȝ"6D`ֲHjg6J7_9-Ol\jՓ}u Dauwo,n-[ Xe+  3BH[Lb,Gi|EǛMG"Gƈ @#c4=iё]:V׾vlzHS Q ֢obaF$GdGD$(Bu]&DgLfH-$ahJF: KAH݀\u2f CJZGu"< 8Q$clNl#¿H/{{晝;s y??qO|ˮ]^wEV->nW^\`w48SAo ]Zԁo+QM/G{qّ#眵_ʖGGnzݪ?ֿӿ޷o˪yξC/y잟:Zy:ˋ|~Mgvdo@眹e듷z;Crߌ  /;ui;[Po>9vjT;_W>{jG!O:|&Lsd@ (kV4,GoΖrNMYKщGڒ A0Ĥd`%$kȋ˃sBsJDmF@@ 1)l YDғ6n*NNiO.dTɦ׹ѳA0ƔRiw `Z U땢cwlo页~h'8:UwgQY"(X0.c&S , i "B* >mGLմ=}Kl@l-=/N-G?}rS ݝ;"l9<~a"wVia IDATqq< %v87хoow|D鞦}ik{9%6>QɄH%bƍku7 `-33`ႧwDzD}Cq&suu]-g-=}jr"0{|08c2+1wd8SVY5*oJB9~"zyͳZy!1:baA1r\$ 0x9!Pӳ:Q֧Z뢢L&KNljdsDB"Az@^3]^ L`_nhM lX0 0rRNNOANCu~Zs&;1 |_τ b,/swb- voFxD_>!Y  =ځd>IƌyCϛ@ /B2߷'(_IddYFaQ x`ߞ~ⓜ=t%A~ڌ3]Yk6>מ uZ[}!BFWCN]𳛬,3AC@Dz:^:Pb24\q~;9%H*؇NY4!5 >??x7.ZH2 Du}/ 1`ETʋ'?tؼ/.N}W0BTJYJJ˿?'>!9oC%ùaKJ?tˇ?-28GDqƕR-9]g10N;ě1:vv8FRS^ .zudEh&a.Ls!YH@$H NH+A0[5HHt4ID J 10iW8!QQ:qԸsל['i'n8c/笘EࢅOP?]~hpO:%-O=+Zd7iG+ko8U"%#:|.1EĦM`^V H- ďfmL"̂2aAZ"D+BtQ\YM 9h(Zǭ͏p8J'"RmP,G$D 19Q  @I- 9 k<@[v} t܈ 4\(RN:qLGˑ0S<7a?@ы_ }CooXOwO.ۿ 7鲥OZWQQB"Z;6::fe|ҥKc^vi{{fK> qFT^^ xf_|q?866^[[;3F#՝qƙi9BOOUN=K_2; _{-qƸ=#&n":%*OGW*b0jE&QHQ=Wȣ3գ)BFiKN_P0 g'o[.|~fG> . _ϮZpi|>^wOXQd.MפB#2PHVTaXdMWX/GoU(TJEF ϔ,gُJ0'7<~=P*ew{Jc(&ƞ߹w i{6>''sM{w.{ĥgooms>km3E>xg{3: ve?Ɨv9s xݝãJXS ̪޸pn!}{<{ծ{g{Z_|] qƁk0 ߶=o)^K>ox#/7?̩^|`3eH|]ʓϘˆf=@EںЁq9nrO=8zS}sL'ο=ysϹw>;;On97d_icPG!F1"B}ݒ+~L{em8mu˯ 懛v-q+DQ%y-xs]_vCi"q(<Ƥ#~}1nο&bgXXQA{~db0q-"A΀B+fׯ{3:G5"+ D`/$ tu~_0Ƿ/[s}Pzrxd'?Cu0D9~fb1H;N"*ҹ}+Wr-3He39e&f8f[ ,L/Omۺ%K[ڂҒ w}_?:gJq7sօ_q58 '&&׭<':t0DvFD\+>fjmґgn8?1a :%x%RcOf$}oJxTYmI N!cPD: $b 0JEdfMG4V &Q @3%2ɤ8rPa'9gG6aqD:P:}6ഗ*KYeͩ)H^8e9Ef=[9-*C׼W}Lvi=҇":ۛ/dx{K+9ܑ ɏ}S=kVw^r)C#7=󵍏<ߟ8mBp,Z583[hHldxlp ,Yb幋["ue-}k 2LQYo !<RZ BqH@@@688ʧ8RȬ%]Аs8^R S]6W)|͍y~>ВRugf;:I&XjBd|8 uv'Q% OpI7+W7o;y>X-/^„ }vډU ;ž]|d%yӤ %Rҁy OxE:Zmmż8Ę#To!ZJ!Ιw uDO&2@$c1Y&gRqᡶӆq)Z&Օi4U*̀tL*2V1jk`k@PV1 cN-L3gErg&KB2RR,cY2#F")Z)J[d4VWHμ0;N~ښ'x4z޺W̓ދ?{Ή؉1 gLza۷m߱>xTSO̤/έXyw?8hZ.TYswwiz]cvG @0ͥbMo"d>}03FZYG&,嘈`aq`_?;ȋ/~gϩN^ڳj>opphphbʕAsaܵc/֖G tu$W<1jFK KWܹ-<3j:R vU Y_9*Mk?9(bىn&-[ \GOf7oȔ twuQ򙘉F/x[[0'&TFA$1%J.(M95%ra^`,HѲqN7y2-XUV}k֎ Y8 0a!_mgf;zvjgO+kc"+v~ҋ>ve\xA~я*=6 aޜT~-^w27,[U`nUbd*볶&| \ #+߽{מUZƽ`oT21*Xhbnu\)̈I?4G&ꁧ`wM܉ik~Mwιb`l|b\[L[: U<-5mm|(rx٧7̭hDz.f:;'FFX67Kϟ_E14>r'Og4e|.\ H~tx00̮:4s.4oj g_DC5_y'B|\~饏g,-9apƍ?|dgM=};bVUUUcc#pl.*r{:ӂʑN 5+򲟍 b,<ס cZQ I Vĵ}ʍMzNf׮?G~ĦҲC: T:8xhe}0w4XЁ*պu]wOv깧r㶞ak#GjվJ!Ԃ+Vg'/:Ճ g &LH+]02i%E?ȶg4<G܁[JVvvOYE2Bp\iǖ٤ڑo9ŨǭAԉx<14>odhcOѻ^ڽfW΀sJsӑG|]睳e볣=+׮z5Y|Kϟ`w-Zz]GFFF榶u'{##Xvt=rXHY;E*5IrB$DDh S!Bq\v bi8A@,c%K̂DD.&^p|lzc2Ӗ9%L51f2YaEKT2 pqwޡZfWc)D$<'rccI&PgKØț l#0$ϓ-B#@ B !!."$I`5)>DH` %D8FĀ6!)HlLoR־u3/4fisOMMDf$'1bm"rQEZӮ "1BAxxZ Zk k8BZ/ʁ@rJWs/m$O|:&+%fҙ~&t{1Btäp+47V Y H;a)7ؓ>@(DPD]@.h ! LiBeѵAH"@d2 4y]>_\BiB,"!01@EzJP TЯZEb 1 $H1!5GH)EC,G804 ,p "4\_a !Wm^cs lAׯ`Z9@Wi:9^֏{zGjiT27Eb IX,Ęa!@ BEڂ^b@ڷL^8[`TXdk& >agP$Vƀ%ejͣR5Y3H0& s( ,")#˗yE$`4L"H1@Df ̀蝑ȦAٮgY!5|&fR_" #I!\)|BΈ!F hC!D=$ McLpH<@X"0xk!!+$6HdQB-C}º}gd{ÓM;,YF>r ~[ߪY{ /c篺_矟wi?h[V^/|;DW-g\Fu\M%hrHTQk^vLG7E!h?cu8?tHmf9[3%EEmm|e%}tXQ^~W3,+lk;wn"5kVccVEêeC?׻-/}꯾%K懲okq*Yw^LK$3?K/iʧ,M{+QGiS]G:dUsI IDAT! lIúu ~8s7?~Uͽ놛?O~|l{֦œ#-kV贔r޵@ sn+.{ef t`@H[0zO?tKԽtcZ8Q:=N?u$%f\D\/.T)Z{ @ξĜz9k-=q?5oo?\sjhhXpџ}׬..Nvuq%]~e6mZϚ5kܹhiYs͟7w0))*r)eܷ',_p5/Z4h3;]X@;X41҃U(68-#[(]DlBiYԓ)hо< mpk絫ʃ|~,(MEFGwl;\m۶/H-Y}\s]/{X70<ڰ~nII9$qHKK6i8*ݵ;Wխ*T*#$$d@A@ ڍunkmkݨ`<ECD Hb!w&ȕ"VVV>6& xWveJ)4qT.x R-#8gMC4I Z#e Jzu\pκ~՟ׅ|7|f3z޶^XssK,~`o61AKp`>ԧD'23 3Pi2?S1eJSrkHdI 4Ө4JH8B"IB~?yDFÍ  bH$ 9yNP^N9c`F!\r& $D"s" B~֛@<` a QVa~~1fa0+4@-rlㄡX%*Ј&CԖ y9con^Ҟq Uu'q 'ζ+>Q7i|/~z 8sV*Y=wWϭߑ>/vo8SD+O_xƝwuy] @B>@k5+*%lY$]R^2鄰2#Jk(Xtw4X+Y1̗R\b0!CPJK&@0,"M>mș0 NL k,KwtECD9EPKOo-˲,а$kc\2A$hf oЛoyh'P1`F d+KCJ@?ák$zo>JNymxe]@Ww<{|+Ϙ?{] + |l`1iҤŋ?stSOmn,߼zm{uАG_9~Cw91|ODe.0Ts^>5PQ2<0*"-u)oh,$ ""px bH Fi%+8igEZk)_f>T![J=x0*_|lŹ~ꦏ$ +^~dZ+21wA8r<]W/#3yy@DĹ#eڂEuc&4?E!_( `}0ОM>=nذk!e1%F$# {lȊ%ISO @/kڮdt ^YN;w҂g~v _\۫g|cw79bT_oC܃9@25 cЊ#!0@&03 }?HKmZMDBmFO@q HC!ܻu ވ@$8ke=1dZ К11#Ep;V:QY8c`8kMh?[d5[T*Z[['O˖-ۼy]~;\.D aǾMSĸxɟ3CG8 >׳c'WXquG~@L뺏?x** {R* 3o6;_ z6@f1XM<8-pQ(iO$UWq=DF9nB?m1vzogpt3@oz~/Bp DidQ "@F h 8'DpsG BL# ` >0B'2Bx傣rAJuvvq˖-o>۶7nTJqc !^]^!T}J2z6|VtJ6m-0"R*H( BA+ T=9 B qOA)e'MjH獸g$G{⪥7:=+^<>w>opm[ htT0C!wM?VX984NQ_ no< p^|x_:̨ԇţIb(C-%bzkK8a~Of9g̐R Ji)IpCRāH@6SQ߅1,#i2L1LEbBkbZ{qv Drdi㡾XYԳtGb~.jJ<؝qV:C?:[)!iYv$=jd,¨0 CJ8}GmRX,C>a„T,7Zkι/QKs2G+*.8VB7 L)ej:~8?`PJ5g ]4i=):& B$r%)L1 Bu$hP.-8, IM 71#9 8peXr{LHOI|U GQ#+{RJ)"5I z>9"~lJu38Bziq<Pz2B ¡VBæiCaDH|ss5kbXWW<7kבRj'MT*|<BPyy8b51J)q\Ky}!Qg8G&ˁI0%jDk#2f+BňLc'{Ų%C(8^PLG ,!yf3<_ tPLŴP2tEԖ (I1y#MV!$d!ȡk2E:dXC-#d:q胟>"ҵ^2 k+BdRJ?J+5qr滇anܸq'c A9!SsDJTbpk{*bq.`R͜9jR2tl̑!03f!"O&>l[[[{{P Xx??q/!7a{##>">2:DAPrE4p̀}(!BOpx޻Ω:IՑ %JZ Ÿ>"J9W},{S.[,߿[o-[Ok\[@>{}.$Of!P}CQFRTHߋ=dnn|bP6e 9kZuUmkC uKșîSDڳ)%IS0ȚO \ YSU #30\'P:_*9-δ { *O+TSڛ4uN.SaN4m&c0tRʶKJ94#?c;RԒ%KƏoB:<Y3qhњ0"Bbh+=?aI\y n۶lʔI}IJ}Ik\kpX?sM|P(tuםviUUUa]{n(D=5Ms ݿX}oud&Lwn:n߾0oh}&ڼyW]۱+h>_ZvMsiSN]iSgT5L򾇟c<UVPE]/>۵K o\|]sc>kux'l5!L~jp8xpU66׮)Cc 4(RR1dJ+˴m|k_ڴipPXX߾=)]'n?Y#jMxq $"b*&Lڳoo(g23dyR2εRaU x\J9䓂F0946Hӣ%7oE577WWW[ .;ܲ,S`ٍ?έ]g["㚚ngˡog9ݵ GaL"y^B.ʆy>e< ,>gڬ5&>/ϙ}k_ݰ&wU2mIZk vl6{}.UVV۶X,>}Zccmۢe`Hxh*`$/g  5uu=5uutf(=΄izQ+R\rɎ;R}C$SCs϶맂kqGQ󼞞۶MMMK.bǏgr9qBЊ+n&5<֔`3e 'MXK`6œA֬].. 8x/ݻ2fhٳgǎC!8|>Jl:# tlwS־YF!B%9g=Õlj Hg`G /E'fj xC7]={vH)ےGt،̿uzhRJ)%5("PJ1]]G^].RT8L]R)ヱ(cֽͦ挳Θ;4^M3cX.UX}sx7OWۥҡC3Θiu@)]W+M~IqS="o:OzL*_ ) I>l~h@hvSSSOOϒ%K&N8009O D¶X,v=,^ɗEc37idda8ڐMgy !Xmڴj( xi``}aB`?HJ۶쩭^` qɤ:XVݳ* dy{``Kh;l&z=UՓjML=yz&3Ɉ?LgepO=.7tWH#t jl˥YRmcd +_L'LFv_]ƲuE%ȕO?,++WZQ0@)h%-CPy:s,tҴd2yO8pgI4MsvFJ)u+**ޑHH &BhЌ8o71}V#3|h|}(D"I&b{lܹSNq]1nݺL& uww>|xbq { V١/eF L / IDATKv) ~d[憏,"|7EB> DJ;!Z<8vDDבMdvڂ-Vb섮x\ެQ9QJ15h6WȔ?i똊|$4&R:ykBp)q;+l۞3gĉGÚ\.UWW?#󭭭3fxɓ=L&o~sΜ9~(! 0 #IO[ZXr",6iHM4ZX"㾇cdx0˶sV8GD1Zk sĆۤ4G:F\‘k4CFH!!|h cH8hO}}}eeeg}{zW>|wwӶo5uDr~JiҠIi 3,L GBғ_xŞ{~޹e&&=->.]o[l BZkljbRʗ^ziڴiA_uWTrbhFXo݌C<4`YV*=00{gwvv ͝;:tPKKK__ĉїWUc})"&`f~%mδu,YB`R*= rΙV dYq{~]D?v41"< MJp OZ!P# @Ƙ&0R(!S@Tض}rmO=y|MS䬙.9.fq3Y/켿>{͟m?niɺ_^{5WbB!o_vPچͳN>g͟P?Ͽp Y}ƚ5k.]dM_Jɕ+WZV`l3Q㌔VD@.;op8dY֫Z^^^,kkF+jgΜpBarķeQ`Z}ӪopkmФ!];vreYc}=A|8 2Q^K/-++*Hjԩ/BP8t萟;-ˊD"Ǐ:3D"rH$ RwSE$$B frt#0vEgCL }3 l&R]YWP$`VFDoSTXL&~y2-_|ѢE^{x'|2LVWW~qP4汍b2, =^}g;NK_B!^DuǞ5̈y|J~'6?_o۞2X;b瞝sq4让i>cJwyjhhC1ǼzZNXݕ!)DX:]V}l~tvNRٷzڞ]Bc6.]z#ՕG:::vڻsΩSKm۶rHGM}IC_0YQ9=1U{{[:RL潔V0}:k[_PWvzu}(ܼyk:[,;>|5uVz^_ o ¿}k.[p+V#z{ [lݺuK]CdmӧM_xشqSlھs1vԭ>v S}t:edr…CDY,Wϯ1q;^/ +5 J'Qq)3d|J떉-HSg?ëVf 5i@"Ƹ "Z~=c,L8ܶm[ccaTßf;n8D* .0aOR)L;ȐIs$道59 N\8q^y,qTer#ۿpW]tjɪq0,;s8"FRdv4{|I9|ҤI{˗/ollbguֹF}(~AK9d]T)JYU4\c8QRpg><41R3$+V *!L<vz`5(b;@3aY& Ci1֞ʧ5Cwnd\|ߞw]ыTwuiqDZ5#?g^*k*"? h+=A&ll.x\iڡ4xU4gDR0D>X͛`7od&Mo}/2yҌY3׬Z Ra>[0rYIoVr]W^9餓z{{}H$d+x*E"t:- *1` Q!*F+ ͟Ւ5 `ƒ9.]ł`kkɓ+8OsT!#BGɁ,_n='2 @LgR|@BQ6f. =T2H'Nі[RlYY`}}m&Le!Dss3uXl``@J]__/8plyHk-I'N\>78k֬3goʲ\#Gtuk҈^q'M‹/ZiM׮^}UUU>s=Dh~q_EI V l֟M~Tn=_8)vέ1sKrXpH@ W=m`(x nVB·ngX,yw%b۷[nYzuWW.QWWK/ٶ4JRgg=~mUUU/8fsC֡T29Fy-4p@YP4! 1D@ 'djDM*$) m=& Cyy<=F1-$묳֭[gYV8fRP(d۶:ht07xRo2AiJl6GK۹)Mө!%P߽ߣ\F0 ygthB==='|rIDڈnhhh޼y/aByKK޽{ɤrڵ---W]u_ic}!s|صK1= ` j?j 3PHz %GK@yLLМPj$"v1Gǵւ %#HMh$]"F@ 2Ԥ] V הC`0t jxP#2"B_瘹VDG\щ)J|> \圭Yw&۶}܏ b@86 smE#jYN?yP":CH{{{ggmۆa<%^r7~Sg~'ׯ_?S{{5P2I#1׉CWUUP(t뭷{$ٵkׄ } X,V[[[__?y|>O,D",ye1/n>Hc R(ݭ4j5xeƬ*#C;w8F)\fff^~e UZoZ/xŊ{UF `0ZZZjjj^y_^O o}[K =#8{ub?IuO᝝DJ:|`O> n҄T:gd0M:miQqUG%aEV0$xpD$4(×A"$VQk8N$'?`pT( ͚5yӦMl ZlY89f˗/}=)Dcšєrv&5U6$u^jH1f"0-Ξs&i,M.=ٮcz̍ˢ1PkN!Ens,`ӦMg?faDHkST(K|ĉ_}{{{*RJJ&͛7B1;އ܈1rXeoo/y^+?}E3g-9ofYE׏>:~q#?]<jLEb?~¹F]<5w}+sϟqޡ?coȆrɍ{;Y2= &7M;څC}o}_e\xÏJEg/^{ n?SL_gyf߾SLOm߱K+]s9$לq= yg5^ZCe˖-;|ԙHرiˌB >voIsg^xr+cF\YR ۶ǍgYVccǍ 51e) ~CFxtU]]]K"qcb1 Zo~5q% S FK.pXL$h/e 8tдi:::/_[ZZFYvD \*  IDAT~5BS@p:;kOAkfG0Ƞiޞ뺟gxkkc9m͛dɒu֝ xNrzl%XV~y&5t*vziUSN8P_+6umѸ7mƜdm3rl}2{p`W~U c(t3k}jd2940(tC`$"Ο??NK)9~Z@AAA,령LjP1S=;3q򥋢ASI׵ .XtOˋY錮i.p[{u/v[nI$>8a"ڶ}7.Z(Jٳdk׮R\BN=DE:oR&0Jynz sʋK|W7qn6ndF2]#h:;%R9!N4>"tTГI@s*~'u%3&fSI}$̥MH{W I@HKN=6cBרq53H8Oq PJ>i2t\+J* Cr66M/>Б#GR,//;^x!P(dv&!fΜYXXx0H d1=t{Ϡ)Mq.P*`\ q]I(2@zؕ#=䶴2Ǝ~zZVьVܱci .$㵵[lI$x'eg>588Foľ3{zz<3M3w@. t!)(`!hB,p$C)l8p=i@Xf\(" <0cɠI0VF \ R2.8;z̙3v}6$()d4ʎCG̀g.pB=ई2T$0$MFي\(;_ukkǟyT*`MӾo]T*u188x믿1z6߱cۻ9CLBCÔjR([ 3I!>8er& #B`BɄ(Jا 5hHrD1/F B?ir A`$/$:b84@']%w?_Ƴ@s4C"3BP #1VQQ1mڴ͛7Ou?n FߒL&:i&tuP(8X|o _qhZVAÑk:vCid qp 8=HINexWdYV~~ʕ+/_~Сh4#HGGG2曗/_QPPPQQ%b{衇^z\kN=и&< MlWjzy:¯W&46ȈCVc~պusilֶX,溮M_e2](ar}}0aB[[[4ue˖b M;y7޸o߾'xbܹh(//s]۶>h$s"N"ن1$UTQCdLj :pIoܱh 픐OH$a-KRpg?YD>600PWWGDW]uUQQc9tvvbg}'F\N=" RI1uj#'w"h6* IHJ݌ՑsA>CQN*i ޴ai1n!2 B۷o-Z9E =PXPSL=56ܳL3cf7TYYɐ~5©o Xp1_Ӝd~lk)@⣿'Nu]!ٳh4{9RŋO4ɲ@ 7ml.++;kL+55|̎UI̛n2Oڰi]"n:_i_W|A)3M۶{{{KJJMufJR|IӴ>u/nM0H TxK;1t؝n{߭SgȖ•+|_~m6m RǎջgϮ_K/\fMScs*zF8}0RT{{  WBnM[5}t.R󻻻 yyB2B=z_wɲ坝]RʼHq|۶Vȧݔjjo,OuZ<<wqǘ777'I&}}}555l6L< }^zΜ9 .bNёAni{zꌘ@ *B|X,v+&L`UUU).` ~{EE̸pCXϫM &0UUUUUUgˋE h4򊋋 ah^__occkyyy4 }|?p8\9qbGGG:B C@K7L6{%iB)ijVZZ,9۹x ~o4Q2:uyDbΝɓ'WWW><9͚9~(kN?;F@D%%%\rL u?ca0 CVtLwWG|x0cǏ/++aa  }I0GSL9qi/BEEE8nkk2e[ZZ10VXQRR8V)9CFR}4ngX~s ̚5 hGmn>"!mY]PPkKJKA2/q!`+R)۶m}ꩧH)ሔg}֧? >Hk֭[ynذa…xFH':C9G4fR8J$۶}{7-G8c^{+wSqrO=Ǖ?X[顱~Ecg>޽{9gk lذV,% ]SOo׫Ld2Ǐ+2&q+^}ՙ3甖F"QqRhtƌmWTT\:õv*--k/R?!׶9yX.e YM : ;HJX-|1 ͓QNF|5Eg44d>1v*kCCɓ'@]7_yv lljts'ic)ڶm7f%%%󶶶/|͈z10Lz}DH4 mV2L$3fx@dbXQQ444|.--M~&wms衑Yi!'[2t 3+!8d13 z,`Lf|//e>u/I`mmm۷)$Ǘj47?"O{|LRDOO/"!Y5V9sځ]]]gyfO9\czl&CC^wt&I ioiBJ9?*‘bqCM!=R1yjnH۩Eͣ&tu"RJJw1~LXȲ,L!"?[:=³ *4Gs|&7N;OǠVsWD$s6߸F8{X:z_o2dcg?Hq) wP 붅gӖ"삢sM2iHPJ #!b(\isI.&evƴ9-ǥxowKbI(Ȉ&070RBF5K,l& N3sih' R>hz!' =dz($nzoX+tfwD<skق]ٺcUWuCcѼd*A=󇇚'MWv'J&?hC 6oS:1Los٤ ]̯[u 77ݚ`X6[piᣩdxqqȾ>RY^.lhkkdbZt$\Uꫯ|9cWbmݺ妛nPQ==CCSϿypK5 #Wnv!!J XwwɓǏ-Cgθ䢥SL S7iӍ7\RZd׬1(((`HqIq T,ۿoUtٳ̞5{U'O4‚kV.)-umɒ%i(EojEix/hQ(?%%%lnsr#!'\=4 vV{h67B^LzgϜ6ŗ^>vC=p69r7}ĊO~Gڹcǁ:BΘz7psr y @)PBA۶C`2mfzʹS ̝y-}ꩧ1>יة5k>VwtժU-7S ׷mIgSTq먡zA ]*;̂bhYYvIK)W,Y$ ]"t&~NCCCe]{8csov<4l;kV&eדd&ZɌܽ{61AyKȈq Lh%rH&.qXߗvGCcS~~uM/TU -[~IYqQ{s7͋zۭH&≄Yˊ\[4 3q IDAT Ѹ8+1PҚQ=iӢPp=8*@ h43 bqI7͂lŚ{? 972`@D$H84FIO [ZRFJ[r=42%i$2@0Za@HSg,_bLB@:hH(H 8( F8j$I~9x?9_0 #.)B`HF㪧Lp4N\ Y>&Wܹr9%?hBTQ͏XA7dJ]  Rp"py1.c;2sƌ"hi.0I'[_0wշϞVqn=*-1i nruuYXN$#X[{K8X_;Q8LhѦeEPIgށL6/_O\}x|т-GNM<:`Ž@SQQZrww]炅Z7ѣG ;_Sfb˖-ӧO;wm6lhhh2Xt-i{6mڴh""zSx< )~UV~۷9<֗xS? i/B^y?/.Ƭ|H}HMv9mڴE!rSjNzlY{5LnʫV]Z׻ ׬y[n|'}c}g?ڛn{o~;-.*:MSeƚK&zKnOcg$螤c?ɺ>xGg[S[֟ćn~{Θ8~]}ϵʉ׮][ZXP[;wܹ=qa~Eu-˯ ^z%/\t/>sO|rܹw|͛69|1ϗZ&=Y^RTD"Jx> D‡~ǭu۶_|m۶qt*SZx4M𰏛vJhxx~Zg <5"rψ QϾwV^:c>fY {斦\~fϝAδD"{嗕榳`V,=qD}C#hoYYQyAY6eby$\40tbGcU׮]$uC hŜh4ګnX{ g"|./++[s5:ڻ]uѽ~zM1uɓ c۷vxU_~fWJ`"X(;d,i뺟Gq1FW?Ml#y0!"^D<0w j_mP[a~ɺu>|Xgg={9f*7ުٵGv57oZ?gf& v:y˗mm%;H ?Otr %vYS+['k?ػg@O/^k[Â@[~E}gYx{漪[z[vc'UW_tM'/_p.F$ğ\kzhFD},(yQP!!ܤ cGt#׷e({z #zt +/XϿp ] 1d[=Ա34by[⚆i༳jmouL:+q QG$|v:|7Ǿ* A#9܇sC^"R< gQtcEHYKOD096UN*?oa~A 86yg6"ZiZII c,ͺ#(%)&VMc̬( #(q4().b_:fsw&Ȅ!!iPcqvF/!ۏИhʍsWuP: hq/ZzM{^05WK,]T;w^X, =c7|s]]jYwoowyO?uBi Ͼj d΃V5}Ҿ'|ϭH~"7Gs#EԬ3Nu#kٛn}W,2#sCd XdO3P+eVڽɧ^bڵ7ϟؓ>Oʼn'ZP48<0=C䂚zgT4VvlKgK\ox뛏}6Qol$'Yի_F8ڞ\_{ź\3 *cFv+2kqKm߳m~ufή;tp@dᒋZ[x'_۰޶x<~'xbݺuͺЃTZ|5˯I2F2=kgNdqH 8J8 9 3`8ƹbMGp@FFA39\@@Ft. `P Afp pPc`p.8gi 3:@g` `aǓ}>u!C}GJ{JʡxMAɷ?"ȵ?@e>o1\m t߳ qv% K,ICCC'Oh<7 >ce& G2Skf^~yuwweG(F=툜QTRh,@χ& lH2x[ߋ#&=ȂX<:s]*6cT)\%O2)<{9J @!)@@bj|?G]u>~D nXvEn4Dk@rD2UOYslڐk?ϸSiQ*B+ucCC}Gd*!ƛK@WDvDܱc"J)g okm=ܬ rC8f@WJ$҃-[11z0(&rnџ߰ᅯdF}?jZН׾ֻF}=4R ,5[ ѩk&E~[o~勓.Xu_鍃\WgosJ?/+Z'gߥOSY{heT}pӿ , rwg뎷鍨 uF쬷+ھ9\(U]- sةh58l@~ߔ\FH'nS5=ykgK)@L;}?-;-E"B_җ9,xee%{ZX\5n]!' h``p)P8TUUEѲ2p|%nwL*4cm[Q]]L<Dkk{&DP~P׌ɓ';#_ ̓}-' JB͆SsdRǩdSIy~{ gCȸSbb~!O wA-YC'z~Jue?#y۾(}82To>qx_ט3'Ozq~?]4p.g0a򮮞'$9jc?Z)"f0c,I9z(޾ck4q};JKIfv&ꏙcVOk#R_8#aol[*I':2yV>v gd?{`Wuާ>sWI2 @1 1q\g'!sǕ7i$$,!HH:4M{83(엀< rܳZ[}ΉK~`](YYޮs|Sf(3+lnM󹀆р2kK2yG}jYEUEgrʊ ;bwrYuʢ)~mOyoV|r~~mۺ_Q|IUjf^zEmwuW("C IDAT={ysOGz),))+ƿѱeM1N_r y>zpMۺoml겲[~ںTػ}˱{"J vl.?^s*RQVzcH ojhxuKHTc[6o~~sEeins`} wt"Ѣ‚7o|G'tA@ӃmiluTSwg[kGoWw};?vc;tb_:GDδ34Ig۶L&v;N :Nl2 Lg#:$F,dҶmy;dRދcIDZMHgReP*4,`WotN6ɤPA3fڵܴ~+P(u\zҥKc(7nliiÄ{Qm'ݫpp.W@1[jTf?/񫢂ܼoo~V_oC?~)/ʶu'JgFdH"WoZtZթ=sps']wۧ{໾>ttgկ汧TM W~s9K<iӦE566>pqE?vկ~5 "bOOYW0pGh 0.n7i.I Hr@ԥ@Y/@@ęID r 8.*ҕRSu⊫J$`#Sa46fmΈ%X‘ 3lΜ ;nlݓ&9())RZo~{}ݷm6[! Cޖ*#=bեC@~$y μ垣39gt4nm &Ē!/ wlUdu5v U*-n1oT-֟2]WT:-!']>u @wgF#PwgүS~ Vc)溒nkF)4͗JF@r9"fe6HǶ]mkkcy>82af^]Fc"q @x< 8lC@ *H zn4Re(kT8G  y;"LqׯAql WCq\̦$cBhK>3M]x JГY nFU80G ն1v $N@H  ʘf@M亮 9W4ݙx@0/Zhxu #qē2Ǵnga8[gvNS7*4WɄo{ z˛ Dҹj2!84%]X.xX*|wH-i.Wl182B +i; rJ] XO8$A)(@B ,RTHT8GpȀTT#`La(<0r '2TEDqg(a+<dӫcbzCCIgGsT=RVel;r5SIC}U]抓Gj7weL(\U&%9s-A¥ee>[hqt3EYnqSsJF³ko6҃D8wXBcSȣdz穏ɘD7+H!* ?846|2ag?sgUj{Ԯ+[de͑}';=/'jfN,[xQm|;ym>~j`z%3;^=}PvUفڼ¹{_|1JU="@,`UiI׾_J .(}cߞO}=d2R׼i#o|ʛn=p꒩gLmΦb/ӱܤieI)~eYB{:庮aHd``v۶o喥K"b믿DLL&x1 KJJrrrl6M3 i)%\l2UU4ͫ<Tޜ S{,$D_[|st>8Q}A;07jR)Q9+O ˊޯ~ob;n}\SCΦ]T._sey̖]r-Yҟ+|ՕW54؎ugqlo<O7>z>zI+N?/tήޯ| ^xR+.)OͭZ5=Nb,[~sC /L#>Lq;35G[}i9ǯ= iFT,&3,Dm&g,1fB3iUUᄋ HRl0t:}78k׮ܾ!ẁp(L>| _²ekkkC"Z7}tD"vtt$ 4/k+ Byuvv984!!BH)lǼt }z_JN74 399cjAsSm;kmʸ֭ۮʮ#ֶD9dNپ[NR$Mq'} =ٹu+EE۶nSNC'O75=_?~5G׊d]џ~݂󻺺v<0'\[sO:MiH);{bC'D5vϛtg}X瘹q K+}D#BӒјA'h֎@G,G84Xi4GL#{8¾}},5x\,+/"tsMeU_E*UWa?wu{Gg1`@#IBcHY۞ ظ#FH"< 1b|_=cQYYѥ\J@"ϕ,Br]qPd:Ox:¸ W bg)MF.A c(.ƙ]QP󩪪)#aP]E3a{g9M\t#TU|cٳߖ#iζ yJQDҋ$$C#j{vEB(}_X`[NOw J+ɸJ(Ɂ3@8*+KVz@n4w/m9.I$FPJ> Y~M&-iM:UXmCAR==݊i^ZmjD|y9> DsuSQZhF"n2 rށ>iɲ.XFq~[D"???J566VWW{L4u]Wao֬YCCd">yKKẚ[m;gndS]ɮݻ\W|MzڴiǎZx3H)(R蔩3JKˋ>ψڒ-+}炓rV̺{Uxݻnysfw@(2;p׭Ƶ_lZĴSe|oܹ~G6n΋FY}똦 ]!2F֯j5#GEw\pq͡Ce ᤒimZuѓO=bɅ0n}u!9U󉙍 3#l6{6o~zoz)d0q ŋoH$3{$lr)e0B657Η^z0֮-,,׿8>gpsskH$L8;$ \pQմu59,8RYQ(~q˭;y('4u;v-wl6[^^}ǖYo|#GfTWΞ=ǟ?Lg߽nŊ]DFT*@&e' dFDڋ@xI#"K/~st߉H5&Rnذaͻv-ky|`0<|pKK˕W^h48N0'LfƌUUUT*77ԩS3f񉦦;w[z̽{hݮH?{pAmT5Zc9_|={ :;;;;;7gHt\- ߝI_cVYHd9UXZ:y-H^Ӎp<KUU3-\b9l7v,M*, Μ El6 sx@\žE\wHoyKȉFl۾hN.+QqC;OkΓC^}=#cg߹@eqYaA%jnj輽www }l$!'BBj "C\QںZT[=3n mu'[j+";1^hkQ1q)W7qθp/;DTxcC(]=?իi\QRdk۳-P_n0XyΘ('O֝8Q*" t=c|˛#ͪ #?}4?x@:clӦMk׮ݵkW"irJtP4=h:If'u=XSSL&#HOOO"P]x@1ɈH7WMFHVj6ZD"!*#@{UU#h}p8Lڐk @1M5 p9ʈ Tu42},/e+TU5H|K_ڹs빹P φaD<~+z``  & @44iҤ"jhh{WX xYY4iRCC4445k%\RVVu~Aӟ@헧bKI&Qg3T\;9#0!?y?}g#Lg?֑#O>$2Di1I zA%1.ݠ]&&ջ}3͚0dY{U )x:i=C\Dz5AOc r i'HIfL  2ap#1{{UG8_|LaL`D$a.h%88"Hxe< 01 :q/l-tÆ _򗽴 "N2%L:=p4M󦇢(~ӦMsiݻߺukEEEyyygg%%%mb˗_s5`Ћ !Z[[&yCBE'H#>N@QEp]&D $9ILq. u G$z]* d.p '2" `QÅQ_@@Rp]!)2R rBY31!?Z44ӸHB&mªeZi@9|1[R7K>Ƕs8[ib/ƋCW";vjz衇*((IΝ[^^99sf^^^:'Bai=== .R:SPPKJJ4Mػw׾I& b1)eyyʕ+ ͚5˶_W3gμ馛87߲ ÇCRJC9e܁ |(a1?1 DxdP Y na# bcS.x.9A9oxop B,ZtW&blfhgMWUc8J>X4$o;3EyXA}bsPY޿삋 цyӦZɼ`Ȑp+֯{.詭dl/>s !dhcF!xHqǺ˜{4<蜓waΩ9V9m#<X$NϞ={ʔ)hXqh4]]{{{ǏG" 0 h6۱cǝwY\\\TTnuu5XD"8?O5kӧ+++ꊊ oc/+'AFߟ%<`bǏb]vi`0N!4Mӣ9;.r`vQQQiӦ3gAmL}Le⚞ j[!6$37}龣g L \%~McHN?/ZFqԩ3fϚ}˯f㦍F&L[S_U&Oxɴ'xVѸQ3X̾VrT}Gg7Rvdn呶#M۲eKwOww?w2 =h^268kaC<#o꒗vN,豧v#;ښJX*rm?<䙏ϿdܹsOLژs/bxUN^isM }+s֖֖Ư|~iη^tJG@p!98x?yϷf R[u…^02u-*++bHhggg,9sKKK]׍F !TU}]wW\q &ND?!;cࣧ;'w9v[zW̹d/~pE]/]L唿⧟߰өĝwI]-Z}G\l"cZpSKwuuI&fM^#O0M B6 kƙ(\w$IMl!++~_?W՗6q\e8/0ٗ6>d4}/*Qџy~o=SAԄ{RhL¡˱ p> ;9ll'hnSNt wzYah4RW{iCB!16QK{-}˲֬YF- B^c1-AOG5k>8H(a̚54MQh4-]t֭-%ǔ<ŋiy&͝2 w7|c2 ,nq~8 'YTP5oHN;'LA.scZHtk=\zUkɉu‹̫x?9K#!N̯weսfu$qu]\d>MB}Jj >)[x@I\SU`@s#|i"'YEbΓ=nd/l\o #X çn/ս!,C)[HkPho$PD=e+ſ}@J{{'N(e"(&]`0 @`LFKYa=sBhH${zz` hhh?~OO]x9sfccc__]w9f$IO;'e,9]R`˧N$O2.CmipU%$Ԥ߯=CvElbjS@/{2>v_.bGpKKsgwkCCoo A׭Y̺͜YW[UIǒx]vE[{K8iCG2@AR 2ˤM@/xuuSSGK=ni2(>$@ L8t1H&-,,TY~W]\{+8D $q)n9,38\Ȭq3>H&Dp1l ! H!GA|Rчg7z9Iܠj䚜36*%99JɤTCQMɈHBP |>E'Q@'Ŕr82 FR" H"!og7<+T<OV4st_(Xʊ;fՏ1"'G긖+KΚZÜg-^z_,/I۲@ؗ_icL#X+V4$ C.% 0$`H'f2$ > 6G}=!BD@@lgG"5z AJ$1" 8·Pc_馛Ǚ9sf<7 c޼yX,JD"1{l˲uܹ //wܹSN CCCW8d2~_uQRI I5~[Jdࢢ ΈJ~T@$l'& @Q h@# #N.^qHVt UUNoTm߱մcNMV0yӍ7\gͯ4ttp·z^X۶_tET[[zU6+ FroD߷zqLH3`IFHLr$'#=K$1 *ŀTt6zy#{΂b$Hd$#8 B2D"F@NwK{A/5.---**4Kyreee/9| uhhȲ,XII?񞞞2o;(ѣGc999D"~UUɤa'NXbECC Ef!O4C_0\S t٨( "."0$\c(81P`rs:p@bD `LrF P+A$ tޯEk2pD$o۶q|٧t?!x ιG++\ +o5Gy2e&ذIwu-[2儱c0Bdm=N< L>}۶m[reKK?ٳ eٲ#Gf0c,F1\XOO뺋-e4%(ѰM ItI "p$' @vFƊ ;J<($ H"DG "@NcWq2k9>U4}?wp#kh.dLmNJN4EK#I"=%Ķu\ @EQtL$(Ɛ uA#.J\"`4*b$Bk]%($9p*c)'{?Gv7Fy*t(|hKy"qt]U`ҤI~?777 t:=88 Ǘ%=o<=&v(?>d} !V\q=F N=m9O2,k/MY/-MԠ8u'ۚs ~V§^yФ >?RIM*-NeS?яmwvOz4zrբo}>q8C4v4&3~D"0$^$ A *HNsDDi뺀HfRc=个sUfzr(8J,6Ne0 e  6f re,(hr3{5jًFtWWW:9σx5׼RJFS ٍ7ب؁c'N-Ljz;;xfL:a\5/!+(Iu K;;;ámX`oev]p]mm9H 7? 'MBGn}d\JQrf̘!.((̮x &ܹ+,+;;MeR{noԩn=17Ur}Rccy󲲲R4)6ypňե9s kmSy$/|߼]U-XU֚m?oָC}Sg\0͛7piN5ddggt{KSFFN\M#@X|}D<,**^pa(pD [nݘ2dzgp(̐BIgVq̙3,Xyih=R)uy555E"lT4 *h@#Gtwws׬Yo۵k}v> )vsٙ1H&Xޣ 2W^'Ƅ%yl7:˲pG_"ݽ'w[߾mJN>minq+SJ՟8#?޶mkZZ:<J1 յ\|[/?8g'{$!2~R?̷wjb6#"9nh:`d;"l $'IĄTBd\k$ZF qFU3b Hh1n V*;3K9rܹY9Ca41l Z=W(vrWkjh3g-ypO,ڶ NW/m۷8cˇ5 89IsݵkOyq醍:m-^ 6Hr][CC׿kZ W/%`sr) twwRxn ߲d29e]wJ#jkk[[[_|Eؗeq\W鵷p ===i3;]P:Ξ"2< 9A3B XF֝D,cl9coͭ-pS56!1Q,[f c]g Ohimu7(8\GncS?Et%38(" # GT#4b4d |F  Cacbn" Aj'%}zwýx7F` M~g0@iD=zx=toHc#^QGm27Lώ;!@O=[cr(Q=RCD#""plXHD)5uTwgæifdd)eyyyff1c'OF?k.&LrKyZW_}933>cǎя~ Ǎbs grUDf^0BE1 -'G*WJqˤv]ݪfJmN l~1 R}__\.TR "!*z "  42)lFIf2JjA&76pGD"irDJKKrsspC]w@(JOOw=h("m{IS5wjs8S٘5tk VF N5^_ݷYv8H x<9jexs /JahQd\ "k%vtL#9׀ El?&s,nC Ah$Ԑa{/W_9?y?/?_H*)-|;߾ݼ}{w^|Żv5\v٥RJ&w~Gyy^z幭[O}_/ܼc)\tQmms>~-r*!#b{暕 dd !3#KJKrr222=i>;"󋊊F#kr($z=[nc&gL:vLczޜYJg+C1H|ץ[dSO蕝rss9^}]\$aM4)'O>q{իWe]L&]Šf7R 5`|\zt!wYd)q!KTJTTTW.RJ¢"Wv 9c0QRiM#Ȳ,!TA~AZ߶mI+-˲ \)"JI)ȲҒR$iKK*++3q]H)ֺqMƏJ;\2yܲ)sss322qRi$G:RI*Bd2yБ~OBRAhhpw'O~nfeW]E8/ ֮]e!5,`iNNV^Nm%m;$6oݶ+?7O:N")meYI'//ođO׺qēO>y2D<`~~^__[>ys/[\~ᅓ'M6¸ g͙S>Uo3f}=ş+V\z}{j4Cm/(HKϚ='#+k99Yt'.YA |J_Zo2"l%l)~mKx2jZc;;zN\2g>xNaɓ'r2ii'"6ÿ|䉧 t}'wlOVpO[l`o/?i誻++D7L-J;xrhK{n_tںؾfʴɾ恘[њZV=H-9RᱸWǔٳK/KnW}`gݼm0iDb5_s帊.x}@VD"Ϟ7'?|K<ތo㷇uq{t/m_?\;,!nc=F@@%Kϗ ђ󫪞}$~Ikj;zH((+- O:i9ՆaD?2:Wa"Qhs8vtp(;ƕeTT:|x %ܻwռdd_WOhP!38*?}czY*es+"@`CCCn`侥0΅ fdd455!w߽|rW2e[nhhBٳg׮]Z+VhᎎJ4_b4\2-@)16=nǗ[-hUݱ}q㥓O`EYsmZi}_{cʹS|_{g'U-񚫾t❝7iW[ L{YXsZN7:^s@9ΉBGů|'+[tGj<_Xq7˷{&uݏ/3gڴw9_0wy vmM';ӧ}.ii%楗Oǎ֒#GĘw!kt uF gz'L-gFR,OzիmɑsPowwxx_?l^u퍱X~6{ RD"az}Ǐ}_G`5 ֬y4&14^o,sh4zͪk8 `xwcI%ܲeKF   Hq'n}{GI#`V^|@ HiƎ.^Ξ;r|Q#]]_~E{{{"8 Ä P c=@p[mD"V_X3i܄#~pxkvqcD D0i򜞎2Pf͚588hfiiʼ{<"9s :uիVZ6eݒ2nͲ,kݻcرc>4:!qaH`|/caUaںdVf`pphÆw޶i AFf'/8H&\G_Op?C?,\X@?Qk<00՝&X~}<PQa&)evV.6c#RsSX0=p8ąٙ q4͌@VwWt.L~'``aK:cCZR)x2rk6tz= 4m`D D\3TI ?$QcZB |”Rqq!D@jC #G ٖLh3I9^,B ۶=:>= LMM> h rƤ.}衇8Ri}Y ibǎB4Vk̔Č=;1<&( ._hVN"*r &@΍p8_ǥW]uՁ:dɒhٲeZ붶JT,Ǐ/**2sJMuSE~.k{P(S*<φu/ʡl3oH;V%~78Rԛ\\~[2iWe^eكަBCH8KOWdwC͌N Xοc~ o$|s` A?m]7kF:`w~'m믨P$7kTgv:?=sG/}≧2 2(c v)Jp}D0viڶ5B4dȕBF9»3 1";HW CjRsA '[ۨ3IJ$8"Kt&R~|8?Fc NjO1q9sl߾=ÇL8wZ688ܢ9saBanj\/8d=!ɗAtb\3lsKֶsdl=z@@d t $RwlLܴyb -ko *0\d&Cƹ`@ǎ"Sifum1/ܵkݻwh& gj<@3Z_ojehI޲e0pM.b:)?riweA4)M2> TG(53l`2,y=˧@8`u(SCk *j‚@R&I$1HkJLH)k)N ' Ƙ&,Ksaផ+DDxD+wahϭsQ!ιiyˊ"~`z\iK[f * 9#ĄՀBfB1e^&! bTG؋ɕu&? AH  '0%.DNLG <Ґ <mx@}PgH`04"㤘hS"zx0뢞9b.3a@@QfƘg M@[\I"x[X$L&cD 're4B@)ߩFt) |cTJfddrG9r@@\Jyիֽ)>ih8Λ2Jɑܼ<ɁȢ+*M&^zue jjjLt'Nrrr%K5<&ڷ&;`95M3.yu=v@jyk:z{N_k:x>}ʨlN՗IƐ ٭>%;ƝH$\ }NN=`۶rܹP%%%Xlܸq'޵%Ga)*+@ye9V\15v 7Qu':T>kmm7n2###G"wͧK9T]/թXqc^rk"~b4I&aJlB2 VZ-H$I %""` Ў[iQH IPLhmHҠ% !(Ni$ A31Is-5Z@&MZj)"#D s IDAT)Z(kDԄ\6Q*B0yJ  %h%CV~?WΰtQ ~J9C+J0.)-TL!FF9 50 Jܟ GcS&MN$}L@  #SON6o隬ۉڗw\r@o ?/ ˵D$u􀿭+ׯ{O֟pŊu[7mD.]zȑܖڃ{vq`k{_΂W-/<|!0785F̌}G&/\k! ZjoV0!MDR Mb>l= B.y8,O_ff&-$@x'Z[[ۻ7*+ן27l>`'FXqǏX,6s^{mJܜǧʊF}--mmX1/ZLL)+k l~=($$"*hMLѯVUUUsss[[[J(''ضT4]<2:Aeϭ?C].1EywJ$so΢<9u٬bѿuLߊ9ve1yW}_ǐ{9imݝK.hn9zH&n }/}鋯'NsdN֘憦7}bAŒK=h{wddW?=]s/=1Y3{:::O>k,!czڴGgzꩧ,#g\la T' iHi3H 8S]]N&#Fgo˶톆֓mJuhk[H4JZ 'JǏk׮L߳78΋/.-xa~C"&ۤ1b',,FZ+i%|qm[mHRB4p3#IX3nBm;vO4Mgo|ꒋoa#v~'_d2H$,YbŲ[neϞ=]]]^w9F04iR3az W@kmaXl0tq;KkР},gH)T17biNI#`0Lb" FJV¥u;RIcDDDU$84"#d2!Qc&!sDMP3z M~D$IKKrʇzh֬Y?W_}(**۷o_`c,--glRgIN|~bf_ȉ{޻çoZ20p艌̐a Wj?Q{BgJȑJ ܼy=O?_UU ǎƂ057WwbʏFM‚]w)c?wosgK04uΠ3s,hjjk׮-**w|i+т. A#(ݹH2ʊ'#&XW; St FDDZ8@q 27)fsix1EQd"`L )d+Sf q5LVR2ƴm R)WTH /FrטH_w}ٳg_{^^^^UU1ƲlTZZVNq~,_ WI0P CbǍN(D@YI0lI磱d߫khkj^[Y\P3 hpWoWTwvF@(p(:Fж?vUOdӦgOWcP8^VZŒd<mkk/,,TJO:Lg"FH "F @ 1˂q&A+))imm#T\hcǏ& 9^XVBk5M#LoSZѓLZII@R9!Bw: ` H SH ;rtVvH7}GGb^WnU0Y\# ? hݙSߟ^[[;<Ʌ8AzX tOa߂K"@B꛺Ȉ;~ QGbE@h8$38 粥p8Z0u!0d ?>O f'#j$_QQ0;v #566 2|(uCCĕ;mFFPh2K",,HtӍy>lNvfgg@͞[7T-^*-mO\v׶.^:ugt]f׿]uJJsf۳wO2ie' t}Ц XE42&̘ DդӤ+kV_ޮ _ZvjE[hak{puŎ=O>[n[v/rʌ [ZZGpNoSs:bcۚtŇY84؞8Gmfr,P&sj^$hۍF䫓@|T0@)P@DdǬXf@$G+HfF9J QhN 4ӧ44 t-[~豋V,Kp D6gq|w]s˦>5ec=sV$/|_?p]_v%3b΂CC555|dD?8w|@#0q H8?P1n`[+/PujyXqK}Zw!R L$}}}J8޹sg~~/=UUU_WƏ iiέ3),JIĘf{"t =e@@S :? PpasVQjI[JAdw!O)Itʬ|^ID.}tztk]=ڵ>mjYYYZzڈxyYs&޳m`o`1NEt *02Ã7tupPq9%vH4ktv(G1ԄxFÏ1iiiqtch4//^?.q>u9Fn6i2}j  nutpŅc؏GҨ$]uk82A\rɎ;f͚md$,7rSL MMMa(M8NΚu@1čΟU];ug_o(?v,ꗧZJ.55:*ߕNOgƙCD+pM)j} #b=ݽ]p$đL455"# 644144L*z~dJ#2B :V 9="6!rPg K"ר̈́b4&G}oooRΟ?{ŊBs2.M6mڴ[nq!s4޳wFw!wr_]F1&W^O^._qaIqYgGW_7wt`7펛áG R믿v%k&`߷}{+WܼysNnVquu'ҙ.{Ivl\QVH lٹ?rrmi_`bhܺo})r: Լz* Jkq=~\W:-QI)PtԵJ?R86F4hBk&\zS\M! DURשJ@{(P#S9H2BP&@)A@d4B `Gmyȑe˖p SN{o&׻rʯ|+7o^jջй9?~O689 ']l޼q=j-*gؾwiS~#|7~SJ@ o}W?-_7 {w```hx`ҤO;yy\q~O"jkDв@yYټlZ<{يX~n!hpvu}W?}W:6i\7#Y|e%6PMј7\{ kֽcs^~ ,6m#G⾾ލ7JGbG#<'3I s p&OZ&eD4cx Cjg{ydxVRk 5)SIe.d7)`G ܜDC=͋/d̎m[<.;&U=xI+/ݹ:[ Y^FEP!.3g7-+LJ=v${8'33i|=gGlX{wjuKOGB}EVwsvH9̉NS1z{{233w9{v"7xbWt'X`Ayyu?t.:?q{x8K Uk[O~c#!DFcsn[߾~88-ޖFsJKKhuxVfQ/$@f%Bc3ƤJv2/<`n?84| O,~Je e犂H<ZlܰЦ _z0"i?w6&z'Ob&ddg5innI&n#U+p9xnNNk7 $Z7iR$[PXX7yE4wtM2())"̙oL:5>aܸNj',\PL:wugcS}VVֺ) G@D\x<~es&l'ڱܜiW]Q9T.7- j$_c].a8o>׻nݺ獍Vjoo;vlQQѓO>`=c[F~-9"App(?KȤMXJX4f۱R:(h"}=m[Igx(#5k;v(aPSOtvtttdhiNvF쭶HCCk^~qqiΝ7{q_ݰ.wX'q?{oUu>|ST1Ϡ 3Ȥ cmM41$~:ر;O:}77wΨ8h;2 3PU5U_ x{ǩ*1"g^k}?LUUUDǛ+**N:5zG=P2ܹsgwww<ohhbj"ϝў5B!۶FF2&a!SX$$2ظP`@[J1(~GF &U' [²Pۀ0ʀ 3;0*c!|@`( 'ff#=sVH^gB'm[@ì?`Q?A0\sHT )}"-Ami}K_z/.OJhƆ .~W[K8O?s+r7G^}72tKcm/Uv(v V\yZPS0 $Yf3fl_jՕW涆h>jr㻶.Aj&0c.w^efH&qgz@НmooWJi!TOOrŊW_}oǏ7Yrc=YZZDX][8pCei!cnf4gJ|2`{ȩP H@`VȤR"☔@mK *A)xfK6@ɠ F Cjͱ ! - hf˒,Br-ڰ@фd$܆i!O{G}4l1`O2mPvsXEd-JϿ\PhMd ʏܻ%d[mƒC )#tfR y>mpFg-BڵKJ㿗 GMM Ƹ|ʰQ(D0vxϳ54XPP0bĈs 𣪪 kkkΝŚ{E-[_o7/kg͚5E9ܧ.\LQ IDAT'SHxBy繞d`LƨڰVCqip=P |(c07@ f% w6A2 m@) fRR'{`)яg.i "6aLؠ  TAY Ӑ.y6dq]7~>; !"R,/OkDhffӎ)aϑ!mG,+LeA(d10Ii4 $KZL`RH@RF81j퐽%n68-%DP@ Idk$#L+e씎(@ &>xcLiiiUUU___pK3L{ |WdMMM~}}}CCCYYY{{ww+++kkkRNk=zOT?ri!'JahoRl| imW\qo>G :) Dq0!O;Stg[xIc4g i>OT0(\5¡/+,dK)+oƏ+epH*#]0@f`-|߬^& )4~Ѡ6H,4&Y!QX* Q Carαyۈ#n3fc4 D ` QQL (bclm ( O+AA@ IhҾ <d` }0 cȂ[(je0dp6p}_P2Ƴ W} jq{g`(-n޼D"Q^^ǕRonn~'9NǎCĚk_vmCC]\SSS__7:t!(/, z\vm?NtqUw{a3m@)>c I Gf2p6mIDG6cNJN2l-d>?ȦFDΙ3?²/♥+$͛7Ӯ _뮾Į{zc% _EPϲҥƛeCΪ˗-D'OSQ!發X^"Bj6oLoE$em>vB!YN5KkA%YvT޻KSo +"F`D%ӃN62W6lƦ@߫rĈd2M4W_Fmmmfַ5lذ۷[URRz-[\{h/Wx!W4@OcƏ6˄%!j+}~CS=u6mަ4jOV^;HO(2dV-_蚴3{k>ޑ㊗.`ˡIc{jswΜ2tl׮X4tv5T7hM˖-8/}}<Te\l=/[zƒH vxcݺuMmEEcǍ]n݁CZ 8МcC"/M[O8T(NV5̟qauOXxycLጒtq%u3(ougϾ;}3zŊ+byS M2HF ^~W/d\iS O`[o}ٲeV,?rowS]2qh-[_hޕOȾ֛Ϋ[ϸk׿8>ZWMYQwe3hW;RyͺJb)Rok[m&d{hVYuSʶ-OMQݴ T!Q3=Z`纞yg\~럃7V\RR4™|ࡦلzh\O5~ӦMRg^ܺmg֬vS&ֆ @f[l%7nxKܿl͛ qN_qe۟m Ux߁B姎+TɉFU}p&/=d(7.$U=5o9uʅ#̙={}ڢG^y;ϱ^۹؎Ql@f=zD͛sΦp8|ĉo}[---?,XL&evwwG憆fq^}/| ߰aC&Y`o|;[n;}ȑ-yB҂L"ց(~u^'>Ptޏ-RVVf۶gVVzX;:PcC>H++EDBҶrAپӨQ]4ka-H;WR#+ϖ1`m@ r +(쫬`RZ7}_GlƵ-PHj̚=%aY} 0fǟޭ3g1cΝw_P:0%rBaW>i4ƸDCQEHS<4aC0h}VXXx677755 R^{k60h4Φ"`6$LHH=T0 fl;/Q2\d<y Bv3P!PSEf΋8CfF!q',>K* U F< .'Z| ˫:_ܐR*w}'O.++s]WU6ogy}%\R\\L&CЬY;6r]7vm7İaîL&s}ƍ={۷m\RRRPXdEyP0/袢?{7$ٶ}o֭)E4~D"QUU\}ߤ¨Ix.QJ*II3Kf(AFG22Pa5SDPWd6RXHF+I3dcٌ" & H)ÖD&}>;1xF 03TGz@Ffll pѶ biͨkY >\ϡ*ٽ{w0t rV+mF[R ǔ6AHdhIcd"ahMHZDZrm}AoT(6Fz9:02$" ;TH !J[#&t(lP0J'd\#rIk2??ժUcǎ ⊊nX,vڡ~ʔ)ZkcpSJJx7 ,={faaa__o-;k.]vIoO|ɏ>H8\PPAZ?T;OD(@<V%fm>m>4Y6!d1da"Dc<ۚ\ߗ&0K "hcPVqXY`d&8ѐow0} x~Nǹ 2f`Q$ Gs1tkHp ,+@k@mB6H B3hI`)-ÚH@=08K A .*Ema% 00 fZkA9C?# DUJBHVLYk0 69_P #`8qԐ}h`m; f!Oi.Y !X +#wq5hH+ ZP ;.ҡS+ܚߟ`  bID|BРa`@.L"G24 [P*0Ðq`_0  ZÐBT$ƨO9\V']m;//oڵ\xK,Ѿrlm퟿eMrQF_z)۷+<^)4&k gԸ +W\^[Y` 3orBicxU D(ca?>їLdFG c'kZݭ߶jʬ7h $Cz?NGZhܸq ?\~yB껿a5UR taa!k}ۛjWojh޿|<ڵUՇl.[5h_z./쬹H4us3b,;ּ~˛W.^R?i꼽G\["w|K{:YJZHvz Ϥ@b'tY_5;6d}:sHOҬWi 9onM?nT$%s{'~"~WUwq/yۭ.e#,~QhW^sºc%,F;oe[_?PvdM+vu͛xΔ⪪.?ۻdCUsˏWF,Ǎ>gPFr>d)))Vs~u]ݗ7 zKHCJC͟?+_K/t$/%SIK$b55.xQMMؒjmǎ+- ,\\Igx'M(+BxxDdsɋC!LVUUy{u9t` &;*K~>Ιyg24jD8N蓄CgEJ/s66vEь𲈄!mA{rHoKǎ?ܺ˗/;RtggW[K Då%#&]t^l\?[pѥMu];x3O_~2sF zժեK:șLZkm Ԛ?Ҕ(l6LJ~3s(m$dRI6⃎mY]JI"iwdݔ~MuMoOϖ-[ 򋫪0Qvbi3gO6;;;3lPyψ &@Ϙަ@3gرƎE^ ٶ|k@E&&#9}T,8tj>i8LO$}dUq3>mnE[ZR=?QY=|xmo'N`ш|Xe"د*atW7~71qH>zxm];-=m!7HBDog{‘[o\U^; min2mۿ/ޙ3gFGyCnۺxJqc@ i't L)S[[N¡w9dv,C L7aBd0?dtz&!b#*c&c\7:rԀ~4^(!>@u_|1#@8؟&t~V0ҙr7C/[8OQa8OLcOd&ٶE@&!mݎȦ"+NQ{1%%O?B{o8݁ gLCa4f)`r`f!`z@~=z{ɓjkk^C`- о-Og5PRR`:M- Hu}$)B)YfuVڑ"` J$N$)3i A xp Z{1`AZ{dtx&d,JZh c1h1[^%_n4Ѐ=iXB&|€@0"@v` T 7zz{+F.(5 zi:4F@3p9с`볛:l}!ZFD$L}$R4Z[J{ʕRTJ!M@ $kM|1ad&9Rk) k&f24B(f3gdi9gZTE/0lX>N)p]\%H(_w~;ȇt}'CۯYxIy9ϟ??Hu]7ݴζn_MHګV1BP>j|ֿZ33f,?/>~aS&O^j ĉG5o޼ѣG/]zɘni XHUm⤉FZbq>F nm֎E3fW}3.XT"<2hA`욋\{E\ji Q*VUa +R6R:F1`PdfѶmu4rK.:FχIf3?O6;iKHo޼iշ?l&۟'N}CBl6;u_mя~M6ErJS$06AS$*O|Rs##O IDAT߿ZRi_o"$HʏUZu͛>xװs5ȑ#ÇZ_tE/ʚ5k\V  #k'C=hv2:yF 458rXf?UG`TI2BRn" ܟXugdW^{aɵі00.X߾n[/^PX~iii,3ÑyK/Zr +QJ1Ñ0@h~7 J^{uvN<^#ϭv]4\L`۶ANtO-f<5v^0ڼysCC7 Q1rjjBN`: ";wzE"#G2tCCC_#Q3Gy/\[w_mذ!h=7~ܘ8:rĘt*J%òĆ Fyԩ}Xqy]]ݎ۷os2l0v(#V( K{鏝KۗmE+OB] i|J\||lO kϩYEA]&Qv]{%&^bW?o?OA歽^}7~Mis.[^|酥,w 1ˉ>D aDȼpK{v-|w3cGslٚHƛ[OE"!3 `ݙoɮh?DD}}}#Ffκعc nټ5џ C%1o[l;\>lXaW[6H'm X___ )D;2~v[[K7t9ٻ,V Fvc]-lGJ M/bw<^WWa@OaDk}{1N>#祒7X/ٺm}8HIA>`8鴿{!۶_w} O>CU BFG`@  3{:x{,U'N""62 @LNTwJh&][FΈ@___6;\9ġȀlN=VsS\q\:\u8  e H-401(@8g`8rk3aC+ؼy}]KCw~GV߰! c㗿U-i7w[njRUSSwgCѲ*$JyHH{*ÆE&qX00@ 9pD`CC2#p{gRVTT|ߝ>}zccc]]_$ c" 3kDbD;=HCLF2*"lsjlghLq€`d0JIsbL`DFfq?s\bEKlJ2T*$&!dE_{5ep,hl}U~;a(@+pǮ(\2tmۆ l1Z+$JyƠY !B3h_4jb0Gxo5hoo3è#d(xFLf|_{^@ @JX} GSY\nPhgg(t}stl++",6vxX[gkp1bv}m.080NˈrCDPoSQ2 &g%k6D @f%fО@I|!ԾĈX֠ `07P.N/qpf=(BHK+"G(Ȥg%'N={=ܳy 0 ,}~]Τ ːod:U?wgy=77o7[o[\YwkDGwJ.]֝;LǏ?O@@ɀ͊m]xZݼ`Ai[GO GRz{ҋ/9xP֖ٳ7cBb1d ]sQî@;֍56|kWLe8u]_Z׽Ґ\{q_yV~MmI\>ԌXq6U\X8s7d- Xw2h3"#0=h`p{ǎp&OP6779ߗɤ쐭xт&?ze˗'^/z瑩 OgﭬJpͷsw[ɤUWνw%}?( QV%ӠN7n|15j#HpؒuսjO&zeuUEC>UPGwvǛV^q,m7@L<¶z{YhQ^^3|ز@0ǩmD.Ğ!_BZ8!Ei/+ 0F4'7\|pB lohK;VxnDoYɝ1bFk GIG"4rD"!3JK/]>$Bbgv͘mmP]e{!@Æz qPtV-M= xw[NƖ:@_~G2\gLx ̱ݻ=>HDimM@+b!+KHbb6#HcL30kDJ}vk+bddȀf]c[1BҠ5JPP6EF~[=XP25bdb49$E.>h>DmV]_{ W_hC*)5:|<5x|ٮqd2>[v@64#w‘k\i)#YsLzQ{g[^_v%ژ򃛿 _B .Ѿ\"C/Wz {8)G$x `\j}{Y%2q&)BFkkڷ$fi`r_ Jh36h1@ʀ4 ,5 %?~b raqPt`R' f KAs]\w±c*/[qO2zduLv-%WBWYYٹlٲѣG=}}OCxF@́/xZ^tܰdgx{ʔItDEûT +* p4gO.pH AB@d  ~Y25B`Bx DMF@c |d$9]OHQ hVe{+ l$cPylBȒd3BZ2`HU2lHhDQhb_ҒpO0ح[`AMMMKKY<!^[CMZbZNh+Ki떭n|⹯~vJ&SNuaذ@ Lb@%&HSHB2d߰yXԇj_|eV~^D"3AQ.rpvq2gy& [}53B2f!S $ b4q#$& D YR@; [R,;<耧 HoaHd\qBYTeYJՍjL>Pw At7_/ܽy3uomZA(p!Ӷeֆ4,p-2`\;v/~h4;vMq0E\;>s^BF\wʔ)dez꩙3ٳ'o޼yر:i؉LZI\#zs* lz駐y""!>"-$4h9~8O?4%$@,aC?ݺY@dAJ=lۢ]A{ζ9zX甴SvXeZ#FL9B.r>qP&>;q)j1?{09L{oUu &# A0HZ^e{^Yzؒe"eK(A 0<3s3 Yk_{֊9U{3 :ӫ5]<* j%w00JDy}ĺ *K+'T UU'Ψ*$| LkF' Ka{ȱɳ'VvuvE*L&3=}ݖ4gښ'[~](b%K&SZ;V =lGP3uh8= M5ٵjJt,3@pxpp>O8-n0cl˲-iVRZk!@Bض-Fۖm !p8}`X`!AAؓz LHF%GHhmX#JB6Cgp&_>Pu-dWO-ۮ?sjS>a0\l&38@&ܚvJRgga]cj/&lkXHGk ~q׎g R2<0k40dV,:rWM|ߟ?o^ScC64qb_tfqC^6GJ|ي g9!oذa„/b,khhJMh :;;Q픃, pGRX!nC_-oY}/'}x̺9 ?6/k$ʳQy@BD~U gkݞh4jtu=b,k~V؃46v6̞5oR]m| 1Xaq 3gΦ3|yoq̙s&NԘo*`&fc;B t&ȸ~9-H'dQiTh8fZ{ H0T\&vɤB*)MBP ^xW)I1D(WRMP(˗_g<7 %eKW]kkj|C~9gΙ3gr|=7o'|~-[nݺ_ [rA@,P!Ws۶W(tԊ33[[jy|[p F $G0 /%BzG - C~ _ҟϜ9ɓDyq&iؐl-u"N2uܹs/J--mmeNl샍`55|&3 ƐG(D# d2LZ[$E0!`P MR2 f@d9]K$8x!D*:r5^l$c1MVHjjl8i^˥Ƈ>~~-9{y=w\(YD[0u?7v;ƋM#G"5Ps-ˉ:!GL2іvL*+ˋ,\~qB<-Z IDATz.Oٲ(m}k>#wcf) wTg$ |q `q 9ǎ O<J)= p%2;sD$V] *afEA @FV ¶AZF B* p#їH&S#K,RWO>wiU1g93Z%*wyJMDPH k;!1Ue,YF1dthT u3Ƙ`x(:xO3j ;W*R,@Ä!TF3B9(6Rj%00 ƢxOU!6'?wK繿կ<7 Ϟ=kY> #s?9W/3y].1!(;_ÿ~rP~ nٺ36QJ&`"#Hv;4> L] f|@G|'2d-a,+G%&:W㕷7C dDbLe?wl}wە@Զ_}/u74yw`IkN_~Jm;{wm8{==0o|d 2Bj)E6"$"`Zd $_Z!AaDa;vLZXQK,"0\[ eTk 5 |Qa dl$Ұ@"'<^3} w8hHD(0WxJ@?wrQVxK=g)(Oܲf9L=t}ݒ}ŞtI>oS@ws%KKKJ,D9s `2YyֆF f  r߾; )'dNey kjjB!kC~Zy^ȬY۽ju.5嵀6n8vu֭m+2zi?];JJ55ew8048og~,Cq.6HauÇ3Y;w F_زhRPi~ӧ_p!x C_/9|0J!˗5wΞ9Pzǝ{Yxaq(aM}}í}7z5͗Z 7;88޸vӤکͭ]6=i$No޼>)Tm7[|IkQ0"%XyK)XfMh/kNdS&t<(X*_ KŻ_}?Jdd|#YUV͚5kzH)oԔ-mN7 =ީ/8@q2"+=.K,ڳ9kf-ƛ҉-^9l{{{Ϳ9j[gƒ dL8 Ҳ ۖƢ,3}fϝ|)IMeEu=XAz A~3V8E#wCL2~/i'_=- qA~ߺ,i;B>}h|ٴP}#؎<]Lh)//ѺX@6o{ow_yA }{^6ڍ?F>r.g::Z^wqhbEe{GWCs֚O?^SWEF=q&4gzرwogϞt]PoXY{d@Ggc=*)FPęFB[bOwH!Lnd>ǻ@oHkK2PK#E|S,9aL{w@V0j`yBB@CC6A*0D8Bl'oc*m9ax\T?ovHkmi 8!P`? DKK2IG嶶6#gojB@v=`zBv.%ydK[w bHxK UU CCCPBc7iE,-B>`F #LR!>YR S qD`)6ArP` sHBI@ \`~u(<]^β,˲j`(Ba%yV2B̮ml`IB!{D ) ]R@ t".g(Y/0&xWsX"\4WWc 8Xy3@rpkFAfQd0uIGFdl)1i&[HCclX9w#(Rb&ј3Tt* 3P0YGh7-"8ODyr Tb?<^ָ&c"CD Th4K  GF c<bd&-G60ƸH!HygF+] 4RAj H!cn0gAoc']"ɘk!.`.$\o*2 06JĈF0 /X!)H$$@FdD&FbDb3"#0gM4!@#%c+2VQ FA  A"& 2" "a`b4$DCCFJ8U[kKw+BaWe2Mk&9e[N6p.)"ix]-ahH#Chƛol:}i ˮ[5.\}]qTZJA"{#p^8!&*`R0Bf2xpŤȃ/0~f&(v[[l#|ͳz1]kwc7xgO ݮ"rr Dޫ]-+-C8'uR|)JVn>#XvOEqFwow&:niO=LY~ЎI55nu鲕`{˾g_B1}ꪕKwugG{{ssso42#2[l-Pfl8﩮)50DZ)iHn,K R RBd qLRY 0[V2CX`4חA@#Ik|xyɻJ u:[EB`ص|m{ؼ[:^Ӣ֮2pDkSCO+[VXXO(qn'd%MuUǎI$36lwΘVz/*(o>1g %wݷ*(;|]',eյ$M\qWscPiyab Ltd \Ea:;OS!#܌=yi2sO>J{ƶG+5аԌ3JJڊ 5\ll{lK{%Z?#!p8C^^@\3sus<2#ݹ(:lhzs,z-42_#`1<~yv?G%l &N`@-#%ɞT {J: Te;PB6@#I C/A+`d],3N2q F4 0,cbPo?D`1 ۹pR'T|rB[Oǐכvt u_Zfu|p޹{K?̭immJ&X[WWg:ms;xko[+}C,Ҟꆋ ĊϞnkrYYYOg학sjN:"zF$=/.-X2+m YF` A4T##$H"38FtHe"t,0 .!d .P@fA$s|$%gtAy^3k?vNZlpf僛t^!|  dHjfA P`0Q FW%@ #1 !0 y^Θԑ- .\|Eh05|\}{goR"~䑟mAp{$)Xly~ mg;:8"B]'Omy 8zR*{ e#(K&2@6cpxQ]Fzز,CAms3E0Pz~(q0$ P\rl?B( σ9Q=!"ZK +nCfN!YQiIF 8 `%c!@A3F.@0Ҙ`LZ9e\e@0aP GJ޿z_7FRBƘ9s?m;Ҳ+)|"(2k6jzKTs{Y{K&ҽaI$R21s H g!X $XTtʲ;w(/w.*)I+LV }S=}}곩x6J86zpCÅ66ܷoﭷQR\N{{vo/_{w 뺕+ڒ7߸MXqu,^&]X8.MV2t/Ԃ˗,^: ? Ps.]رC[fϚą'V_V)0Jzqkl1}&;8pe3f͗R.\Uy {:opzǎ yC#`~Q#_V'S s]mOM=c^aVTϊPɂ-l8O}ˊpk,RqĹ7,piYНwmYdɥ6 O2q}榎)[}t~ O{u(;ČUw;*++v3n\Pt\i_oogGǢFֆH҉ B.z9MxA33ʹ:j*488nMN=*)tv3O IDATL)<,'O?4j;>'k*+ ±h0Epl\iDH$Ǣ*.,)*-*!8 VtwZP>~ O=p{sܴO?3Χ+- E#H!E K PuV"+~.˛v"J$> X($#aPm kLQF F(J*kF%{3BJ%c̨j5F[d$%#<>X'4GZ_@7OUձ?LMM3Kƕ:t'~1c<2qcS-}_{ gSX6gJե|3Κϛz-OYJ}Gꮘp-Z:қ6?|ر҉T/shC*8ffDeنcI̞$)(Om'A)̂ڀcke@ؿɏ7֏.&?y1Ehn1?ZLA'm'!͖Ξ= ZhU65x㊂HLyUk Ǖ-[|x(]=j]DG%p`mٹg!01"g~L>-6mP[UpΜ}mmm}V}Aneeeg̘6iG0L\[Q>nڬyuuSCoru9[W^tG>}ϽϜ?vٳHXXCwܹژo۴~mH1[ rs zPy׍t,uHg[j裏ޚm۶e}[_xy)c.x~s-:B%mCGSE}f$[ۆ 3xM}תǎLJ+*jv.^phuSO?DRL"Ɵ}fW>s/pήG~H:|FGߜhq'֕7l?{? Ox2}ձMVڎSO;ݝmO1vz Fv۲Mb+{_Rj;v`?Wc?&1e c 7x%PJL̶mK<֦gy=u&FC~c OhaG_O˅i$Vh[x=}=&>r +?_?|1_6GyVyrag-e2>j])ddF d"}Q`+ d&m+O XJ)6a&MT 3>Y &#0hQR2(юB@h00cy$󟗔1ƘE"۷{В?N:w+ECg+1!a; wzx9ST`yydqa>:v,vvyGafk)¾+'tO>"sZP>PFJY.<߀ Dz`c@yv Yl]% ,KR0B!cՖF|HYaZzhH(%AI!RAl"0- b[MG\5c]vicR?~IIqWW6~k[1ՓfJ -Aݢ! XW} EQ-ˇ҂WvIт&ө;0_#tut}Ǖե}`BńO:<.fg{v{NM Qy};:X7o-񬕷]7gw[)/~{V4V?Ξs3bŞ8~SoeMyyUuUͥKա/ph RSSO ,Ç<G>/6AOn.pA1]]][dPaa)3z, m+ZֻԾ/r]_U'NF Ϝ7}?y Q7iJYVk4vHg֋MS&V}pmK|ҊrO<̣qɹs͘1=.))⒢iSUĊc;.]jJ$w3!"3A."t:mYVヌ_;wnSSJ[\M G| SN<uLp'NgΟion>{@`CKޗO'Q'|d2<~옛X-#_>{sҔ'~-]]RM'ϟO$R)*uL`R`㙃GN7T=sb`|斾K#G_uݦ? K;:[/|9r4?vjmK{=-jkkB◿K)8SDDTUUqƱ[nm?˝I)|ChWZ5k֬;PJ}%guaayo'6|d׮]R-(ZfN[ac| n"h>L=D_YiISSmo^}ٴYk`x3K_e !Ѩk.(< nQQ y kC<6ܓ#PY"TYkȑkW\6,_0w)m-]6drEsw9?w۶̓Ņ^ygfɂE`۵zucps?&k׮ݳgi9"%?g ,>NHIE3CJqK"84X M/tȴH rAML b,:LV8\!'3$3B1`afD|Ee,E#O5b8~i@3s&@d3ekgI)ϟoXCI}h;Kzɐgzw6=ِRc/01r!h.0{okUgRIE`=_(-++SJeهzHc,c3!If+TdP  f@@@Rd8a̯H0B" Je>"TMPBP780LRD*BP& \ JJIB &! p3 *FCpUҊ! "@D)D\C~g'Ƴd0>إFyf[?ncT^FxM˸ċDdx"/\;d+ }}}apsR)I)٬Y۶ӑȋ >9̀4 J8`VIdO+fcqV._|Zxuu qō+c%jcD9h&C`:ԙ*\ g_ʐ/pJ HxD?S2m 9bru7=M]}70ΑdD3f'tH8"HL C28"Șw Icc gKd!=AFO 1PȀXQ[Ǽ+ 1$1q4qcTcv["|.) R87?ͿD蟼"G3@uݛ>?o^-(D|j_0͠\ۺ{:_S&RrƂgz<U`^NMN^Wvߞwwn~k{=@!(O7ќ5'eLfLs2" :d~? 23B!eYtF?f !XNR9 ")rܹs++\XOg~YEYm k;iCr W+6H&'@)r׷:ʏie(!}6O3NipB@1ƙ$F$@!0+(e#f%7qnd$\,5ׯ;=M7_+./=HC]9~/ojg#IwoX}۵zn灿c9ӫfG~O_?Wͩ:u/ ]zꛞ.^܏9KW^sB7vozckcU_qa5+\yu&7Ke3$O],7w%Îڶ}q7oƙ3gәH$}Hl޼y( èoXd̺RP)7S-m.yUPL&uۜ]|0U-[juk󟹑y!ܥҊ=H_o:$ꫯ2 $񦖣C=C7_{)'>oxD,3핗vԶ G#oBC}K&8S}V,ן,/s++8[?im-сoV>7g̘RZ8G_z;=lLE3g`[{[&BuөeYG;tuuɬ08:YykkkqqeU@ ۶URReee@ t4u;a#"5UϣnO p*JL[HR-Z|c΍/7CXӫ֭kٲf̜w~憩U%ӪcM&ͽSB?c;'Ny~kG_6^g;G=wGۇp?,6@ ) "$%C}~M+Ѐ?g~̥K{q [m޸+8Cpa2(r:_o!TP؅C/\Ӯ*Μ=wݾՍHv3|_]Ïe\\?keDtSB;_O&*qgiSJrss9vii3}+M)g,/+EÍ%e xN(0udL&Rʝ}R`0HeD:'FwU 8A"k/\<[ T*ǴJNKvh0sM;s$odqƈ ǝ;[׬䒋ݻk׮_bEE3J_Sgw*A`>nxjg 8sӲsg̘u}we3'ƓO?Y]^c֭?rr@db[.-- E`].Y(ϑ!0'd9I@h1 ܰvw~pٛY3'uK7}vҚeO?~B(k2@di mڌޱ"nhhmĉ |hhH)qF)E2hj:tyi x}ADؼiwo_aN :t!wuw;v(9\TTa'ܻmS'ݥ._ ^ĻIYr%9*ou09g!kliIuaw]}-0Dpd_OW,2LK)}ǰCwuW^^^NNN( B6l ;D=$Cv7tMYq};ܾygמx<^/ox  L|IG"P՜K-GMm'jku5#"@G_΂Fj;#~g=\/mW0Pkݞ׷lOҩol5,1ԾcWjNvkmqc=jl$ 5gc"b*FBH8NCpxux"b#B DH$!F")|cL%T$:L&Rd8<, Rp8B3}'gLV#+` t=m[2tӒCDB+W^}Ձ@ ^}'Άw߽i&4&ˍIn1K]K&4)*"/kdJY8>~r $Θ 'Gu8K,ٰa7n\vmnn.N\2fHD2l&,d.CX!JcTV0Eq@@Fe9`H0VsF$̱Gs<@ IeM"7}R"!$C\A43PI q_\+)*m 2a!f R\1$!S!9q/s2@R }G c*nBJOB1G<}_zC_WZ5I=|G/ۖf%+Dd~֭[uV0(*++cXss^8C9_Gpy{Jy<;zǮ銱S?h.${Fh4Fh4Fh4Fh4Fh4Fh4Fh4Fh4Fh4Fh4Fh4Fh4Fh4Fh4Fh4Fh4F8zY'7IENDB`Mopidy-2.0.0/docs/clients/mpd-client-gmpc.png0000644000175000017500000053507612441116635021260 0ustar jodaljodal00000000000000PNG  IHDR5G IDATxwU8甙mK:iR)D,(TT^(B ٔ^ov$|9s g<#(((gb!I꺎Zt EQEQEmm͂Ki۶c;2P;|oH2~Bgڊ((!$ s@q*e|'e&'s[:Gx)%H)F;+y 5Cǒ;I舻+>cTͥ!@ҵ( Ѐ96|HEQEQ/^Ġiı%BC>/hg?$cRJ@bẆH4>$11zƚss@~n`7nq)·߰A{uSO~^uw־3.gAp7OI5 Nk6?YaCx7c1HZlL4nEMvY74i[Y!2 a wa(Ҿ'·.uNc,~wߓzQEQ ƸuǎR@!4{4/ĈqܾcwKk`bpEukZ=qB%@a1BbXOBp8L qB19Rp!\zt)B"7K'g ރ z<xV@pd0b=Oİ]tZGk v-ؓ œh{sO$t5q ,ee(Dүzc[EߺRбuTRJW#OUo[nk5~ou^'^B9};?{h iCL+_lBjH -}TCBfӋyvz[`ftz1w͎f:trogtDZ 5((]kk농&T !s=oN0p̞R]{;ƶmJH_fLi1;GMs媵9Y`0X GC s*U88륄`Pp!A15f]zx_g[ؙݻ{ m]͟?a"-H-4ǟ] 4^RL@d/!a ԓB&Wo9fO35~hͽLEc*R'dxQJ/ }yY! $% |坉YgMu[{}\yE7m$f51m7IuSgK'L׷X|k/$$txۺ1WWI6\%&^W:Α{cx@6jb+ف:ťEҹU{6nj2=Uӧ&{N##YKo|I@`$ 7شdXWL2:E2wCaIL4*u`pUu̸hNv݇C.*]GEl;+Txʹ㲎*(|b efd^)3V]ʤ*BО,R!eK[[}]}SskIqzP]]CzZjVfVFFz$@q:30?;33%9iޜ#P<#GF̶LP{Vop#yVm=֥K2ݟYb׽߿mg͒.YӤFaX>6zͯ]x7<_AO3=㦮K9sB$Hڦѱ9/Kޭ)qNmŢ7ϒo=4$,k8ښC1AON=KG];ټl%חϾ׾G k@an1tR%w:T_cO.tfmY־.i _ akZ{W(.:&5MjZnnv$G}w;*&e%ǻ1LFDytH,?}o sz_յCVҴ_lAre/n̸v ׿v]ϊK$ܭ[5W?₢_etr+=I2o [5o-{U=3,n?\aR>BڡnYLɧpscOMKUuBcYtݎ:Wxt݇v\WA.G^e~\5hQ fi,E;$w7otٟ]1goRe1!%*|_{{O|(*vŕN)N9@]r/POOߞ}`O5l=&@; )P__:n|((ljɉ<!%&(9D(H)t(a$ +8&.JJ 27=,[7 l4*DC2 pRT=SQQAJ Jov7EX&FͼBM,yX-=p#'0-!mO- pKKQFgkb*X/aDOzua>Sp7s%WN}󅇾xdW~ٖIsrPf{z Klues>3}~%\PvW^81I#ĥVeټa"i @@ؓ;-7>>q-]ï>WwYWWTgT.%I!iizEI>ko& i $w2Zv3W(/I+Zk.h7l)κ/_7aO7lڇ娌M}XKօh>/e ݋B`(j>t] 0wy {o~`cc'&1)Ge[,D cs^x e\wE_LI${z"Yk-;RpMPBRo:HyߏfUovA"gJ}Q;7LLc]2a[4H@$<>JEQEQ>tDcc &actCJxrs\eY>H ujgRt |&`Rc )c$PPZTp̼hOr*rTRI]ֲM $:SK >wIœVޯ(uӽWU'ڴwZ.AJcLH "9?H乳s7$hqcD7fV}Ii~z y4G ۿfy_`}[뛛6=,=c&\Rq\0JLi} F! _]m߱rW1ƅ3w sT;̾X3ƃI]o?ʀ&gΙ]!mR0Ƥ,/iw6J9syƼo˺w.X@hw: s׍;z}g{o1og$]Y \JvEQEQ300dBpAaql΅mŎCq0$PJ8cq:d@ a<~|eG{k_ RcDW,3;1eC^JO<|uOAqO^qakO,uidL1kUC3|ʏv_U2)34bֆְdf8qYPN!YLqzLauۈPbfa^ݭm!Rs R/hf.1X=;2=FqcPb#RsҤEƶ&Ƀf^a~ll1L'(װ{4qRY}Csw5RKJeo{{{%ѽD:󀛚7&/)jZ*OFQEQaiR)13v1&crOܧ%n4^u͝XH joWZ؃NjĢ{| ?=bK RZ:3őwʹzmLH)pєg]8J (UEQEQch*C9urٛS#LF1gΩIqꔛ;!PM3e]QEQE9hw|ǰ`YAK8SEQEQKDM6-˶⒲*q1*((rf٨rXaaAB +((GD|uaH!1{"t/|= 7;1AG|CN2pOt~ıAw̞y8 Q >Ÿ<5#B2c~r|>K*EQEX ~/VeWw HHd6,]&Q²qk wDhٷ} j CfN~LdÚe :ɮe/_]1#E-_~"Iu0-؛VeH!!ֺZ+[gk H4l[rӎ0N* H~l+mI(N"fQaV0+T=PoǮ;%iډ {hnQΈmk׮ٴc׾>'鱏#^vH `7~k9$nB宧9e^~\k)(| ]6o|]=.^~NMEc놡^t×g&La膮EZR] w&ZY,yni'd@0tB4]7 ` :ڵz=:Fj:ah:pFD"6{kz.6i뺡 @PM4Їšv=Qj (-ؽ|^A#g5{!)g;//Ln膮QQ iNq|O'$a }VD1tM7 iݶ>yj5xyhNO@o}_ձc#:$e. NGmm@10~'۩1m((D.F?{IUd+~mswמ:uu7{/'t̓dz_{qю Wqy=X.Ҹ%T"Q%+d|=;qLdmG/O;Nz{_9#e}S.F\z/FW^==G'jr9up'\0cQu;%KْfWǵ-S5uϦs6շ|ǶWlzb+/Ϟå4P27ѯnwÅtD.4}ۊ'vx~"ֻWmvA/3nu7VQB3nzw>gɒ8p!A {xz!}~w/.y;w}~\Bݺ-X|t @U_i<r)BBQKDk{q} k3;;e5w~+F_Qw;;{{; I{Kǟf֊j hGUCgO//GQEQ߅]p>vfkޯ.KG:Lxsv飻gM;gޤPq%o߻{A \{B]]eSR4 !G(I s@QKU%]3-WGnYYKv610!74}_CʄRx3g̛>ȑw)ERJg{iΒ5ZfI^AJeQեwtyn̚t#u_|h=v:r}&$Ouي((VF0'гhaWu)\Wcڼ;\ IDAT{x}`?"&YĖ@0KHj)oD?#!$Eu$@5J`9HZր.%@r]x=_S8lĉI_z>N0fz@kf,+j6O>n{ʨ-!, @B ڙR uRJ)\Ywst[˜Q3nߗ &]2)eûei`$D hݮncrF#ٶZER . fBH@hCxYP\@: #6H۳}_n=;.L$=c-+XKfC Lљ+0Q4b6JL.,@+@yw.¶wÛV \! k+JEQEQ3c|f_xf%ƭu8myC=\e?J/;or {ύ9V4k:j4lZuGHUٮ, ,_q_MVLΦNh#%6+66XùsO>I"ю=K6liJK/9U:#=H9׊* (8gj"i즿/x`CixXOT__L_6l +r7h]]ok{VZo}{wFQr/U*^~>3]Yv|"aò޳lG(13#ѯ"zfWݺ _]F>)}#^֎otfhgi"~ehP{Wwg:ghGz1"xb޵_Tr1:A ?Tco#Wu7.ќ/\j>д׏.7o[õ\s{ſ>M{e%:P5Dd̤@y3BRBFywendNa8ezm4AGOL_{(;{|~ 27Gb?fe߻h!PW`̾[3 ^~{|Kq|RܟY~0M:kSbIS].g~g{f~ W\χ.dM8+&i`Dm]OEpr7ݘ#H^uW_zzTL?3 r>*̺)3j"o.|? K_٤yOD5AHyL[zRQEQW|!CebC+h\醡2ƚQ R2ױ]AtC N1Hږ i%@ n@ -Ilx nǣ$̘C=:m!APM74R8r 3>;u=.k;L'5@rf.PMGtB 1=b6OFH:˄p:ŒFz=wLB54mD "ǛǧkZ.`!e4DT4 )9smQMգ{$ܱmKD4a$̘c;2>9NH׉C ,M ǴGlxbDX7 əm;=:Hp׶]!HbF0߬ㇽt$gq@sׇ]W1ӕ $cbWcĵREQE9c>ٟp.c{hfC m̔e:tb";еbGn ƢXȬmÊb 9cGra'w#Ux5a29)1fGk^'w"(R8i:N,*Ē̉ 0s`6v{N"fdh~LH`Vlx#҄9t٧0mcn 8aMvc~'܌TEQEQ>jۋvKP}ܼT[)(G VUgxQV(|Ѻ3]EQEQEQNS~((()Pι )W59%- hpC"<)(((7Q_JJc !8cBpƹ2E9gqΥsy|%1׶>B‚tf\QEQEQ|}i8ljFG5tCq0zǠ̱11%DJ ʙ>=!:<wɍ]QEQq]׶,1*8 \[eأd?H8zQm[hϔ4(us[HwN]vcRP[{ช۷I II mQ3jEQEQq%0!G&KHlHir#MJC?1M{Sƿ89hoJ!:: 5" !%]B R.dfB|>_zJRr/-\J)_z QJy#N?;Q`EQEQuݔ4G) . )HY\\f_Nv`V{?!o8cn?Kq^a,)t/C$hzӴĠq KIi$xu:LRS9?bɣX~YZ.Y5|K^/ !GN1+bU?'?׌ӏ{v]5nIJs׍YPqEQEQNsP{uNH0.diEe,a zܥB )$B1^_Wt R׍lR_D" |wƘ㸖猳˻:?ݛdH82Ӵ$gQ} K LJdq@ХNc$`@ G<@4,ie3H#bF1H!خ=zwktر'pZo AR+۷tshޥs$ahH)-ظX'#5AM7 h g$ )1-F a)ewEQEQNUo}_3SksؒD!t `Gh=Kpls&8#;ic |!"(H)tw9g.`u6orHFB1SKpKOń["D~BF\J&FdT冎ʳ⵳x{/uMOWܿ'eM>.*˽.3û汻쪻=U~X{8d:TOc]Ug|_\95r*n3jݦ:)pLm+*,>Ot;gTm.e޿=z2kZa9mL:<(1Y05ٮ[pc;ٰ{e7VO?ܩn=oN J#/mScċ/soy=Ǧ Zy 7$zÿXM]^׺VrAQ(٤Wlޔ`_ fM5Ѩb y^ --gǝ1|xAEGOy_DZt_=mꚦw1cq]H8nG[3.]ӫO@ 2{pgӉ1AJ%ªE9X h+ &iuYs7.~KdBd\'%-(wQN@f:ci=Ҽg32dm˻uWz !rgogٱ»lWMhw ((TM3r.#X8j$z~ug>qgIBMҴŕ-~=c((g {?BA ēx<P8g$p@厮$hXe,[ȁA]L˒w42xX,ʋOٶ}$pw]ױ2vutDbѮ1#{y4a$Q)APwT %Kݓأ! 2TfBFl"]Տ9IA{̈9,UkS޾摇VJ K{6SV?==[hMw\p880n6to5y=ݳOQEQ>Gc[MK81/?WJ1w!p4ʘҡc~YaL8%)1D;#7Jw>kٌ1.āUU=w\y4l:I c)?&a[hs詞Bh,"IJ)u]q\.\ܶv61]iy`1Љ("iw>A-. GC3%u޷ƎL@Fo`GAaTIZ;_; f&#Lkׅ(O,[W4 :]ntwxىADChR#8gE lulouEQEQ(%Ŝ$RJG>Bmݹ9+J`wAqIB7 FY3i Itؿo7x #ѰekWXv?L7xUBUc=#<3+9'tGB~$poR!aę{_cY1A`|AJ/F4"%p A#C)@5\<hs, ME[,(CQWw,\{%͙;nŢhz)}tokW ;jy RΛY^ZϹmH5{ Rf$y1BGΟ?yg}W SιrNݎ4OfN$XYsyy)g7&s,>g`jEd?yVz FbaA}DEQEH##RS'MdIk|\m8 EgΞ~)AJĺBgYn)ʒ{'T6cVl؋i:.?y,¨bn@J0A!4c̺Nu]RS|:GN39g 斌jlnBm$1]Ugx|.F$p&@ Tе̴y_ތϞ0<1`;81~Kܼ}6)8߃@ c4~FAq!%$%7|K1us3]q R2|÷@r埚H9sM)E+IakFIOJ-_p׉N:7LH a/ɹEj̯wH)\REQEn$FGLd@bfK{gaIi@/O5YJ`9 WbfضeQBCMi[mYR*Y%dάmmmjBxά f|ЄH$z$1cqyn]޳ͨ@ "Ѱ$s߃ K"n Ą/^{0?{x 0XVk_q(-&;uG^-wRYw9wğ/ƏƝ#;rgJϊ((8uth6u4&x̌ )zi3gq"0 yA% B8{:v!4-"t)M˶),:cxIS&>\k׮)SB}G4yJC}öR-@C9=Sx pR:sHqxrƕf4,65fddYHBr"0`G9 QobryZzs8wcH$>,8mSEQEQ,fҘmqVXXFBB)'d Ӫ˲,"4-˲Ir\۲x؜eYNRJrffضmq>x#{4`ҊqWfTH c=̌bD#Ƙ`B 03N0T\>ah}ԣcrN#B36M"dx=6Ludue2$"Ae?ST39BSEQۖL47EMXc-M}1+uܓI3O/i; Kˎَ19eǿ8mQJP,4<#Hr:wz=='&Hq ,D2.)UMܴvr@jOPa B!.=RHb(F &7:9tN{')E|1 D06xli˗?s~17߸i!<2FS?n )%cg) 40)[#5\`BB(샿)aJ7aw.x3/}_͟Ă#LHZRr0A͛z݃Z 2GMS;N~4qsjsHB 1D8~gؾ>1x$c@  P 9NPt]sآw“ܿ_? tb"1 u+ &c-w_/_ ^,w-!2^o"e }(p cC-hv=oUlv\$ںYxncʚ_{v軮K-ĞH\!EjÎeO-#-R3}o9S_|Y^}tʱc/?nި!Br!r#LutpHӴcGkh:n$I?bÇP߈u@dQ8秳iYdNh@JM8TjiiSQC* Ʈvyꠦٿtw C1 IDAT8@0N !]aL@-"Auɞxsc=؆{k0tp@A(CD($%]CIe`qT4{gjtEan~լ7|g W-umƬ:qw^/ZtMW{^߶sW{s5>F#S ψz/  PoϼrS{usFԯ{zʖD|M7ήR쭹XjۛWiλzEyQMC.XiWq@m'^U.~ڌw}=ߊ5}w.*ҙ-hxɕӮ.TJC9C9#QiQXD2TWtòPMUEJׅ'? C3a'9۶ "ȏ Lؾ^b0$Ϫ2rH]8; `k#a((/=eg'>JwDuZŊXw{gl֭<%? R@AR 5b,AcLXܲ,p̘&YQeܐ7MX X2_ ("1oeAՄtuyW}lݯ1ck[kҺ9 ZgK??!έy}ylߴW~sѻ$BZT0l%SI@@L EH%%@05h*hD)"ǎR{79e%'IJy$2LsܙO>@D;j RD%ev'Lo!jT{m.@DDpep$xD7SPy`Рe6]OYJ8H@b TQQ$'Ja0|PVʈ0ȡ6"k UOR$K-sŽK_)J.Z`AaQ7WE㼽LqѬHRDgQʋn]8ȴ䕔]{2VGs17 ^;D$2ADH:M+ECA*Γolq# EoZ3UBD+5QSuj( #iE,T,~Gw‚!3,<$T2P [z˴RRW ]T 7oAL&0dJ-b'YWhĪ#λh#:0`N-td#ζ/htapj4֦T͔SD-! =}ϡ=-gDUpB003`Y6,~y_ CM!TPޣSJTX~:S(Ig^!r!.~&VDфx?pwu@̯<+904,=/qʦUա2nt ל7@:IT=yciN?w{&iٻ°{?|sڨaWzaY=;hϿIk&} e~yV_> >UUYkY'̐Ǐq6"9hcDC= Ç \WuHe%FU^^fqEA5eL:TD^@baEa Ν;M.+*+UU8T7ygǝg]K{=?RF@<ߍ_󩙥شuZ0yi\@2M(s ]776L<9P*xˇ NhtI2#$a! H$U[R%""`JDD[DK%#yaXz.>0{66:C=k)20%aJ71oX=EŖ_tiQjLv`H=}HVWgw$IQ@ADMYXE(% =nHuTX,Lh꾯Oֵ}uf3M,4)?;QROS CEyဪJIʾ)c& x~]a0k98x##;Ģ6UZh"X󗗏} R9,j7Z& s.s.G[6Qr!D@XxRcG+jjwnܙ?&}𵿴`8Ey`D`fwKuoW4rB>1H XG=.d(P-"I7|!x&  8v_15y_׍M`pa=[o޵j58:;!ps$IIhX@97OZL1N㏄"!nKZ8(2T~raVo?Md~U4^n ]qs.<Ԓ]$!B!KB+[cQo<kϻ|y'1N7̯0M}Ê7:_}Uͯ??$v*)uCy!rS,Όޭc,~ͷƚg$X;nףvg-RaR5 2ֱICΟ?6,=vH@2R>8p]fL 5)Q:+ϳ 6t׿ua~7/[m:Z8f{7p$ga J|sْǏkinjYǎ<:{C"Q"dH CDR$c1)ym__c}Z#2)wSBhirA2nX2aHԀe&'DIQ'MLɉI`&*Z; .;L8/'A97rrB}: }t:LӲO.g IXa@Pn B&AuCծ)i^ωE-nTɾ2AU! n&)"!7uƀRS4?"a;-!j[20 j67'뚱j*aw吤(, 09$+,i'{jBXa @EUeعηrC9pm[v$ j۵_1/sZ2?Vw.Z:I"`z~o}uHC'`~YEIH!@q׫m|Y7?]|w Vx0mX@:>0mCΛn䊓}n3ONkFz*aI diX,2c$GBpS7tBQeUbC@,4d{`XgDxODYQ[n &++.9g |)'aeŒ1 |I|~Y+$Y &eP4ȴ̥N`()ޟ(AI1$d7a?f(9ml:bQrO~5 GV~f^·Xs!r k{ʩq |YQ'cIAU2+X}+/,O# .0,#B*D[ R .]HЉ =SesJ=R??]mfyea[( mn TUxw<8y<h0~Kd@ @$@@i)40TC@^8*+,˜[lRY"2c,U>/VS,8~30p<o`iHVNRr!r8hF|y/mya8Gesͽ-O<5|߯`_j߲%i%W~6} PK|e##_P! !2YSC;x_n>*Rz࢛?S_O?ە'g|jd 2PگH8_6|sْخ;!H)}'yJS29C9C9) o]eݿAĬ" ]]/!r!r!O4^-(7-9׽!r!r!!Kîmg 'Ln9C9C9> }1doH Yssciiy:?b"ळ&c=U`述+B@5^M @S-Gm?*ٲgGx94q3)2lmi.)){?~w~Φ.+Ngߕ.#'9gưo~d)X˓w.=@@:VԎ(/=>|%>B=Olu{~*~S(cz]{NA*ϰ}֗-|YoSVXT|Fz1b-H %>[I,5:{$f6W@Doi.IR21j 2Ɛen|"!ٽٛ7  " Ǘ b5^ O|"{NdS޾Gh_f9\6iEcqFc]j@&\e=z=$"fVtFȀ __W:L,ι''JH#3:sK{d^O 0=LY%L$Ѝx1)Jh詑Ǥq $-?-iʱiqϨڥsOmulKZy}f=Tҵ cA?H۽,DYUwWO)$WHk2EDWUɌ =WLfdbцGis`/ڝ;yxD@D>>{K#"+MY}76t {nic3i[?w$&q6|iigvf H0W`lBd`VDg1ˬ>2mȜaBo~J_Y–f*^+'QLGtxn| M%8#!1d0dpaC=@H`jF@,iAnicЎ["bY'z!+Q6w9\ۚZ1iQ93r_Dh[Y;N.㵵 uS$wU?;unU]g; >ۥKZLwwqo4n kN=틡]g@>nq. Ӵqk3TeKN{G'`z,=5ϰ`Ob8_O +!۸MHGm"9LGy,"p2M`tMw6!\m\grKXy04!<AF,)q@"kիU4 M?_웎}M??*B_严fQIH sd؅h%dXoT1Hٺ1 SnBYeYfn϶#3@ێ3 A' cr#sAp+$Lqn f-GH7Xw4l eU]N8fJ6 #疽XSF(HV4EX).0-(40t.l㤍\dk@x_rM-c;19cKGͱ+V"]N2}G vBH7 F_Nϑ]15gZ^Tə F |^q`Ls$et?`8'rre뇃#&D\WZ@}DO;eG2= Nʌ;zt LOHyMfP⾂i_fT7S*.L-Ss.̣ iu9L]%9B8⤴vy4wdJ0NД;i&6/HQj1Ðf7,UVqNI;*t$= ݚb{53e~FH.xz{fҒa:3㊩mc/2Č[=Si IDAT<4A^T#|9]~~Li%xZpKrnPMO)$n:8=&W=m"B!pC/"7b@2SPUUn lfsqxcmM!AhӚ5)a| Ƀ1L8)zw j-n!2-1OI0xCD}bӛR_rZ: rU 1[-ne@,KK{~,tBE^=^N\ #޼ӟ\0uoceR|Ҳ1t_*2Sye=~3iÜy3i t^z | Y[$$˲lzp"&5E:3MU[h֫}芻fTt8 |B]~>fh_9a%X-O~n,7fz?C3K~>gźy \v=1[et/ D;Ӛasp;n!_@Ϥo:GgioK&@FЫt5'5.[!K&NO|0YOl@x@k=;{^n2ci 7,B3,xUyцEܲEQ&8wy1d(ow•IUeaȊ&'n1]Ο^nuBp-!-%aY%;Dn\2#Kor,#4Y2j +,!"9 '1iMt:~5sL9se 㝡U>fu_2z)_+3]{^gfG gA sh-`\?qO޿d7ENk@'˸3֮|hQB%)M =jAEaxO{c*TS3!7d0^oW#2f0s=~Ĺ e#8̅]y}s~ ,$-!_yN*e"2v,#$v/.q+SJ}.d3Й4)H>)x 3}cPSԽ"9X{leָw?[|^R*Y'n'WBDF$lo$! NȘE`[ 0zۡA/b8{2'Nb?ʞ0/wL,Neo3lO2 ²^ByA!0ZT1uE,SNt;Z g_$!1$ ${~Ƙoas'BD^_& ƋI[3$&O۫yeXA)7p/T_uvLjGHnւ^w"2 ' L*2z\Xjx%65tb/_82";lgDW'};C(1 kAq_Ӛ!}+#xIu޽s5nZ+2G_$߯VߔVLuي > zS)wENQO@@a%_TWaxʰ,n& "!E& ēf?\XX(/<0޽7ٿ7k} sƽgTIp=sCL$b$`HI;Ov?4qᖃ:Kfq%$P$Dমl%R0YQ[? 3@ALXFJPRԀ* nJ'Sfg9# Dg9m)\Ro;L6=\1%yÛYȀjiyU.RQf6-_f\0@p!(#/<pK D BAۨ9c ]{KƭmF2#s/ ܲ8׿`[li(Z0 N4o>96Ǘ';zM><2j)+WE\Qdw+r"I_Po!f|#.ݱyeOH|<8q3M^aM?y|Sًa72?S> ub;ƴ {N /v-S]G^,{ќhbo[8|ǁ}\C>ݹMY ;e|wV:p9~3wK',ضݮA[/O6C,~m59@;huKSk ;[ÏDqؾ-g:PpJ6Z>NC&͚:5`[INLKD!r/%ݶgѝ7iS ۺn|W~f~QЀq3*O u \FNLj0"{W|xciTs}3 S;jK!ݟ-*8҆?QÙ[Jdܫ}-"z,ݙ'^+&xtc^{D]Wn=wԞ[kf\y׵5f^^ޚ?|rMȍ_i}{ D'6lmN+pn:Ƙ$֮iw-W.䝝•۸tk{lq00Âf'1'\YZdT8ʞ8piTbL!"$u޳%:ᕣ#54-KQ8g;\qs 6/ Xq}@{ ȥ]$vMP`zیBYc?%N\3!9+uk]>s B}m4go+ИIcIwŵR2{21`.2NJ臛chv^=TGe2NH9Jxh;[PyoV0A2h4w͹f-//ݼ~H4<13n8 g'/!5,9ɨ)SZC~iۅ u]Sg'Q8'\4)ZkWve]h5LwW~p1a<9-EgD?1y瞢nTʲOte×zD}cAaިC5MXSiw"CscnNnZ ɜm|{MB:Ec? ^T`Zcȩ i#ZلorſuMgtL/=zd"-:7]}Y0f7_"koqh'|ӛ?B``Y:;elчvU'yIBaNɦWvu,9ܛ@ ۞ DDB1@dL ::<R=^V]bƖMZQ5AZQJuʃo?u94pIF e *FW J ^ܒ\6`Zu=UK6vLY( x*']2a`^oG'$ᨮ@t6eUR_kwEg}sT+gmҢ%澸% Z"ŪF-]C/z  iQ,!|Fk64$f/Ζ>rzHRl':FOza3HK6l؛(0[h`YMYV%4[ LTႤ؎?򥼾\ >yZ lt<*Ƒi]CW=j-.?k|Y:o?sP佫rCD|!J2eBe)Ny;o/5Uߞ]PeW ]{]WhjݝG#Ҩy{[m)]|L{Ru]=e>И$+Z 6fc{v.NvԚp*kCϨ Ұc*Vdn~eϔT!LOAeIavd 5@MSP7 >vZ۠^  {Pn j.-w^^fl}CK&w9 w- 4i٣FZx( FUI .cX m^lW̭kvlbGslN.&rZsFQ>0Xǁ#QGу6̺һKqIF$4mT$Ie\pEΆW3:_F˷2e0+^O*X>|Qh7ef@-߳i#* ,t(BF4&Hp-1M@5Yђgf16apoXF8uH #}02fd:]YTb NEPH#JF~Ρzy"Y6|]7BYWFP$1TY0\q(E7+ z+hө|_ eqFS>pt/ήI Xͻ=O?͎x#= E&-D̖Y~w` c铲d#%oEy[ PV7tyڿV.P7i˶, n,-0Yɋ8xfx".zۑ=s H D11(>y{~Cwub#U㦜7w}1 ږc9Uaasoo#|S! =+IQ8ZP5dhsc?c1e]di$錑~0Kxg7 ([N/~H,}kk9gzqXՈգ{eڧWlrcvL@K_?%o.{ګ^O5hƢCjz7\z2j=bB F҄"L'|{}޲q*b}P5:e>4+_2_Y2tG޻v s Bx"CYuϼ)~HLDd>B 8˯QR:"h~ _10|d|W ySJ !Ha%$d`S%ҧ8>;I2CDB->do%.)cDd`[ s2g'tS^O՜Iv+[1{y>MƹPL&zVih;vְ^#IA~iLbs" K$>gn#Ĺ\`vvCL.Wsx`T TT)).?PM HTW[%InWDOs+_7z@v?&PAŐX25 ~kuO_~$~%m[&4r['EǍ,m~, C^zwyWRYPn1hܧJ@ ~G9s:G͆bTF XcJ[OAk"33 tV^p>Oj߻tiJ%uMatH|p)[a`!۹*qdC S@T5s7  9{7,+ I:#R7؇=ڬoL3X[;eBVZL1fv_z}pe~3:q9{NH/\xal*y)U7D XKc-%/ /,''͑JIsfOJ"}&g5Ө4"O'~aLy X]i}Gj#kVwVhG EQUkT}#H%^.I, ]eI%Ņ}u%^OkrȐq< ?ܞvⅷ:.$~;d? UrIc_VsS ߕVT~2_x˃%¯Q.ᵟMAVYտ=ªZH?gs*"۷FSx*!d555z{>`K Ԧdav ΋+K4$=A*Fk\FP͍mjqyi]w_R 4PI~HCʯʓM=TVUZYayDEfGk[QR 7w?R躮*"b g}*JԎ^ZA,dssg JJC ##%Ɲ@Ė暚T* jaiXe=1CfWO"ݍM@T: PKKC20ZZE EEv\˯"ݻGawhgX Hg{1bĩIͅ} 6\"O,<8xUK/<-9@CF?d,Kp#[C9C9'r~Ϲ'h%{ߣˆzN:C9C9'rI~Y"o#nwuxY; nZ Rw/KB)ZEw  H\r6BH(m vvgFy;7n(VX… oh_>л΁V{|Ǎǝ@νfYj;i:gm9?|nb6lv.(g,)Ř->.f8A_smOT;jOw .'TŔuܣ2Kwvim4mݺu-*`mÝbϤ&3.Yf-[GiqY+0~̩?d٫ۈ$ge= ^<^?)<]!ۉ_O(ĨPX}*?}+vJ:g-5i9k2@aJkӶM˦{xꆔjкMV-ޗ OvNleesoѲeƍZ"oCѭZM z[V/ܓ gM[hbs ͛{R۶iժUv<1v,u#ӫܜXѴUV-[w⟍. oׂSٺYZF0auP䭧d"g_`=1!oIB,gffB4_"/l_r;jæE^S]ZL[O|Mpk+)?e~$mcLý>mYŁ;UP~}6OZ[Ϥڕ:0u3XlYC=%Ya e6E|}I-uE*zZa_Jֻ^Dƃy&0{\?qv᪯^D'v-7gi:*Ұ.(~<"h^d3tj.fjWz7ek:JY>q'\3R%Mӿs 6ӦU'cV'}5l~3+ܦe~Y"Ö^EY<\0c`"Y3Ǧ}yzlWlCg=ڱwWe(Z3-(8V*u^uh>p˧պ.ɸ_rdn!Jľ?\72fn3V}/5|Նc6=EuIg񵐵|r yo2>5'l[  kU9W8= 8бzM/г:Xa,>_wf8 7/w`Vf+M}њ*ݬ{.;U;^c/r 4bʠg l'fK )k_ǬZ#Fڴ4O~P.jRpƶ_BWQ^^ZO{cNd9rd.][lw %;B Բ*-Sʤޡ~ZEFoVd^~J|~F3,Bj$CJ%U-m[a[˞OWlx+7nZZSBۼimElI8xdSǭذveK\aۢR;K&|Ykg$ /3O{+ =3vɪՒ?o71y_H<~⍳'!W 7+IkWOk8mσi觱/[ν "^PzVPEhgmH%Xt]`pPL+b7׵*U\ˁKE*`? I ]DB&JmP*8VHLUD*G(:EN~ԭcվZ8J,aar3DM]Ga}ZixU"&.X2e1ExɕߟrXiSݧk=yзr>1qoߕ\kև׮"qu:~dY+2Mzj &Rv{T9"K߼#isH&h!vq%`d凰]W>,&$PEѧm|}5_''DO NP; /xuTe"ހ("|tO?>&zG Q8fѠYGFܻaɜ=eT'E쭴OM;e:_@GQ}ƒ HWş|&7[Uz\i%c{4Z߂U{aݰ :wUWSItݼYeY0b]}}Bλl}0B@_MolT%{+G/1;WI,2SiqMo=,T2,dY>L/ޒ:TG9=S( ԉD~ 17L~ڞ'n=V M 8\aAgT;0ww|R!ӽH)@^ZWr-Vӻ/-4x,ɴ»/] g@ڴqQ &kIl׸:ܺ~\Zk^|Z-<eݏmOe]9oAXTv%Z|ۭ#%"jDb)oS@!01^=]*]s`:&~/P2L6x@&2AƢ$B2E5'EQXih/_NzrN, "LT[GOIIs!ֹm )8r,6w$!Ue̬hŚlU_z*5Ƴi]zIMG*8fYVAÛH'M &*߷ͭNGQ#ALDJ JWj(+y`E2[s8ԜČxwxPdӿ ػ\/7ϲZ]a¬oviܐe xX$ XR2WqܬgM 'd>*VPDfޡXI< RPՒ×^X4GM_ާ{]$s]zjשĸ{ rJJIe嚸˥i1usTsNtQYL@ɥk}XI]ϰu_[ҟsoDЄ,ǓJӿgRrM4Vy9PoO"v!\w/?X'{XZjl~F,w.us Nſ(m~,K.䠥s+ a1tUL2@yuCc66*O?d9hp:A^T_RJcq srV&*^!X ^^8 &'Z F@ K6+?bnbI>fOv/pK)>iϻT'rgO\IfKJA7F c'J~xzƟ^Kě{ٸM_y!n>|yeYf5ƴ[?\9C&]HH1!}1P1XC} #@_>Wh߼qha:j0&,c,PX&@ќQ() 0 heߓU "˲L(&90wAp:.ySETd\rCMf"2Ƅ`Ye ;VeIvnDeY&/]!nw Jg+P'%d,% <gúw]p!& EaIs ٢u}ܴ믿8S.@,hY/i (ofTc SL)DwWjD¯ Oe&u-AS **PxX_>O%K RHe F㙦ʭU)\0PJO-v]X (sUG_`e & H6*dR$8NJ1R&= ƒm]JԤ__RQH2ε N,_om߬zM:y5&9װC=ǯ#>#" Y0h))8P\rSٺb]R~ _p| (G_[PȽ/9EYt\Z;.A$vlphXEIԫ' -rB?5YSk}tF™K?MɂeX1!DeLPxX'8Yrw_Q{p 7*~#MD)m¥|"" {++>~!c,ˀ$|o>e,|M_W69SeE Dr^!=P_/Di>#JVS~je aYeY,/b:{F 8o~ӫ'D(8AAهNcIeL@~Y?R)‹NݰA|*;ZGXBʜBQe6L,l.匈B`@O:V:dsPr8B"taLS2(M:tıu*V"k>yM]< ,YϬ&"fU飲٢n.ظXzQ"+:cPwo Rf-<SRR q.wjhvp\a7'TROO/ֵgc#6Vl;ۓmC睺wdjzu5]=zSbǗޫ}CnE[)uZl#]YK5Xe,(woʊ2q9 _8az|rMZ/8 Հe)pa E*PiAa+n߃*T/_2Z士f'/ ǡq {c)5=7;XYVLXuvBmڄGky$ҍ&ȕI/_w0^txOuB0M4.PJ٠-?Ge@Jg0h8 BFjSffqԨL^ZΚ}4 ST F/4'g~A" v/1{Dthd)3%B:3ӳ"AZ(K4; IDATfnGz_5Æ)Ny55$q@\,Z t`6_bkF78Ғ)im3*eMy(ZWQD1UoY Bfr3[O C\[C4Qk`1+#.Ȉfu^cymBִL;9ɤb@gefehd3+#9ѻ,Oq*Ѡ3-!#{BUZ$''Fe)Ry jHL#HiN cwdWVZMFɤf~B܊1l6W9BZ/Hs(&@tC Viz9+=ˈQLFrddgdd"حVYѤf)nY))Ӭ[CA#tOcK,ĬT;V"6x##3Va4yđ$i|δ$^kPzpp46(D1/NIғja55ӎ)NK "gR#"YL*KLOu~o}q'/]yr^{zj(ɖrDh6GN_D @Ro/؈Мhxs*wۓk!&`r{@ AH T* @$8XȹB[GBV Çs;*… ?{ytuUסz߀\F?g$Ɏrc#_3T|S%qF+v*S/5N2*_.Dsfo.g yu)}b پF9 'GbFCjn(I'қ)f}iN2ɩdU|sU-2d#~Qy7349|Qhs9Rj)Vm5 Jo2羱s?RoRm<ڗg߯$g ;KAkrbf߼w~aTz?ի*4z_M_)~JIS*s1D ׏1Jk h.'@ } y^:W/qxi^}x myhǾXcY>\V7d'w9I:0f4b\idQT_ - WQ"%>y[MXRBc?x  ,Xb *ϟ=x`0x١hc|ܾD-yS=@ݻw/22%=/#w{*T% ~޿+ ^R ^oVCE'tU׫${dBʕ)EL ~uMOq߈B_H6;MӢ((._P Q@/(<ݦ͹DϦ"UyDv7"o*sw?ewm[Q2 բ50ÓlÓ3HK.n s Yp8\<DvfbrL @;6K{[ɰ`WʷЋHN#5iǏm{+Mwu4?ݺ\Ȩ.s4P*U-ˎ>%14b>Pf*!8h4CR)ה˘#)J-YLJx6PftCr$F̼mb>lAE=;0g;W=ӡV"Ig&P9d`8P`w2*#hyXPȻ}f9H"/HQ P4cJRH.OcB4ϮxG'K(=ꇲ {@f8%PQE Q`Ix*)DD\ /ʯ <&oyp[6gJBSB~=Akٳ"0JA$D*5%a)W'2v@rauu;JiQJ+^o!> am/u&b2e`TJ ͩtR/9?Ѽl. cP#T&G3v`ɔ BXyA ohٺ3Nfj4tm>{Wu];qw?gZb9%я-{&C%5Rq=F^5~-xKig\rZG38nAVİQ 髻<oP6]q#?6\zgt2%$o F'e;W ;ÎJLQ48Q(_n!=bY,bwO뚨Y.ZiN`8eKP,4P .A8P4'.1lN/93^#8&D\|X }T͢ U_EC-QzJVJ؍ . 8C#%@^?2N/} $>NQBH'O:q5S?HZgla6(jpkE˛4\R;[T R73)(D(V>ɃfKJ͕L (ZbUT CQO߾0f˙GWdQk2[8Uq1o;y٣ʳv/rUS/ZE*:>B1_:7nN/3@(FE!yr鋾B:(Ҕ3P4S/tqJ*|f՘_&~NR#оgle;SV1+<B5jԓgw^OZ 1>1ŕ_A~~զmX$%GSN9貉vC6-@3wŭtTQI0Kv/ _.cakt1Qڕ?qfOb_~4qI]e[jҁG:ƧCF&wCz>,V;v/94Xz\. ">gF@ty/)NS3P t|%{A@1*t3s5mr}&6_=`˝h'\Oִ^)69{v!Nd?)F9 7zj-_Lq"_.Cs[V6c݈fGk0a]_,;(x;qL@ڈBϭ|̜~Uw.o|*ƧV[Cm—"9)VVjt"8܆sC*> @}b\& Pɂ3RTĤX>VCУfgM]sB&UcWcxIkOs兮Gcʉ裪uo!޼AChl۶-,k6YJ!5;{3H+^?ę-J Xtf-KXqnwljJln!h=G,Bĕ މe!!!W!zyk;[SlѰpu~RGJCbBqUܾ%~p\'@L7VUaˢ6YNJCwڄB?<>~1#3>5d*9d,t+i>VdDhU_q|P?̴=;Ȍ/;zD!W4LVQ\ik_MqkؘA˖엾bˑ/avk8&Ue]u\(+1H@OmA)FժTΜuf&4{v;qf- }[ < J#*?&:wT :X`;)D7[=|Qiz%Kڣۇxǽ}ϺN]Qh{pbʌSg}?\[صiW9;Ozzޫ[tkC^2N֭]G?VJOxXsKVmɼyWpa~~DK~+կm{'K.`5-%ED(ͶX Fˍ" V2p(gD>3VG׎5˰nRM?%|W;fXɂ!zx 7SEӴ`pG&`$.+iBgQLA&JL6[&q`BqBQpKDwU}[^81>.,JfoReE-o!?_AX䏮;QJ~iheÆz; .(&!D'mk0T6vb[XC'eo 'Q9s㛵-PΧߝM )c2apC;⭆T ;tZͬjѭIYbpAxŃԔ][%QYtVۯhe2r";7ܺ*JVd  'Vќ|iӮJPL ɓCieNBq?Z(ǟuewǿ@mBˉuQFQ <{/R+mLByZ&*6i#NKhʲ9F ~qq﮻PZs.F+3T!:-Yu(MhٌW.+WGXN}K|32.S: {_EL$]Q7'V\{R .Q4iBE 'A7%XIjYBtkݕcc㇊ޱHfmuRvjPSRJi{]$.!@0f-mOw181Z m˿+7^=Q?u bpѪ(əe&]uZ.]y|yuW,4WǟI_ȥeg#K\A~W7DKmFƔSeϵ7% rmo۲>GL-z 9"x=޼kտ\tuܧd scYq$l-8y~+_*q,E]ޱHk&!qwuJ8%bZ҉ ONt@ڡ;)*l&UteO;|*FLF܁+tԻўpH@/"JvO}VjcA*MZ,` B9B3ܠk->XEyi2jj'D ,6CN'@DgKӎHPoj7Fj%IVͩiDƈDvZTB0&S)mVRD&oN=x{of5Od+># {`1˔)?|dPnёQe$ BGO @JɖXzO(O;R*/Ç?+ߨSY2xjsKg *qn! s?v'&RhYBȯr{Tj 6D\A7>6znK׺SK$}nZՠ{K|۝2J,H ʁ-FwBL9D)96.!YDmRהJӾ%k29B1i9 jKU.~ĵ(0Ʒ6N׼by&O1B¤C(*SD(Rdzy5JSII@\Vӥk"؁uvw2FREB@zva@yeL#[YHcd MrHDhDD! PD ӌWxUUl"R뚀 Ѧ悄[ M9o=GHzӼXe,adOT̴Z"#PGI^+˙ wM2G$QB8c:mK"" %Qtlj$ "&DEWA_nyRh8R2(ym['lp?Lqy; ^ZZ1ՎH ܘ{<D6{t% ON?%z.F!LXq l\/s2Ie0j$䷣jYRN -Ì]lԫ'm0o\ΛofM IDATDibi5y .>ejV,bG K^S'X\6 /Mu,iC+9fJ^AL (V 4KNL_N0P@&fyԑ$!@ 6mw6n޹~ѧdv K`֦*Mr~TABeb,ߘ\ܞlVqw<4.ʻ,,;ȯՃXǷo?u^^Z%=m}"?lD\O/7U=9`,K(@đ4! 9#5K$SL3HHKNw, YM0TFoz>$3p<~`n?/_9D9{#XEI.d>8MI@ecjQ QU,ReKjb!b,8Uݿ]Sf*U~; 14;%FIBq2=&m܃Ȓ}7ߞ1hѥqO&~lϻ6zXtYyTݲnuHC!NMySD='_ѳ,,*ܴ!5> \y;FҬI˲{h,=zD]!"l% $U 7!Rۜٚu 7| I[̏MIv;.ӺYI9l6A| So0mtI0Dnyu*5)lbNkf%OFлz펕/4\QaIY{Xȩ+ w?emT %˼faGk5nݩ~M x೬ @0-}3~xƃWIͣ ٥|,QJQO0n⧕,AƀuW)c>3}Q G|e1W(<Є1R+};cj:$\B[ؒ_xq0[y5Zjh{aާ#F2?4=Oog*S܈-ڵב5=NE}L9k)2$"kkW[`)ZeH2G|"ŌدR&=yz@q>I31CVawU]>3{$ %Kq-)VCZ(PxRܡP\z}m7 B`nvvgv3gΌZ\VwCF(DI[NaYE]|ָSjSnmK6L02ngTix^ ( ~F.X8g)5T \}w[&'pG҈U=*7dĨ,kBB!\ k1`0>xkȒV?&ڀ5hfIVpSLs̔?.|c!S1B$4# o_zʰN1B ~bBBĉ$-kSXKBϟ=cbٶm[ttt%J >\&ϟ+W.j"  1Q PGw~]ӈVX. !ȳ|C i}%S" ' L)!"HB`v90ESp!L(x(VZ"B^*!Ղ0ESX9ҊRJٴb0ozB 4Ex1?Οxŝ$%=},T]]Kxwsue"ĆIγF~BxTx"i( "AĶeQ4y( ۓEaDļDK% mM$"vEDy HM#a¢Q4&Gd2YժU333F#t&MHҿ a/gYL~i]sgHDI$ aZ[<h/:?OB4SȪJLi ~[JW W0,¶M5ǻRP^P*23&Aoh:f? WD.,ک¿ K/S % P[*`0z_{3;+R &" zr^I W'#ʿFPY 'p%'|{_+ޟW~ڕb>]Խ 58mxUqiyp؝EhuZ/b'ρZ5zw˷#bT{Suw.)Cؿj<]ݗJ~6سqr@9>ۣ|Isa\pOSm%(Q U;~w$ EGdKYԞϗ`GW(]ypJYƳ FSVvదaMA7.?b]-4إE sVpiFQyM"R8H^ @ID .#{kE+:Pp*8o1𸴇D 򭓋 p=rQįjs5J8eh fB*JR}HIQ2SToN>$ EGZy󞈏 BHVO|:F)'DQ4Meʔ0.Yç6l)¥~eJ94/OB6_3\@/ǯPuHU,$ǥ62f 9)xw?c5"z:+藑OlYǁD1rqK~ qMǏ!4MvWb| )l GkS؇"HE4iJO|mCE,DžF:ΎeHLïoLVvdxa&$+="O$#ȜE T}&%bXּ8՚CWlAڦfү7{ d )+BZunӞ=‹kh‹&]:nkh`t!N> ,^wqqh%cǎ;vرPwY3LY9i9zcbnMzՐ2Qj2w'a&SaJ)o5LsfrF->\N F+ Z3* 5RQ(!ΘgUM;((3+*#b%*l v?@`г'JE%''o޼}n U"AZS}Re{JJ'*aaaeʔإ%22ǧvBFV*.6_ۗӣA@/ I'hV)Z*dJJJ\svr(djDH! AMBx%N9`ΎZ; NNDvtrrvr(eiTrhhB())iڵ ,8pz2ޘ`D9/#"#yMMىYy1xSFB\LdD7!-酑c\tT֐BZBׯlVqݣJdM /_LČmmE0Dly FɅ4Sc"#"2XQ|}FU G ;L*| :eޘed2uly>r :L =9;5&*222*EgL06ʿi$bS =+.".Mo|vRLsy#䘄Ts+ ~:&=ο7DFDƼ0pCZtTdddddDxlZC̄H<ƌؘȈX{bNZbLTddTLZ%w>LIqёQ1iz g%DE'5ޔo!٬ܺζ q𝺱1)6 r༽x 1z>!E"}jOGHIN׋ oүܮ¤k"C78BB@-0cBT*rV}5>r͔5ݲ=zm.u1 N•jv51񏥛CG6~vI]FU@w9t=%psGܿ v{զg޻UJ}!o84+w`a5=ȓOsc 0<nD]dC ɏY~D^IѴ^SR[ LviZIT5Ȟ]>ʊ~V޽~Q|jjOCƒVʸHrKnR}#bc~s慯I㶱- yz|RM/Q~{|W Oxwȣ2?>OLҷKWI\}˭KOϴoч.0+¹szPR[>ݺ^xIW{vlBn E/vbZSk]{;׺:+')VRaI Mwtz7ɕ"gYeS\<3c{1| 0Ƹt:[5G7ܧ'g-k5tҨ$47ܘ׾7~lw|9pJ؆̿ڻ$>>N]zrdudZf="_?yrUJEJ7B*w`RVA~!DQW*޾;Z܍⃆7k`ίӕdb˃) -[~d4;أԵ"S#jz3ꈁԭ3Εtr $ꮯFlrk|j.ؒxɤ)g3*ttJ!b..SwKqfOmhC 9ZVR y/Cѧ:3)۹"F9F.9xx4^em#PQѻtOutvRLJ<Zw?[V˽*e-8dP_Kqk1 ^vǃ7UG 96r fOοy暖#=2ϴ[hpa3*Ϙvm%'FgɋĈu=q$>2ȟc;/JiS~" _֘qaǶ!Ē>fE̶ˮOg-Uk]מmaŠs)kV/y{jk 7~dfnw-=w{ww1K1)KYwWEryôYۮ3.ڲS!oZԴ)m!8;͞5{yGWdy^᥉+K(LI˵)my=Q4mwΑM(ǯ+дD*ap%3RO2ʗ:wsN_{n}L5<e]?̍'|?:H./?pt䫷n550ʖ'R߶},Ĝ;Ôn{Fz=BBF_o޿wqו / wb\+Beh c݋1#woUAXcp} `ys1]=y5(425ЅOA/:{ҽ&Z/L}86EY! :>`Ȧ%-i8cP^\ÃlC~;6񷛇Wvk{IÛW$Y]Wv;3+}#mV-9i1I..ݸ}i_ӆ/ q/L9I``A,Lz3oyQh;iplRvl@\`sRnk7@dpbk4?VHXZh6g߼4qKn\&O yekR|'hѽFm}O1\COeyXbLJ jKWP@(_FIf IDATo;ϭretz$z>p.[5ej] ל?{x| Ŷ%XUd&ވb5r5(*kf+}=&nBDby8OkˢOy0< LtXp`:;.Ecv|wћyd-Y;(f=VqCڿ#XY^#A͙FE>? bX3` g֗=qJ@iޮ:mzys<6M.Uv"0#2 e|ZUڼRMY1 0ŧ?hh+Ww##KKN=Ku|A<&j֨ طe+G,M&xMUZrL@S^K?wL2#@$!MM0=vN1 ' 4q{FZGV֝}Gףw ?V$|P &;;'/Xp¢|C&_y]&hT<  km>HB7lVp%;kdPy̰L 0-f>y֡k5UZz3[3S뜊0ϠX:~N%ogC `MM!]響Yx$#)<ٕ'99I1o]) /VΪ[X h֜, 2]t!˝wL!e!XU(mʧ:{ׯ^z p޷YwbE32Յ/͹Kr4\eW ZYba9 ^ɝ37^p"dĥ}>>SSr#>ҡS3 fcV蹃Z"ŋdžGMf}_\*8DX"rV_w-]x}ajDS4ΐx~\ƥVEjay/x|t6kLy(ܡ]srRdu.K!(ִk(RME[܋BH``Ç m;I1$Tnf#PvۻvL#l:sM IF7ij5F1%Pa}`\;θx` \>uYK˷Ig!~-ĜD`KW<CZ1%ui5jưAl[_"9W$_kggD}!!&vнx, VλFv-'.*W1 S) $w5guBͼ[nsG1Z&-f1fĔ &}RΘc~?zA$Nܵ\6ܑ¶2y#Rg92ȘͲSzz?tvȡ<Ϫ|1yC{ZiIC6`VT_.Kfk6u{ ȋY`1Z>K#'Dd rWUc*rV ;vqwTDbj0i$GUwW^L*M mM$ahR[^,+OFnk~-z/X]h7yZU<1 \B3LVlNNqDKNx0856iPj'Ѩτ3w툰_8Wm_ukܚ|yE^Om/(JMY<ȕ?us/ERhX1ק4MN^27?&BkZR /Vϛxnx ۝7qRûwBXZcE]*8!Ҝ.]Ԣ@-1w)[o%,[2kfPʊzLesmHʎYfO=֒m\Y6 #gv|ܔEM99@{s>bFyAΧ9=3(F:f?~.W8z5'T`[5'~..=6 @J9U}*Ɣ:t.V#,PrҒQj9RWl%'ӀH]&;L&ի/.]z„ r`u^ƋK;nOL|R`Ob?NvL |;Lv{P(6mj2B۷M&,tVc^cǎ;v|rŰaW܋ׯ_R%łj|Һ.۱cǎ;ocΧ[w'C?sWI2p+cyc;vرcǎ;v>wQ%$Q!MTΎJ,@H@9a bv8#L*Z-z-ah@XBy%lޚcEÝQ2ST )|.؇"H999OpsgQ{v>_l,''HR0<RSst@n^`V$$ϓDwmա(p_ON5 k}ݥsl#*h. em?t/:Hvբ#Eȸ&oN/6vnLvن_iQv줯K(ӑe+2;ٲo`]EON)RF`Lcc"5J|ŗҷי_,__~%%았#sB?89ρ }.!k9M\2nzQEK]:i#D!hSۤھgRZ_Eri_"K zԼڞ&,%IBiNQO*B&I+Vn+cǎ;vرo(3I1wwx^LJNiEC˔i^tցRFP} /([M][O5ӵ+J/oGŕkzw;Yw˷'gZyo+f/]ZBcҞf/gwtRz^(|BޞٖVqű9Tzbwz 8rO۷7o޼F6/OgHgI}yhyҎ@0>Ď"Jdi/0pg. {vyv Exx5 !t^q٬Nbo/* |olxVh:qWZ,FZS_!*[eG]z8XbeM<'Bl4aB xȆ vOݲ9ϰ/`Lqn_DW(!'"x sNjM:~(g9M< ȚI+;KPbx&+]O[a"z+WR5=&,6d>\wP|jLD\j6OKhњbReUD`S^[2%$ ̄XVwQEN^ T*Ղ5lT&ӝ+9Ui/N&_f1&(T(QQ(H!h/OfЂ&r> ?Bؐ #^eO{)9E&n\/dzu&(UQ[FZRexg8+09nc=uNlZ!;GF*wy,}N1YKo"TJDQִ,ٻpHJNvzk76񅉑CvBpR $j8[gm>_kOMGgG 1VTnnn:BՎsϘ뽈',>fK$0sMAV椛;^J <ˁck.:gf No.:F6(%S=<ك[֯{cD)ZNV0B\UNcL+ZV#sOKNșo0$4q؄GNvhRtZ,;!Q:N!@rVJH,bڿtIPM~ $(J4*9!H*e2VU+(lKr SFi5 7 Fj=d_.D^9ssADr$ Vi5ĴTtZ\+j4r%Pi:V)c%*BhT/9Zz'yO-?RKNөRޮSV"vi5*z 2l/Qxfޠ6% @S(†?>gd(ZNQI0,jd:V J0y))uc@pa߯hbOnmRػg=]2uo0aIV& 釧1`^7%O^X؉5 k{v`O(D3BďK7~ɣkLY`}ߎ||y5zN4\8v‰V1g̶ 3K%E^k 0JJbXTku:Z;!JnjR#yyV5 RH0 NըmS'-SeRJi?p̬+u:V#}]:}W4#͟DtZ)4-זh`HHHppXB_&Ԗ vA³_WOX+042#%qDUJMȫ}\%GVPI:@HYL[ V+"[_St!J}yҩ@¸5]kF͚"/oL\DDZQ_\5g9L>2rmˋ &N[7A@ [.3yI.Y)ѕ>ב\}.8c0Ʒo߾xݻwy/U |K0g|G\)ɈP|s-b-e=M~Ơrb]o޾tDY0ozdvfwc,'sVtj0{Ųu _pNfUF pߢ4h|TA(J6۷7z~4HF7kPz~j e^U5;Oؘ`Y};Lr'ͼJb$40l`Yj(͝j]FM4>lUk5]qf,]Oǭ1{'Vp+:4]J#Biܩ+:4ݤ[ujTÀ4XtsVRKKTaߟHIpހ >UfhAxiJ5c@Z&tXv´$_ncJ|y){g=b)^kT9}}#>=wq&8oӭM{Áe\㏔u+ ""a?~?f 9548uekI?{#0Gԫ12v^GJr;Uwj1]DܺxWyo`¼Bi9pP~ݳKMS~Ujw+uPI1l]l?a*=og.r挱 j^#Ǝ m,>6ڽK4dhlG.h}h^7㡅~H L4d_:Vj ;kb %UWiڽ7׳~e3zeƘ&vAY' o)Syҹ3,%ebkӤV*-1aKzТAmjT"5ݫ x5%ބ)!L @V}7t 鶤@93ˁ0[gvw@xj>PBBHӇij5Ƹb^$y IDATٚfN^q)$#cMNZ: Ik'gQ䵽Ʉm/ 473#iFd4:3[|/j fC2v5FMKIGm?ީK{=q|~{# ŔǏgSazVV@Y.uq5e39& YQ 7Yk~y1yDSٔIG<ɵU4vΌ6vS+ yȸ1[,^z4i3%^4.ї?仝w Erð((KxO)Qҷdqk=*ty ~zZiy]<A<ʘ]8x*Y¥aQNhZ 1%{FղgSMKFwׅ[Ld5bd8>F7vl~fRwq%Wl@e9`9ǮC<=7^pnŹU܉3/Vsk?ϓїVT7WZRjȖ\휷е4+GH.]exE&%kSs6_2|vlHuqp1T:^SԾts" ;.h`=GSLdr%K: ljuNe|; En?ƴrt* JвJhF>9Y{NOYwj KcZi 9%׌"[&) |nRJqGS__n/K1(z~ምW;NDDYj^) +: QA"~S"z`լuYlI D%^ZJm<2ʽiӤ19-]UٜboJe^UEQ4 jɥ%c)Ԡ`lZ*VKʖ`1 A0[ (G7 x/ɔ@Μ;I%g5r ͚s|KEQr\V-b+9S0jr~˘_~ĘVj%r$]_sY) -E1D ˹r^g])6fdYS F͛w([SToi~/qCzSݴ; חؼr h!+-C/BqL`)C1"PKN˦8`zKaDt+f$7|ibƕެUMl+O{s?N"֨(L|ViތV3sl%(I՝)Fi/:6w|5"*~FC[\Jd9"!BR[U`_ ˌ}tէOuG[DYp#gs'2t'[OxSzb o}rK!.;y<'XZaFe(NVk2)ł*/9HB TwZVC3`Fb&eh%s8U佗JY>/D,ϊ9!x1#i" .)G‰2~l ~ZW쨒Oܛ+jFkFS0@`,D1OswWAdtx[_❓%qn2X4^PDuNu\@g;o=o KJPj,18E?}D"PLZ|܀F{-ܪS@2%L&4ڿ )SGzhے^6\0%Jh%#dpA٬zCz؜!VK(E\iƙ$ )),y wQ iAE(4I(#  "jj2|2&(AE`Qc$*P`|BghUHoHmX~p&zmƄoZl'z߼NIqX\4l@6 H IX PzzzlllBBBZZ': ?'ȡ,)/].ܺ=9c1[9xmԢPDe%ExE(r< gtZ5.\wY.+9KU}X;y+Gڝ)y|޻IY)Ks cYx3}=X 9L4htѺN9W"&*XNOIaEQVDž'?X~_I2|[.[0bC!(wG]ƚc_B*ͤ\֜DR !g'PE{/?WdѦx_`Kz!c9S=Ύ Kbc_Ƅ_A(Ճ[Y /ҲZ",H!#Z ~Ew @@p&|;oÇ4[#|A[2ת ^|K4߼fVA YvNV&KoBȱ,˲,.ƨ,uЕ]oe̩ 1wbg7l)#āWuj6yFliUܶxk̴{E.m>&'7i=TX8>"BB_:)c)<$EMz'S~|acê_d%Δœ.9eHs7.㣝8r@RrSg1+.&JfţIJFU۸nL ĜW i/^Jj('qDݿRqDOU_Sk}:]iPw|nf/cbRePiu\9'~M"r 5D?@PL RJgmKtA!Fՙ5Mc^dԤp+AUjhBT8Oשs]@*hVA0ѝOTGܛfȹJQ nՈW;0Add(*Ύ^?nުAe̬7vXFsiǐ!ӆ;k5'6%][ o7gwV; (t躾w?u+9lGޔ4osQ9Jg-;4\jߔePZk?"6b0Dۍ]DEP;L3NR$ʦb.>qmTN&ϨХgh̛޺ݣ=Z4ZG[8ֈersHddZ%eNUN\9ŕ7qne0藩lܶbʽܛPDH0p r̢ŀG!pMI6*#z׬\Ge=PzM6gzJc,r̠:w?(Bk{VZv#wnT*ۓI|𡿿tiel~'Rt$ozznǒ%fw( Z7>X&BtI0o h4IVDx #R$R$le4b (Y^D kbVk " JW*ϲTi XEX F J"D3օu:5J#\T I(`6q2Fj\^ƈTk`Y#BQ Y@P*FE Pd3T_h>/b4E!XYPtk:5M`E$ +h4jaYLiD$)(5 2A$V$J2bc__-0G$7e$ʀ\lm͉j}gWu̦6nSQ,&IeU %AJE[,M`Id h&`EE M_o?yA!H&?zS3R XEQJ|.>^|M6 )/ӭMj*(6n_"$=~u(SʃQ0A wg(~׮*FQ #bRy!l<[ttsѪwro\doY1+3@o_>yKtB°q/bN5Bs %ReDbeD7#Ny(3EjԨTDG@r 2wo}*SR 0n\4NUUq.D0n8}CI+gNToY #JEc݆m>g'^<{!ҲV~R2c_Jo*֖An_"È68xV)eM r GUFy7vd_8kQsEUZ碁Hؓ;+F+ΤhªT`ux\lVf{┤g.'Z{(G[Ne9o^Y??̱ZGPQ>zذY[~Ft *,=He9ou)^#$XCv>"ح4a]qm7^{JT-Zzŧ)o + *}|+kFeIQTq6a>ś6:Jx]6*$#YڵY^ɹjC :?<뾕AESK@[_fOT:U8PboX U_J5|l\\Vٻ~^O8YWA.Cq_GG^<9ǂIE 5gMdllIg:ɲ2 gϮ޶)6|EjRLJU)[hA.p%/mPlf IDATÏCzy̺lfԉ'ڦ-)_Qm/@ktj{[kRt.m9 A+VY%%(B,cb:^UK^SyGlSƿ_sFLȸqgs~'lkǏ&ciٓ.yeTeRYA<{?&{ǫ$I@1rim/.u2kX*(zqu^`]k=KֻE+f.Z!}+ `!UJ7]~R$Ǣ9);q=:v<^9 ~qpށ&l.| ,+wQ]~{bXû{`2|Z~ W *T=i=? $0^ȗ3W2ekM|(c>RE* ]8}EcM_nvƯ/݈wE|o`y;2E]L^۫o>K/iY%o`X>Ggavis!ojLݐCWJ[{4T_pxG<ۻGd!AUJ(ܨ{Y{/ !νẸipTT/>%D=mbYLZ: ߻Tşf1=>s~~~ABl2^L999yI[lZG#$]`h/`UKL<QBX"\Ӿ¬ ZN9$mKS9Ċ +$c_FLՐg9 D@PM>Lx@;]`:~LRB\J=PT+̗/g͸cUovظ˴q?e^k*ѼY( FFc"NiIHY\5Pgr lӼϏ17th?sOYw.W\wyc~=N{hպMX1;1>4u7q#hF'y^auߪsVm\S%\CLV3TC;ʎee9M1qa ml$;`Q`$Z &SwٸVY݈ܪ_bK8~aWZ-;hsY< KbxlGM'w8֦HIWJMK|8vs#V,dsٷn3zMS=s"k2vci\TW}W/~`,ưBbam=G܄l?.m1]J@?@0gg_O=8Að;x#3 JbED+d܋an7 r"  \P6͙$FxelJ1bqS?E 2 J<Ә]5ǝ,],0 1b Vve^yw.~̝ELn:2jڭ~hïpHmgj AI%DH@ B+Z^4 刪5 D‹RZ8fY&ʁQOť99Tylo?aYF$I/#ONPx[i#~ "8uԐ]5/pjJ9!u9WlIѷ 74O:|=E6 N(wOTiGlJp{ǀ9,T;$`r5Mm[uxBޠ5dJ_Wn9Hީl~ zdg0ekbe|謴ǏLRt߲D /,*pgIywypjRa S:x! []Ѐ_6̍"*^5TȔssԂ*뢾SlI MUhAk7yI EQڀ'rT9x"kx"J8/Xr@Wؿ};ޕ]3l63dڥt>.yP>MM3#z|sJ_TWv w֕ rCK ?_1}ޫsK]>fG|v3iP&މײXֽuRAKmwlR|68q}"bY52"WN9n:o `w`ߪ1f,˻-~Ry6a%^X5wnRg/ei_PS~IG\;5-_k{`ļXO|sM뗰xs'rz],gM;KŶ~VC7Θ.VsVMΏM|gM O8Q2Dz(,r,9qv\)lϱ4gYNʌzm2^`BsĜAS--p_2dvċkǬKp Y鯒^eJ{훷jeLXUo?n4MLݺxi+fME}QsU*Ġ_IzKW\lO~;(HZx㤼  p0v%봣@dƜ,nX8`1Һè9&bBN_ǬY bY8/EcI?a:5V|sYp+dv*x<˒Ϩ:Y6i&/h~ٲqq;W-tխΝ:Ws+V9QvV|Z; w@(J0J 0\D8Κ^`_2CVʿ ܏`&n(+21h?~>| ?DЭiηk߃龹)Nvdɯ \N=慿hp}L-Z|B, &%%֖D'Ox{{ӵ|3+یv]쟮!66x;w˗*͙m$-or(`WA`ECTz:>7~b qg2};fhcn ,|%`»j-VQ3IV30;^}Xw&> fT ,X`oa¿KN ,X` ,)m ƙ?rS XBw$[/brCý@gs $-_  PhaI =33SQ/ps*IYYY2iA666R -{aI#}>B^Y߄E$- _EGbkU= _/ۻʱ rrr={9,X` ,|ڵk> Es WlB87=I1=p~ ,#n䤧N.g _sGRG ^+dd:)),)iи gd{fywR3XnV|^>w MaN4,*=ziuHfSOɐ [j{GZA)z>:Þ N(MEs"ED 'Ҽ=5A*LZQyϲ8W_'-տvL6o}A ۷;s96󠰒wfƵkz굫WU~g8wvbSQHfYV$ .qҵр0VGvn=v`iW#{"xdF$ɿe %|DԌ t{{{1" -® IDAT^tڵkofy:|ܸUVk=oS }T/ԾAD>KD2]8}իWߑm~aӸ/N̴IHՈ{#N_zk8tM#_㢦R;7=ٹ3$ͨjVkT4aQ UT +S{38ssKVo2S'DCzs (o'>5{ʩtDH"Ȉ',CTR;]>| <6"TNӨ1Za:VTi:& I:Ne:IJQGwKȼvxg&+iJ7/;ZZՍYXӪH04t:5ND}~Ӯ{FIxk7$D5:^QjU~"^hCV)n0?|(hRit iV4 @4䟃ޜf(@$VM77R9y3]\ٍnҫhm^#h10ZZhZBɽph9 gD\l$D;n$o)cLxӯH@JuZ5E ZU!`t:0BRo"("CsQRgm(i}.կI?(7n4jƢ>p⹁~ռً?_j ]~cYA;ATWewj7kmм[||{c7C:Z{~ˈRER7\ijּi/eV ۼl_G}lO{TRe(N ?0ƛThѻ_Z`o[t 9?$iJ1~=tp_LG6 rc laAdr\znMj`W^_~h78myh=muu2ͫzL;ۙf>lwiI" 1ܣs/)|iVӐ1i˽q#!sk7oi tǒH>DH/;GŧUwȉ|4zpC>Wk۔`k9\L ue$v7.|PA91BNiQ9yӗELߔ85}-KUyYӬ-xjt6#&ƻTco]zx>F7C=s__SOeڮը%]iggpgL;t7}w1V0&aG:SeZSO]K3vqҔYT>˜}~~¸FH7#X€7g ^V~o>[L\ʶ9o蛬ޘ$ 1]훾HYA*NUьJ,1JM("yVZŐE9^T4j$)$MHT™X#B I`y_ُ0!ХKJEZF Wi^+J}hQL=K7fܽWeFTʙ@XQxwNX,܏}QuunYYFHC$M9׹;._'pRF6=A7 +>oɬN>GtpAѱʕ*+HZ}˦o\L/ҢRkی{{B2#6Y{'񑕯_:vwSwߦ۬2\>I2^k uL͏+Us3=f^̩%tl_8XMC!nU .VRP7b]&7_ʍ=eWȕ'^Ĥf42$azĪ5lY-McvMz MF߬Z:Io/,-뇎Wt*ռm욶o;!U,gH=9-m]k̚22'L;hg,i/FE=bGg=b龬]5)ӵ3UP({ٹ-f\\\NcbԲ^gg/w56;ګi" >VoPv'?-g?[.Zubu-!u`Tgi$ Ip'w{HP܋ qI)-}{oolJ@AO" ҹٻ7٘ۗmȯH@.?e/a+ٸu]4 9lST7KU? GЋ}x#ZL"7Ejf%_\XIzRj 37IVlJ[^J!>\;t~I)֥bqwFy~2bҍ5rʆS{iF.UjwB˷NU9۫(q [O,yLƒ{蒯bvUO vyXL֣m9e7} |7/Wt{O|S{fmOTյRB(e54<%Yb(dw+VqlgXAּ[dw\pn`XYB#:`\e"Kv]</ޭs]| &$xtlp@OVDm5 i /\e3g_Y- yr% ?X,H+H͠m:gׯLƈek#(DonK(|"S%JD@TڙwYG)v -l"B@$Ȕ!qZB YPQ$xD$#LYρ9" #L\C‰2X - )h =(zi]Y.jW;q? @Hgu? 2lFEd9\lrܧEc7uкڝ`Š?ݵ}9'?K4aa HӺcNV`voG_ZEvqLL̵kbbbdY'E &_w"o6)L")W|fIm#`uQ@d5 (L*ɢO5h1M1S5jݹzsˇ[W.i)R?GԺ 81? GIW?UP;veJ7S|ju>d^2An{AjTeHJV46" !Z Bd"J$O*eқs Sap;c)qW;*26Vvp6>;q" n-x/ ³IGc%-NKN{j(cG7O>rUrj\cwg V3XF14'/#.w(D)!ivI&_jʕfzD-/Ӱ~cBDТ:k3[G}<^]JꇅDAzɚ5tV~EEˎLH;/ׄUT;VĜ޶;PlkX+7?q+^W|zxՖ"c=w×I)/ﭞH]1Nz̆0} _Fݳ^w1SQ7c] AxfIx|\ ~/ZM$ި7$\cLqWxآNdޘʥM\:|\Q+E)%6Y"qQK ^43y \ڱ;B/]=jo`&wӒҒYdaO-hgԍ$ޤOz˛mҝϬBGZ!JUjeFYJN9u1)ćv_SnNȮeF9ɲr|m:kńgR $J Z fЬo$2qqG(;mU8|䴝#qqR)^}S55 Є !'N8x^Gy{{iIuobU*cUكUOR2wmmTwY1zϣZ~p4wi e:ưۮNnk>dOwڪMYft\Wзw|KmDUɹu8'b*-BJ((g3+u)^咀CjcN= Λ*I`FٹZ£{מxoOLkLARJ$R4޴)ú5Zhcj[ǭ6"+\ZdBSE!S~sr+f-9r c:2MKa$"Z&3EG0uJeoD\')a]Q)_45 ]5 UwR~O'MPoK\ oYDHVV)ty.[U+)Q@J@=KÎ ]Nw\|{$SHUFٷI5#Uhc@eYV* q{S(L1{6O@T7&ؾf#[ LxM"ߠm[ גz5umkL/,R~+LڶZ:5rcvmٱ9osƛDUh)t;@pzݧo"۹~ӓ,xc[mkS`~1gtSĘbuS$md/ĊZ=A]V!)ǩ5ZY~ԩ_ݣK5e({Wl9L,wviI[}C[̩scL_UziTzhKVҏP&WrªIȚ&2bB귆`*aل^^i\-,oUL{) 6Uq~i\b{6+:EqţF7Y\cA)ZgtAʉ26*H,JzMP[=tSi8i*nNn]}NɮRH|qNûEp v+?r\S19uէ6yډdFKԭ {B›qի?~lON)RHvP/_Ø(VQHVH+2g1E_gJЈ9qubg"fIf.ı-YkPJbi D9T K!IY,DIx (F$MDi5 "PRT`[L!j e2E:iԒUoj%Qfjdll  [ )-đRj4[D$5dd@ FPgwo4LjQ*HjEQUi &(H䬼$cZV+)D$jIjD)JY8ӬJ1"h5[DhXdr'GFFEQJd( @DfQ*op2Q+,r| IDATh QUMF*9K\ڏ"#l1@V]uBqkX'g3Z8:::w܄D .Dl'1Zg7xB*YxBS5Ka" f81fRE2j sakwo!> ?DGnkC7Qj'ׄww7?j˗P6 G̿ H:,|I$NGQ:[N>gTdr(8ݿkuY;n+g`l_o] 8p?<+6ns^o28?E||Ldɑa=22굻V6}?6Q_6]O&$G=Mud )QS?GwcзqO1Sdɐ,\?9~E\Vn!tqR?;W{ި\e/ĺë\KZN"Ͽ5[IgWd*9~Ȋ"Ѩ(&P.%|ZSkm-y]wG!GҨݼuʵ#ZI4_2bTEyCt#E_ڵzTd ,ӹg EMsU]dA0?߲J6u"3e?Wo|^Ot+F% y*qԆ=i+2'/)r}A7k7Ce63W2:oA%:9(oힱZSkxqhD(VQbV?ڞ {/7IU[w)4'R,^~7ڮw~-'.=u[K熮ybɚ<-E2K9iћF0h8p2귶Ʈ M?/vN?uz+8,oj[-s~}.ϧ,gkQ|b|^,=t㹨v+VaJ]`Ki>EȰNܨeAVYfz*U3gV_jsw_um=9 |wٳW>=*C'n=# J_S\x> CCʷEtG;¿̴).u}gc,GAsz;gM*=u TÎ@xV׶[Gڂx$\8;6.v=W׸)N K:藞={v֭dv ,K}|uYWp뷂A .Ks y^={\6${iV;:UAZV$Xqn4Jd07*!Revƍ{W^]Re*">}V3w 0zÆ)7G=~VĜأ ꦹd|߯ٓ?BoI8нvra߬~'*Թ|w/=c'UxPݓ'I:pڴUrjzQmpn= Gʍܿx\i`o7%ӝH{<3dHY-OO&ˠ}|;N3X%gevD-vSΑ@Z$'T [5^X.>lidLCŴoP7!;<ڒ:Ƥ~? rҹIu&- 9cc}Ȣ&ߕXG2_VܹZ,kȯy{hnzD( 䤈 Iq%W$kxSvP<^֮!ٸjzC>d֣{oww:Z)mlX;vn- =b4s"^lSgd|PQ;;+)eH64;hB E -҆VKҐbQ:7u\ \ Wsmr傩gI"FP}G.D2ox$!08@/}(*=+SԻ/Dn@n:QD o9lqU1B2O0FaȠp{4s . 6mQ~3 Bij@!phIJk-fj".~~>9X !^Ejezv,N>/ؠ^&6$VE/6,:(qZ5lE%ZlX37;ciœNC JWٰ5[gx}+ Z_EH:!eT`uwv:M䄈}VZbsG4o 卉7E0otZb8g*dY:rdd -2% /=CKȆ_J_s/R3"S.9g؊nPJdr{}ŌC )55P{JOVHM˭'[ FwMTCS h%-?,DY7 f+ߠ}<~e bX_llKtuP=RAo߆I.ްY9l=?~&!IulȱSG2ioX=w9lÒmD=mI߲`DDѣGϝ;.?Z h;xb9zsbNPB7TmH7 f7$ & 76I'MONQVΈ-\-lӺ?F ;oy{|]rmf +I 2^ʑ/NJ)趣υ w{ף G\MCa DZSyyxDŽy6n\%_dBguk7ao^r"R,9mU81wzE>!J8TRd4&Q3ǜĚwB{1")o,Y_lwy/^`Ƹ#,jVhҬ3a@Mq1'vm{f*og'G)9G,v;B)1%59111hx"jZ'6KG~r7 :bgyj>l\ 9?gEgmd"L]x^jة-^hba Y: yp2ش\ѯkTfC$$$8Q+Zc_@2IfA$ħZDTdIӁ{"/mpdK"}kXFu]$$<>zPZOgMݛ#E%IO.Vh=aK|ﶫR]33-2{;C%IoLILLLJ͹aҲ'&yf@iVPLڐ)_6OCJظtSsrC0T:b%\>oTK#OScHRsʑ+/04tB)b{W$ߵe%Z-& 'ʄbNlIo^Iҧj^$j},F[LVB+TZDZ̜ #L5jB VE#$l*#~-$IڳgϞ={2̙|MLӵvk)3g e@xx{5tӐO #yڣёr#6SJ<,$u'{DNֺ4<@S$ bN w_NN5Wj3u:ZH(7:gziYd 2b&Y,7PΧsj6 &ޫ`8a]"צ_jpStZJΜ}NK ha w(wunSwҲ#ƍ[eX~zȦ9;-{?/'%);?Ru`En*t-֣KWи{2 딣EͪfYg-$5|X v,1Qg.Æ&o*fۄ>kkn 4uvtS1~oZ[rVRO6ClOHXnin/3]}ݢf6+"RNتSXgӨy`yYz!Ӑ_[T4vVh 9:v/9p)nv_Ŏ m\*8aF~qvsoᩢq ]f s[s ;wИ#gd Ռ[ K!k-mûUV\o߻֐I5*u.B7%ȸ8wo@i.lY.+.p ާA jyȠ5ƻ=}naZΞ<(J(ɒ,aQ,9:m"V~-zu~ԋmqlwxėBͻTQ=Yb+yuUy~h]YfU5ܳUK2~JĽLB ; c&5%WS[^ssrz9iZh%D;7_B=[A6hѢ /{2evʲ神|}ܽ{C~G=ldna4c;!x_OupS$_yqP/|{k"::: smxB<ϛa$yyK|m IDATyX"2D<$Z`%T 9r*re n n ZXOl_o*JK:CCn;bRuZU*h?ǍϞѴi^w#\g2JzٮT&FVwaݏ=B.\qYOJZ]Ė9v8p}qwAE T* E|$?|]BD(d|ziI6B!+Q;eoy`;+, u6O0}c6nq+ƈbp|l\8W`BE 6'<8f̍Ɛ#*{1B>>>} 8p_:0s1 !n/_' iqb_@E0ӴVøsN?o%A#!OF({SKڼ'@x/#1lI$cr'< T6Pll̙ÇhUTXfHRL0Mg6 =3Ё8pDD L&_v_G<{Iڸ}Nd!9!G'.:qۢHIIf/nY,#ykj̚.0 m=Z5dDܼd񂛗CD$/Gv\yὧ^Iw̏?H.?߳n+g`Y" BEk ț?o$IE d?_2z߽%ºT8J~m_ '<+VP 7kЌ9iS=ny M++)D)ըJ]y<**xo\yCj$;{^B~E6ʣs ߷$/.W!LQj(JzIz;C|.L&<~0tCYͩE%qJJߟ~7*CQg/dY&J(*O9[ e! ˒$F@$0QҮ QBY PZ@dYdD A$QLQ!Q_B$~v;B(AxVE1EIsa7ܵڿ?n8222$$!~ݞ}AAAc;we:v KVYlj^T)/?'ɓϞSd}ǡYL?8M2]!RU&!(~d)$ gB)))Ϟ=+Tx8p8;ޱ%I*Wx!ޞ6jHJJ,Vt}-[<~811Q[ֿNE+ M?_Q kBg80S,P(tz´ iaTP )_EtZQ*ʅWYf|?fiޝ*zS߀isLtj ގ|=,|e#$D^``K ׯp64&,"JEaJ({\4̸(FC?ŒMWW/Xi(G]?&1r|}(0v-Jһf{;\LD_Ad2P~C7_ #H1=1Cp>bG4JʌWɲSw%G<[gH__;s? 2.Z!2vMyBs7%ܺu+::ۻDP,zkR[yOf=.7|JN|˵Jm~qpF9foX\k93-\ز|́66OZ={7‚i>x)VDf3'p!esƗ|a~dl`ƥsP˥Sg*O^E_+i^}=/-jP>P#I1W6c }dž VNp;qgPö^ؒ~!gŊK-ЂRP4$Ȁ ΙST qcԂ7zĦS:zP,tmˤ{np|"(ٕUę-RT F#6La*DdjJ2skAX;ۡWO˧eJSv6 'HaVRdIYmUkY)4p *$X6 ZT4 aYIh?;bɵc7{i>u6)Y$Z9^&@1JHLffd7&?zb0*dZͦ+ BWn߈PoMwIB7կU'<}̄)!)J̀`J I+N,,&+҈̡4DyZh޸c\l~1H6UE+JJa{}?ϛ7bEӷڬk6i+C"Ev̛l/Glat =!]HA-\c 2wӒFN02y̨f/hzS6ן8m}O^}`F.Cfp/F(o_JGF ‰2´JȜʋ2´ÛQQUJj% '%歜@T I#%qJb]plˠ]+T 0Z2٬0T2/# %?>CEW I-5$Ŋse)ˮ}ϙT!*mElVNHW "sV+/Ɉbh0yaB*?::ŋ~~~]ۿsDַ^ƹoP*(>o R1gBTѷ\Ҝ;_yV\dP-Y}&Ջq6b/MäP9 'hLVYY"=}\2{=7+X{Ec%Ec&{Dvwm~ jb,_73޼\\]޹e't?UU{ﻕ%|r15"k`)I-z';W9+G?\8L䧛7 ['}oCJU `~wi6VD_w )L@>ʷҥ(7բuX\*V3dq3;5a+%\̅ k:^oNtڜI[jh@2 7y(^s66:U^;C1Ɲ1Q5Ok׻˱.qc޷ʱ'Iq* Sb!KnoyV!kuiCͽzxփDžz[K?pM pG}l?xZϯzs;'馬̓Wfw${eKjuI>9<ˊȧOY|A-j)nҪO~ſ+ !ؿk#aڍW<yNS|tѽ}߯M&ϵg]Zæ2 Q7jO:nYcqM~#7DҨu1 STpT?NT1Jxj/u_xxkN9,$cfߚ򾁀MX`}Jɰn2-U_tc HvgWZDix/{qE' c7TL@_W}PE9Zeogs>?˲]ܦר!!QpB0JuJ̛ayAbʭ1tz)7ZDVv 8R)U44hdϔ -zԨרSNUkXaI-kDRlq~uX%h났, (AA"]ǀ1lj_/ cquzU@4tQ\LaQNjKn^Go*.ܦ*?+!;l`'ȴiL{ `JfD]4fjszE ҲCd/BБw0S#6wYZyaݜv*Jςg`g[>gwԟԴQ-93Bɻ Kj+[1~aX61 3Pe_IXƦUeݕ#rf\Ӱ+0TegMp jJ!D )ɒ~*s.]t=} LZoPtGNU/:5?_%N DD$ߌ{ս ">9_eMwo5(x#jD PfB \n@hUOv7T]܊կ.|Fx=UuY"a҄ +./qaε!7U7FTB"U0@tDZmooADANcګ}hA6LH$ʢ -q)>lIi6 t Q(@x}|.+rY|^;f̸SRyCR40Ps+t/3D96|[W._EUW94EOiwcs)TTŶqBJeM) f\ɗ`5L]Y3[YՒ1 i3ie))LbS );  Xap:#DJBeU!O~F5K]#L:m="h )B@hL xqZ  ;tȁP :MLa`W!=qM[a#wݽ-+{!] Tٜ۴% Tv:PM"ɁG $QZ-':w+2ɝ4׎ߺ6 h'5CSE([:BڪQ4yZi0m6ϴ)~?YpdD iݎJc6UE&%& B"Di924+f-!VUy4J1FSBej|>#ah0}NNNB \ILr‰S,x^Dԃr>+[GW`) F%*ERYP>CCRH$/KBy5"B 8"Eωϕ5ZD("@)0-vuj6N$h9-*3[;; P}$Awnu[!iQW:Ү: g9W 3c D7ڝ}ݻwsݿ_ݩjkǝ6*ۧU5++~j?CU][< 6'1!Y$gs}VBB3&fN؋m8pyr/[GM&_䪌hZ>t)ɱ;6+|΂qPMz٦S))mΆ _gھѪ !\`FT:y׶v C oآג򂳛vS'䆈]|,%95JB%UHO8"kْkhm^Q@j=nwұm͢vzzz?Ȍg}wsNJ G_$G_w)ֻ^E{e%aFPHC#_(`Z鹅 B.胢'L77vPѓoMh֢m!AQaHkþ$%%εkRfZո4Ce< OMHHLHHHI~sa/rm.mKI{x/h*A͂KKk1Q (s54Q@>0e\ʩWW,tyyfn];LVݎF0rO&jU1sEuU_OKbV`r2 xNςw5n<8@^:!0I%s`Z}t d) O÷mޖh8p__?)'Ă&̋H+xrC ƞ 7MwQsS~37uP.l]& 0|טW,LȆyQ̚m9Ԓ(kœUo1sTODt%/;vOۙV6u_W|72G4 RC'n_ /Ėu Ӄ%~([j Dib#j/s\r;&X|K:ժB hRR#V-7;{NjnΈY{)`Л[hS{|B1oi*{]ڳbvv!gh bcZ?5ʥn!m,eXf]y1+~Z8|Tn5,MĻ6ukKf|kA4"fnWKrfܩEFkvEC ͼCfIct/td0J.m=VNt6 /J<1@fZ9tj^D YW4S3i;Jހ|Ǯ|ɪ5ȼv>uxn =[)A_(]HWM$ͧ# ptTQXi[Њ'XV7+=K6Ost-˻{N;wKD-9֦O,0FUݔ8ˌ}zQaxZ4k(!qf9a;g^D3b؆CuʯSMhu;V!frXW! 7j~ޜe)=gg˹dv{j ZvO犮jʠZnvŸcu qv͛1)h_}Weڒ=bku\2BhXE6"!Pss`xǯ]\z[i|ekYKo+;EnzKƌbl`鸗^D$n^zS忠vr4R׫auʽZ}-]*9"}ሩ_Ibbxa)vT zr& tIS04B5wD*!\'~B(NryPڵkwڕ_ގP(FP ")vOSӺCxp"fD k Tj L8^LK#Q9'(a(\Ζ/k)0%aPb H%"k L1X^@b$4F/yW?R'@Kd Pt1cYf$0h 8=/b0A!_;"jR CQQ9^ H$g P0 Q L3 <"#a Ќ'S `<ˊb(Ė7 #eGGG{yytIi),#  H( HhDD^ _uh Cc",˓Ic^yd$qh @X"`YxRYN1-`Qϖiy@@"#Ho(V#y3z+wjBaK*Q %7J"x=bp,'%%QDC"++KVR䕇cKBr kxJ] 2Uw7z [RF%"[:ϫ\D}po<_JO2aykB7L}5t$A 7[[Sy+ ‡-9c+k`_|c}?Xe_@JC*`4DJKM%]񯑏`^-?ԙnP*bc=c>ݕ ۢ4} (e@ͫCb(@)%Q$!1D^_%Xh"G BZӧOƝy]1o5bĈ#F|Y n})#߽pA ԧi:))yŊa7[5eÓCCƌB_tBPA/}ĵ,Jiפ]s/+Y~K/ X " 2:1pᅝ.'Q:tm*a?!!`(sF1bĈ#F۔33AhѢYaa!qvUVxr 2t: ټepĎ'{vc^4ƆoyQ]^޳u~=K7>OұSO۷ۍ~H'0|^Zwvҍc}H7$T|SS./rl`ew !Z(Q !E"\Dh};.-QX5iVݸ f3D&jiÌNU|\Q'E["2UR\/h?al5 %b5u ?Iq`A>sss˗Z655E25R.I4N5`FʅO!qgnn^333ZKa̬Rc$L^䠐+~^qjK.j2 "VT(qخܚg&NR67{lTYi) j"ED" D$AJ &G?/,gj\0Z ~C"|d fˠXܬEFǎ8h(Xq'^*,|N`$\ejkcC SMm]Ԕ-rT")H ed#I'y>U_.UVVփԩ#?vY1bĈ#F|ƔAegc Z%$ܸygmZJKD_W)h>+3RVJSٱڔ[6r"^ ;r21db2vmfud"PX}  ~zlmme2L&w.8Ѥ\sTXfu! 37֑n~ᇎ7j5]Ԥ\*x̚t5:R,سQga8k֕/E>_ߦȿ yv|[w[/£[aoN<ϫզ5| ڸjOczڌ lZ!pL;B̵;6kԢU:nrאロ Ȳvil!|:0ڵkGMKK{k뻇vs6ѡ U0[l5I{'4gYaFܤAxv oo-NkjOŧuX=p11Z>C:2:] y7תEF 5j%;t#fM.Q=Fm_ڵy{.#wJ~~w,Ԥq&-z_sF۟niz;Ͻ:p¶ _׹뤽W.?/*hEyә,0ĝF. okh^Yaι,3ŌGc#NVNQvZ\ƒĜV|<,փ&twb@ Ԍ<K2drL%" jeS6m][Z$SФw >^.j7ܾ`F>xrŭZԧW~%{@'W*˫2&F{VŪt^ıoc弅'hJ4:n \9.vn~m>|W{6/!^.Yv+pܸ|"zӇjT{}::Ѧf겖BoԢC |~a qHܮk31VqP9߾Y~6*`Ygܘư}"A=7{`H{079xp}'̣0p7& tfBx.Ngn8(KKX🿴:oU]fڔ':X͸QkRR+m뮾6Pk<#TQytgs{ٜ+%´I;'\CCCӏu٭@#{bא~NԁdϞ<)+y{BH_~o?hm*YA{Q0P-TAx)qt)(վNGYg aTCjB-w;a3`)|d3΀(FYf~#+ґc:V7>3F#CdL믶$w ƭ~-hL`OL oqvAӘ;HzϞ߮чl8H,^G\ w0R;ڰ+hd7] Q%YAúH^OMjz]vmLQ@X(K+SP1O?P"^$+B}blR6]疟튐zi ;bյ k.k( qg*.@3DBaj9ѧvf0(m?=H j ܤ{G6պ k>C-d کŊ2SQ W)B}oԶ"OTܩcs/N7]=uF+w;~VE4v\7&ܵsmn* `H1aY+/[^*{{L]DD?|Ko;{~>VԭSKZ>!H̙ 5b^8z:1ֽI 5g”YeYe\A %ȋS޲Z8M†c[>?Sv1܇LiW"E"wa$(qoWD}nImV/ Lph6/fp"`SZ(%"!и (|Y<~7bt..ٟ&]l6c~ ?wy+rb'οY(M 5+-Kfw|tM L@b^oH^XW \1''{;Sx?û[XI!|| /EW# W]mܾG*N*+Bei!;! LӺKiŖ!y#i(:MIԸ1̙;٘WZgӻ< `J8Zd݌ܫ`1F$( P7̞Zwꚾ5>\1Y,adC`,8 !5ӮˢaENx\ItbJ$݇ 8lCOq^b0[g)?0&ijz}Ⱥ=YJ "8ap@Vm"REvdy/IijM-PQ>-R# x +(8}m U[}R$/zYןvB0}iiWv˰ 4oO=7dLV,U{wSZ@*itfIUe=Ř0yщZ<ȁF>xQ'g߰^Tm9|헞?INرvML`6E{96{XMamاQQQ ;3c˳ԇmz|s00 Cܲ0Ɩ~~cb? =kSOePje#ɋf@`ܛ9-FOa)z'VkP5"_̌:a.m'o>wo͊6pТ _/9&/S^$j_۸;e߾ ^Y'W ̇1|Z*IqVpqZSv%MsIF>vRLF!R,)!+9-Hϯ نU%.S~TV +{n2i* ;O\ @IH2 lp˷"̟Z [<LCZxc󉫏ލkViܯv_Lm>]RARzHeKL|9YN;w<~Х͕Hʲ,R-LΝ8~bΡٜI$+9ٿ隵[og86kYֱA=ϛ6;{ J̭< xzrckZ@a& j<+ζ*1J.>z:IF`9ںJ],/|V=r񾫹G7Sy f5B=%Q9foݴ%-4 |Fe>}Ka322ߘ?bbb<==?v)ކSkE8CQ^=@~!C888DEEa}}}7oV9Er+#F1b0c'Qp/Je׮]?v)1bĈ#F|QCpiO5Z61bĈ#F1).pc<[Zr(- jJWYL`iELAF@޼X?Ku9W)e VJ)@ Qz/K(dj\(8c}aroHK>vA'[0#_!ew{,0.\XYRR!i H!P'[E[l91Vszߺ+sRK/bE w2j/=pFĥkZQ UuY+0t9w7H1m[,: \c]_HUEi4F#()/Z-KclFw>! RUPPPPP`ݍ|0GZp'D>fyIzX}il YefjW:pDA{ۧ0:ĎJ0D3BX^ #" "%EK l`y0%eqi7bkyp@ʹw64Y\ ( % Ea% %p#G J*ܼ%m&#z=WR "r,Nj 48_n۱B7ST=/gj(5c}IrhHl ?g|[Wq:wQk@JiRDT(զ7W ur6-;ĆLs~=L#7Mw/zdٮgwZZ%9KWgWY+25սGeݺs]@TVn+p""(n bVCiZnǫ.%g^6QMݧDвsZ16oJQyƛMUW򉳳߿ʑf60FFUħƘ,4b0F/B ABD#f!s/b FJ /+>I6yy: Mo#)8:t|yF , hӿ)Hظlbuw/qϘJ#<%콒1#QH(]Z *(23r0%ղNJ=kr, /GDg<86WJDY9mSn]'N,{C|e*Ś*'ZBl+Zo'yd*m>ZNbGRQϞ=C\.-,,<)L.a`꣤ r"BL8"Ih @fd CQH_wČL% lqX8҄ 8#4o )R#iJ%c5:o0*S -4hJH(r #^ wWK"ǶҮ[j F*I \Q2k_WB\.to  2W HMovj.\˥B -"tP)*DQPRCB IHruT@fg+fjuF Riհ%E|CDÛOX mAsj˗&aoMPIۍg K49Vc7iTik4,jD"ĬQqؤPh?P$-$tzeFY@P$$ZޤH*"ZQ܄P"E6i<nj <1dfA^k8 n>'E2)jGs+\Lbp|å/?Ό/+HDBS<!/>ԗcW[Su@-j khuz ;vq/,%6f2Yi6#pG༷xoUFalސ ⁠S k׬uE8k0y3.;w ecz(0\gch4ٲPo {wP^![ jC.I~~3g*I1‚ԃ)u=W$U%$$H`cG?)ȿO@)z E mGoЋv[@4ǧ!qOcdL>_ydF7ϟԷ ^snے>1Q]a~Gp5NҒ>޷l@܂dBvfx (dی\tj`JB%5z}D!Èh/^y\ڜ~Vvß3`M~rua]^e-ЊqCqhc^!Zc+?+5::Ygý,d;#Pѩ FSj'G$|n訨.q[0eN^*4jN M -qYiY|G;@F2:,,2:**CtOXЮdJ frٵ߬OՒTcd1$sGnjq'{RbCF*Bv'A ޼`}GOrd40l DJM@-::J" ;u{iʿؕa }eY3$ŖN^*Up4@EA]`CtppBPT%Ay093q|{h+rOͼ}$_N`#zȄ{S )r1V{6nzrj1Zp7/16y{v6ɒ?XMMʜj, WZiF՗fdьI+(eBX;E5:o!DW߷.9Fh5{u<;y-DiHOϪ5"scc9DIE)f*ҲX_Sk+ t@\.ܫ{V,'/1b`(ϭ'_@^tmjNֈjw L e5yK]ht vs0 Z̶b هksABEVrfI )\I]C`0!98B-Ԥ\KS*3f!:{OTWYp.>PxVzfޫ7l\n?]^?暱eEN#);F5% 0&͓H tf7ﻯ>HH_zm?=!×lkmL[}kG dՋ|f}zw͋]AԚiH92kS9P\m 븠>0hX@%u I9eLmF´* !Qۋ4iW -}½jx޽JkHWU ~>6"$7Ir ; wKqX*S39RhgFg,azc pU~zZ~Xx+(h9,0mkq"D/P*IvX_ m=z–D 74OD }yvjnA>.Vǀi)zu`Ss=m,HY]lm $ TCF3rD R1uLa$ K3Iuw*1)Sk]LN79Nۂ)`UFA*|;_Jɉ⫉V'u $ {"5|ٜ#h$I  lA h4;Gs5s~a]O;R^F5%W-`viV|}J/gf߸V0k40ΛϗzC]n >9Qxw5g.Xdf? !G5y ss!͏6w^+sNrbRA\Z9:htFר.j;^UQB_N/,˿9Sf% f uY<)&/ _ئ~Eǎdt lvF}v& iI#܍Ls3"97l3·d' /7^d(; ^s lZ4"D]BW3u6#N:Gkzּ)evkM _׿Ɔ#(p͆K>5 t5c\}.TRe5EnԞ+/APh+sJҳs0Q}̛[gxXG' rzj&a;0WYqpq, }Tġ~wMe ByrDk٢PS[59ȼlpO'M 5i)Иr![v񠨕3V*>٘Sx@!5dv݌犑yKZō;:`:{{K8M҅|z`%b ֯;hHn80q}8 mgB 玝v<=j޽Clc^ka81l%+/yJ>ߖ?z8K#FH(4$L;]\~MpjRrM)D0VǕ$UP 9RDkKlc: SxBFFVK#%aXZ1efYs(߈ w1v~}.ߘPkmԽ XWU5{/ x#I@\$4|V9ܐ딍՘c vs1:F u,T>j\MՎs9Hwgqo ]s|Hagnq@ 0soۊe LLf4856d,If/+ o(].@3I)hڟ-Hע-^Hu'^mBד">^\Fr@ $W< BwkJ8V=!T2 uS)XSQkKxy, 3V~=[_k?>~ߛA2HNfe7Σy:p@z@#MW»|bϡ:CED.E|C \XC m٥}6Pq}yΤ$Y'hAC$7$g7XtpyA" -,G?sY`2:x?'PS]`TA6\b^C7LXX,H1labMEN9S̰DZ0g(c; &nJMnz AfN lo_n@-%_ .x֭-|e[6D ~Pki.lIc(˩xx(1$.(iqi@ `hodVqmC9> :Ơc Wp@iLet,56'qx^wNm+8 F]ZYm$c VqSEQ$Ib D$I HܭDnWݬ0PDt\|)#´ L}ןo`@@1 9B܌g1 "%n5 il>s`c .X ^37FP&(͍; DlKzS-d s"}UB5AES6Gn7k:X򴥓ҏ'K&4r_$y V~}y5ms$A$"͝ ?dOܳR=ڄ|S" A@΃[sF K8}J2ՉB')CB@"uza%֥͝ѽ,x-_$&A> Q1OZ:[^]EDyA:(-'W{Pl{ 2ⷻ/])_*vҼXsGEQT^rmG4$cLrqCt{[V&)z+v3oXoG#N:}\Nrʯ]M9ǭvV=Vd*[&jc#T[x2i3xQKL}%V!%tdhnɟ~<#cea;(Ad}DǾޒs+'Ggy-o8SII$Y.pVy8t/;p>MeÚG^N{E7r4뗮Ew_,,e`+S/\F"L( ;yڴʹ#UlFz_.z_&jL{}ء׋n7"uYQk)ɉ\8MTEr;Z^rD\c !Et _ןX6zIlTͧ|wπ- VU7^nJ  sWH-uh}(=6[1E٪X#Ǐ\)P(Nvjc,6&^uBo@(˲{=~xQQQnnnffYǎ[ 6*+:"fŸ:^;s2fa?9O$_r8]XUPPmtx.,aDu_. zg4sg>/6&_)&XyxpYvUGoTt3}798=7eQ:.\LΩVw 49Y^sm ft!fz-*6QE~6B9pd.]_MDD:ZI;h0/TK4)ws={Rzת3z;8;6Kn\HHɗ:x;Y rpWt,\c#jS/]t 60ZgLYYYaDrnJɯ_1%NM[sYo䁣!;K6~a2 ( _<{!8 ؅X_WU't tc#LI<'ZFʕ6234]n#QpRJNCH W%IYoxL(QXun8d<.>BΠJw`N})ndjs_FܯPn=;܉/=wr&`2'XMy&4^)BUs\"8W㤀9G, 2JySj"$I@.!:'7//'޶s}ǿLݥC7 ^OSsgrc]QYYT*1009E`{)/q BW.]M+ҹzښ;s0_r ƙg\Be9 a_+dYF-ζZF=(q6g0lYoZ7i4W(:sYwFc[])0~Kcهrp;5mdCza̛5Oc{e2ygYO1xWu^ҷhG;u̢ю( 'XbhG;юvxJh7ܟ$LY Y S>?6d4ڎ'mǿ쟋W}XDH-awO$&lwﮨ iG;юvh?m ws MÂGH2T*ԓ<"HTL&z?3g|uVVVVYY_M#a<_skNÙoW}ӊn{ IpL_ec&'E/+omIZanܩ-Ha42=>Y&N~^ 3.;mkSf:wV3"`(--t+Ö\_+VLGrd]wsуzDlHmԈ>ַSOK\2_ku YpJ  76c^Ojc)m6XoR?75JtOi5P}@ᫌ-_+#iw?\%_YH+ЯSDXhl_O`snUw IIuڪlu;]#C#KW #Wޞ:"̓1ni?=0]l5Io&O|ijO@lm _YA܎M~װ?ޮ  a年 |f@NaaAqoz*^u6oZƄGv9^P="[V5ҙoeψCRDXWM}!口:OMlaG"eਹ7ޝݼC#]bjmo .ia1=.YDEJbZ̗޾UjU99'\\<ܝ,-nqඃ'tj* o̬ pyfrJ~+ClDl᭛laV-oq6ukTvRFPx[KyQXfwҨIa+_.M~ܖ4âI7ӫ$hlY_p3Cof-έy)5!tq!^u'For r"]FR:RE-+1aڵ(JR_Vƾ4S?f4O\1hV}^qPǾ5T mæ-vF6Paq*j2*E{OBF)K;r`=Z)ۧ/8_LP3s+۳mχ M/KR}c_(>9oިjfY]AR>\Z٤|^йow{ N^M`kޑpwvNJЛ{{&(Gy#m6AтV h@|n⩫e!竖@}Q҉¾`3gَ]򍭃򂆪sgjA#K}7ֽS9SLOuV^˫$zn. /CǧwnXݤ;<?{FHJN%h7W3.Jk䊪{wmSۅD7ר.S 6pQ`>OKĈ4mRAҵo[&YM~!tݟOUp@cNz-ZG*`wLvG{_9Րpd=GdHO?- _Ip:w|~f] X|Ppq{ Hs(w F9hJ&s[z-b;kA$^[i׼i%{ccxWҫ}OqH~zvU cL-j8{+WccI5)M~ssMa.dҌ|/{oIU1=9ʻn"jr}@Zh#mdܢ.&i}fm6;'-l6h+G/v+$E*]5NfƈuIV!;䵌:=a).݊?RT'r\e xg8V7Deo&'\ed6'gA2]@;sL˽ځ5?'S %Uzi7D?9O]-.o*C?t4Inb-h8koF3vIaZ&$ KK9 I$u"{s|zۖŜPspێ>T$~[z_koo{ )"h2)u܋\+9Rj]ܜ-Jɀ ss_~^,WR:w sQohzv &uxG N=,-dԣK& <`.߮T%W# vb܃{( IDAT%k{F)koe& wg-q0q{%VUS_ 5l‡G)['Q)SW^XN>9]߰dǛ5f'lFKY*4S!&10fLY;ʍi+ʫ㘺|[ ~=wq |"{psAY3O!YA^)r"7>ܛ0qK]o/<]j7ڄxHן:t5Jzj=,,FVzb)R:ْGN=~yDenfnu(u}CckC.Q,+|0ӳ";'2c [~Pw~BC~wJ̽8_MYyٛrW8jʖն?.DړvfqjP˶){I^^6p)쨥 _Z1r:nߚcbM#2L&&8tT4t /ҩC\=T_ D8 /vq:p:Q>fx7/zQ1v"es#\v '%*@,d2 lYy{rG sk7] @!O.u'CBLLI5 QdΛ u_>3wA'e?+l0kJ]so~DDȭLǍچc|h=xV` J k -SDwbp/xR`dw:j‡;v&Bc@[$ ;*N_QYdV廻:UTV8֦|G$W]EY,h 8ƍieDz-،u Kp}Cc OTB m^}4\F{ >(^*%.99x+p 7+;ޭQl)ܛg9 HK3lLԲQѯNg!H[0݂hwT״J@:eK. a>Y'N:^10[a yB9Xro&.ɭ3(_jN"c~4Rv H 7 ʻ$L'|Գ b"%')na]-%{|?u pº Nٕ{4k;Pav!JhH i7ڟ%cG?cԏ>1} ZXdUM/7A;yٞk>~vF{7wܵYqЪW?wkũnCv奏/2 bu7IfE'Sou 1'TW @XXKRO_{D0w 7;PK'N Uj}L> UMU5G\󪻾8X$ Ҽ:gfPSW1e*XVٓ'J 30ƔXƕ-N?-0D{)Ӗ喨m*rjK_IJLJ}%eeİߛ{{ߛG~5y n_PEtA$A \hW{4S > 9\0aG /k , 1ފ.@ÒI.1b.kB^1Zꯜ9x#QDWG2 !c"8 Jj!@2g >O-3`K;֮;^'m)A+c7+K9V&âwՔH`)?aDwmV\o=sޞaOErדk+}x۵\}okY@RE=I+l!gͺMy8[K ao :R<A_[hh 7 ],|Ĺ3r$` c?\pzxGeP'!o셅2;ZePGYVo>3LM=x[K򂺲 [U?[2SG]IRa#9Cv A9/rRnKSvΕ6 %U~% pV5Ec!$PwۻUH.Z?=ôG}qn'PWawE}rAc>o܏K=XX|?VI5G}+dl0GPi  $Q<XD3U_Pu|?&ے[qWNDOfg[CCsUldXJBQmk <-y&0AʳYDH4nܔa%rWd>%b*)Up1Op0}Cќ$BjX ?ghMfC o *nS{f $Ȧ(w_x$35ڱOFkg/Q+!ֈ=[|I\ݿEf=1E"!p;IQ(X[YR$ρ2GkZvwOZL!Z*,6`ұ@Rtmf4nB-{I"eQ4-$)w+>ZnD'OymAR13!QOޒM ^xἐnNiPPzcF8I=_2!}i^L=NM@{ P]nl JBMO$ܽo5Oź؉>tKqwl=RtZ"{Z644D몹h>ˬ6\Ú词S:hԟC `$<1z}QTٞl䧶~3Iannnn&Hs  s[[5WcU s- ֒Z\l9o7{Z˹|[[My 1+UGxZggq=8CEׯ$mK}$8T2"w3Vw- Wپ㺡`ۚ;,h9ònuz֟>|MqĥoM_]]}鼼/8"ϖՔ=(t{> miHՌ3뙯ko'f>!Qs跻V߭K[Ao}q7yB%7d*_5aA;ە^ rg.\Ow8]WpuƤ~Y7Õ4<^ШW6mї~~ҬQ8,b'[3m^bW篲z1!e:Ot62*IdŐ%62CɳץpG^_OvVLJ -u'9{ړ\Eځ %=i>Dq`gy}1ƀY\' ~næ9Ε\gtO>N"6I tߎ9*i&9Rn*^!ϬK C='X,ןܺU?x-_hx+SchK_ea6p6tM14*>u? xC;4ogT~и>]zynyoV*+}±F 7k&AKgu;elp[Z`6yWmN_MΗƎ _{`!!fީ$K( Ϸү]Ü1/Od=kU}zKC]Q2~Ơ>"g/48R(}w|pzSa[#٥wBkv!{zdl-e6V"@$r[[^A"iOoZ[UV6c>*g朳}ջd{$jL3I$\޴{s\PS .`lcろdYlKV2~l$!$,x~|ٝ3gveg3G?Z\3R|7KȴIg>ʯJi5"ϝ[ "ߟ[6FUgsQ9&o䊲$ ddbX[[븉S}ݷrʖ`08k,]t-{{{FFFkjLiz4I%[ZULW˽%7ΫbD[||Xڹv9n4;0K s6Da#gOt?dKRt"MN/0.\)JL{m,pgJ 3JԷGU}]9ť5o] e%;;{MM8ڛ/5WjFNI~&g.r̖aV6isƄk7hwq͹%ř䴢c^P2,KܟZXT?VJɭ(: 7L)(H(7>EiFԽg]US4M"r\DDZzy/{W|&s_Rڰ`R o05)mIháxNiUYnj 98h|Fi%9gp4<1kLII)KĠ#==/9'Yvyʂ+ot-pxjva:sL=*K 3i5Y^u_yyzc݁DRyy _=D0'ŝWSȓ2:+i9eecǎ.{f/p#UM; E@QqISrI'-J8=t"h<O^6r\yEYi=IYEU^"*~fP͝<~TFئnv_s묜_ũ xYwPB^p]; "9ǖx 5r,7Rq͜:>w2kQ"} ưbѸ/0-X=qZp{uȉ۵6e|(]=o}7,I ii/%'x"W]_^U"?3B?t<8=;ɑf'P8t)Y"j*ҳ3ҊN?g5+_$ń';,C})UyI%ei|μWc^J3ao__l-tHJz{"x͞_4mٳg'vpG0|NIi 8dCzo~PHjc9){#܏{ Ipn?ZdggSO̓) Bpw!`81 ?=t%IbPF^q-"x<#uh[KkT2Ƅ'wrWW=-GzAҊ=pCGmBٹ{_~^NZJjw{sˎް:1ֳ_ᮇ7J 2\nO :_/Έ.}.1r{|>mpFD\.ϫbNmo߀I*%HR߻>))tHIq mۉEW4#!lszǝNGK6]3T$U1L;wdt˛3!f/9EU?JGbjxݦ *~6%}޵ ;̑ǖysnkjDe(N1j GiYY-km Li, j&9pQ1&P8ʉGC-)%?79V3/haE,OHe"p\ecm G:"&-'wlL}W3URwsΞa魉ѽmY̛nx/o;" J0{׿iR )LD*Ԧ | t9wXk:QuGm#II}nYoX ǝwHF{tR]r߹y +5twr0q8~ޙ0bWWm*aR\hұgfP5yS9qo3%+]_Woܰ_./k1S֛sUB"ƻBCy; '3 IDATV2fLoCGgiR)8N,++)RM[K~?㶳5$7}.QŎU3bGb .KgE[%xd'lImY;|%"sDY_{Gڑ;1bٙI˟\^zW}UAbmG#qMM;{5ރ+G-;:։BwmHO9mbӡ#{HIIͪݽu;$"!],)$E's6"O1\MD$Q>grOUnЎEUhr%O?ӫWT/O,ItɏvO_xea7D1H:Vp~஥J+|BI<'5Q gMM9y˶N=‑vuN50+%MDM1näۃO*x wt#R<N]x5]LwgH޽g?FDԩS$cԷ"N*|`}OmI>*hL]]WͦeLK\/891aٗR[|zgrԿ.M3;noG›J}M%H/H)E)n3Oavpp`J6ul=VWʄNkmWog^IS/>VV2elF}uqy759ɧ#GI\{mEEE_K0;vkH$Zm3ժ˾rѮFMɮvۗrɅƬxˁmg],T}ͷOtwnlvoL1=/O5oM vDDgLZ&_U*EyN=Kܑo79)chܗ=Utn|Upr OL0([n޽Ǘ/MONͬolw잮z"E]2#2bSVq_4M﩮ki)6!"R<"Xəc+wm{!tc^i3>۸&o%l'ɗ >xp~nx\h8۷e]0mmmk=Gpg&X IG7qec+:/[6"xTV5gd7/_i=y95A_)D퇷Ym}ΰ FabƔbG y4n5>N1ޏ5{_ض# l{i:L|ŧ6 h Pw,s=zMQEל6ܲEUNlw~班lڷo]O.Xe };7X]6K}j5u5_ncw(Omڳ{_{7%-c: gS|Y/1Jѽ[{lDɎ;D**.ҝV*zwY!,p}*_J8GH+NfT)%9BF:R+krYZώa.ޣ^vΜ;o|ZOMvMΟ7& :B)?wq3m.9,,qN?gւ-wVpͺ;ʼnu0ʶ^8\yI娑Fn!I>Z])%.6DŽDaKgL)bsF{^EȠKWKXioͫEDL$E:uۻ*&ϴ=sS}h̝&9h >bKO$!5i+`H8V*XaGen~k٣W0'|oKz܁# uFDO9pef0HON"t@{쮜3YS3vRD$|9>_cwƥ(@y:n ZEtj=\$͝:Y/Y.)dN!/N“sW?m:ξv#u,+bC}bD"qY;XOqa.+;o=ߵ!3p#D,l[6ur5=zxgxܔj*3%L4g"rB]KGb›D8|E{zJ~9c=cEb Gqoל+i{CK QN8lL hL%hR{<CI1G K3qM Y<4pf%XR_*ݹ}э o%OHx wp= w&""OCDJ'ygO4'wøHN!P;Ä?Wߢ"3ʅ+ԿO ?ߤHSVA)|w!`@pw!`@pA{s:cڱLZ=^Yf`Ŭ9U>G5e,eι?ol:_\\)w~,=YuQ]+:5&9g204k ""%-rbk.8#i9d=cxϜl $Cg'Tc@yW.]\z/mkKo¼T$whpp6+2}7"s3i.uis.s S'Uewl)*1Qstybwzmw|3f/ ֮x6) G-&`L*El>weYA2dzhz+8+GtW^S^+`zʃw}7ߚMe3q!gD~|"MRZb_Q#_%Sԃ0  C;0  C;0  C;0  C;0  C;0  C;0  C;0  C;0  C;0030\.CW4ah}te܉>]ݥweێm >cum5YOWܕn>z_ qMF;65wGL"R҉E#p8w"RX7b# ‘iΖ#vv1Io/+;{CP8$D,k%)i{CᄕFzC)#FzCeBP,H9H7P kiF^{c;W.~t!/ܶ{_<8o~闏ls'~`?ƏG"Th{Mg|h6kبvKG|lO_~7x_!mpp6+5U>&_m,}nٖe ߝ{v޳?n=џ3]C?9 _2'K1 5)m_~ 1#:+oB_?7k3|7.W^o4۞Kqwz]:#b;oXY2ܕe+uo4"Ұ]KEtgoI1욅ovOr;x)&|@0&[ W,`\Nosg^paF*sJe%qϔ'6meL9v%) vuQ:&gYuDD$YMAv,na>%Q*`en0 PJI)Ƕٝ{vw-k}?_㱘HF._=1c6 J""bʉ49Sj2RrR{:X0rXFfQDw[GHGϚ5.߳yݚ^}u4D"eŪ"ƌqckйK'rKGwٹ.Q:eZ.pR~JL*3h5fẅlږ+=TfˑfM;L?ecmm&NllՊ=e1XLJ:#1&)%H)۲&(4McD?HBw[&ǖ6|ѷ\hȮ|nF(ӶteYif4wJp"x4.%Ts{ȧ A"l81JXJS.8.vGS*3#9LIKfI1wDHpJO1eG}CGBIƄb$'WwŘmx4M2D%[u]$w1ገٶypG0^dN7׭;x%"nL1OVSrh3K7i_<_&4ncJq67tu xcG?cK0Gg}{]Q TJ1|{x\Ѿܟ˸[]&B(;?&mۉx,YӵuPW .Asq]caL2Er46>c9Ew[z\w{_Sw[/韗&R'ֈ}/m>5gTe?42Λ7wKZr zvQDjiF}|f?הSXb\葦h̢QRv>l'y҂G᣻6~vS +Ex"af,okm%iGZ|rKes84 +>5`x%J̙+I&[GRp+K4F̢&7%*Kk4G*"dT껣R*FWdR1Q1:IHǢ|U_|k3> ;U+ߵ{#? j>z41FUɁU:g.܆ 5ݼnM<⢫R;W(EJI3)%1ΩqƉLu##)=qY_ ce^#c;/V!oyn+;7)E`.˲rgҭ+ +۲uk4xLqRcu>ލt'?R헥tTuA ?ǡysH_3iU/"IӴ-tΕ #I Nnt]+)+njh2tͲq&خ2JiT mXB,c+h))iqN3S͘żrqeǥ)G7\Bщ$Pwgkk[k[gO$y+rVč#XKiqLQJy5EL J*7{;W-9Hi,.% C 5&HR ܹPk{ൣE‰+O.AD5å ȱLrY]ƙrlEHni+ q&4-vTXF:|wGJFd("&*+#wG@zfRwer#L)[s T#^[%(/|ʣ+&FvϬmi=~&L'v<]0AO4?zsFFK nr`:\ţ vlݶIM 2eG[Kr辺d_SG~)I%i WoSSXWy~=xfMZrBc Ž-G7+<}?}s;|ޙqǣ1[{C{v>}Lc,6aU@iahB^4"KOBp"G:R*i̱S#1!gr ɔH"&H8RиOݹ}э }sO,ֈ(%9iꔉŅp1FգcH:CJu ۻ]_">X΀='}o QnNG۳84B#"$l0RIG#U|H%#===;vZ3^PWSS>'YpߺuW\SV^!%`;S W,d=gdfs.8?MӼ>c/Wp?!zJ)>r^1SNGӎƎٞH|@tK/ݭzMMM7TRRO^=t/:Eķҳ[́诌l+,^k_x+}n'L8n\͍]|ië;;A>,+>5$wнK̃>xϘ1222~_,\愃|'xt }Fw(WNQrz[nӛ^k|ry.UI%$,DK†& t(h68L46a df*T1+3rzoÙks˗CVfI{޹~Ywʧ \xZίϛ_7\ʇU{QRJ)utby̙۷{_UՕOF\7._>KϾ3# ş L{ '6 _ }~GǏ?@{MoZO~< hPpXy|#6omQk}xcN<+/=C#w]yVJ)RZD0z[oy{(>|mua0;gĎW3f)g;:wq{!j宔RJ)i}o鉈wuׯYXX?__z߽7c}79Wel/vxO>ě?{ՄEiU~/п_;P=qrqVyK<Փ#kr&I;KrβZ+RJk;~N>W[_='>ϿLӴ=dW'o? Vx6zS^v͙KrnxsU~侗>fnt{G|8G?w[ہ$FDl۟;L׫rmm"{V)RJ}=W O}_Ԧi~~w&_2"rgw][0N$%?_޿ks'?ceRJ)zURe*I__m1 ÷:;,s3su{+QJ)Rp<~~Z7(BX>tۯ|RJ)!"~~݃I)RJ]sӎ7~sǝwMNRJ)בDDfN[IQgm4DW-Uw;s"+!4Kas!Cô:K"b)˲;t2VRJ)vIBt{Wj MMD1K^'F!Y ""x67z bUգq0?kD\e˲*r(Y~{Y_D:y=}sٹ^m3DѨۛY9B6qRJ)wizer@ ssmѨ* |wv<5Mt̳leUCy Ū1Y5Yk<\X%@SWsu]f9"@Ax2!,J)Rs$4u{vd!A+"PֹdXc&Uav.-. zA'"1F !& =vi clۉq vLh}mufv9chMbcm{F!"&I#Wu0:qܹ6+RJ)Q"j*r)˲Ɋt‘4 O\5ސ$Mc4&:|zYC!UEƓ`ow'ETu] MRC"#e/sDL\[ ,w[֚ɪ#70Dl'RJ)wIn rYƘ4Mn{7rdى,M.@$kDd1jWJ)RsۑvW"b \jkҾM=VsK̶S>n & b{3`8.~c,_R)RJoXW)ܯp9wyHÙ\,[;?:SjծRJ)\z6\QJ)ROJ)RJ}iyYsZJ)RJp󑏉 d-^C{ G.)AE"Da~72 c?ZRJ)Ե`?"""LDm푶gn"& !mLSS%؉*c2Oe݆m_1p(1ˎmm1}{ Aے+@Ch ;${R)RAc 03\]wKgF÷kᮔRJ)騌D ("@]Jj'AnMQumYZf^$a_uR}xhT`ZsqŪ(H9Ζ(^>{ksݍQ a[;w3^Z+RJ)u;2/ii"4@P-͡wf 8!h8lr33I7_NX.F$ 3y x IDAT')`3IM82{cgw0n_%OH]{t|2?7w6l#2\^#{VtՇ.jx e_fvNh wݕRJ)upI%=fL\ 9k$Ҍ @??>?\eN#{rs?w7[/l>E3t)?>g6|]J[D KKmDiyt"zi\oKxJ)R[Ƌ# 1 pdfP~rrsfŃfz_;z˙ˎrfSM&em bǐ4y3'KK^ٖ¥ 7Zy&ҭlg1 A`"d! 3 : A3Cݢ4WEs?_aO-F)RJKFe!IH70xVݼK}kg'~³}lWMjޱB1 y ;\AM KI Oxѣf~oT6>w~RI€pn",{Kvkmj/|qv]zՆūLJ)Rxpw٥'CD!]jIfnqks0A!v&YH(#yCm(#AmloMB"w(!g/.[!`i朥{m7uo= s}H:5X_ޗDwhq>C&U)2";kffffΜ9[z祵)±}TۙvfN=J)R:50 ݃, ƘA@@D4A"^ |Â"ދh429:spy"[z0?;T&rA33wٴ7_|2m4GC DpRL.(SfSS}iBZk$iu_iZ+RJ{[Զ3!mߝwcENL"E\ 5F"ru" %Jx u] pL]~{{kq>JHc,mPqLKN?dLojaߴ:0V{٭ËA^".NUJ)RnmNf#^"_Q@b@hQMhsaq!I:EYY`&2dpRP%.I[%)*=E!MDL ͠:y5)H cdaDhۿ^ divK޻CLh 1Z|,J)RJ]*;mi[N] .d 1c676:` 4L&[ YkB"\Ufi@bRk0Kہo,_|E) ܍lc#ᒚ*RJ)in%.*@h Sޭ_LYi@B0BAM(&$cd@}DH_>ԃߙew|3_6bzAࡃӧH!2;qD3 vP@.("QvsuGɶdo JKSRJ)53*#o߾M1N{{ "8)B@t<9XM !lHX Bk;v|L,i=Y 1n*;y: 4q7ֶͤ>}!("c,CƘBD04]TMD`F sABȜIMsA_cv6epI RJ)RN{V7͸$t{U9j;,dxg.H 0 0 "2HUnC@hckʗ0YK, M_}7-{{ǏT51_X]}d@f@%23CDd 9qIUV1xh[*ʒ0 bQ{vmqO<"*J)RJ}A "sXBkhA$EУ0G1ߔQ@m Ax-!B+  ud،BHpzsȣOzde~G=w _p_cϝ" C&gB 41L_`FĢB$:Ur қu{3>MH"G<˄  #,%RJ)ԷTvЭ  `fE Hƴ߻S5 خOj (((\x`Q# xd @'O6|bxϫ^6VϜ:41RfLJ 1७&,n Y=DK0vױJb#XEX{|q4CpD&đucJ)RYL&cA0 `#( ʤE4$YvQ1$` C`!hP <_g>O5oz}s}*<؉R`?{~X>EDtY#_UF" f͉Md,$s7~<_hȆPCGTRJ)u̸ DF#MWe(ί  H,qkC8Jhkq$lX:Yu);Olo.aM\'x[>:,*PgfMuU7M4@pr86tFI (Ƙ҇<ԁcOĦ&@}`Q{3[CF"{%z8N{6\}Ž~z[p˽75!|so}=ggVdBhGlji23G 0!fYzr16M%`"ɘ..Z9EKj5\{c`LS.`YL33茻RJ)vvNEӖmqڎybD@,QPАmdY= SǧѲ5b]:l&/f4뻴OM,._:$uٹALo,-#OCy=u9Z]  F 1!H]L1&u;iOqAQ$(n^X9w}L5 2 v,Ynow:}/I?ARJ)-Y!ĊE(@uج69J & &E1)MU;|S֟)V֊bLz=4MNhBd47ڙq6QE$DoowӸȿ5?˝vwVun.Gi`#B0dc8ȥ`fyyy<3H@Єf… cϞzox>R L%B k4O_P)RJ} h;YRoB, I 1RDEUg]h].ܚn1Ε@#H\J*Fd <3ab  0 ‰ز*֛^8C7~`o;N|c;h\fm7KD`fg)elomY'ZH{B0cCpvexȁrX@=NM 3?wx7:㮔RJ)8HJ:몙 Ch L5;@~f.na:d-115nuf{{)@`u£Nؐ%IEFk :?7z 'O:اΜ?:I'.0T r0GF(ЦWUNY!"FF  'rI n26Y/RJ)L $;E]`sO ,&]{7םYf]M= ئfa0hr//J=^8uQQj{kK(Q!$ "y'x6yvz{ص^';{~MɺFK^o,MAdafF !JQx@DVV{K$r 0P@&`.mtmKƒwRJ)uY@7s Iif.uf\zB! B`c[8'8xܰ47QA/8aњ o /MHdI1"F(h ׈H2 mm3QMlJ΁;NCE$ƈ " s8|afM9[f4dmC (L@RpY} CܶRJ),xWgvm'1Ylb,1@"2(ZZ3B/1.a}gֶ676ä6@R@ط3IC(#W( A DdgPo0@aeY݄޶#3ȑxw["b$qFssd8ZkME"s@0n;mjO]D &A*RJpo{%@Ia2ѐ!Rn0b$t졹žcϝ:1NƱ=waդY fLVLA(:Yw09,`qnpٚę !lmmood61l8I[" 0G0Da6]O?):1{왃dYfn̝;{vaq&I.$  ]=¡RJ)ԵgEH8Gd'3i6q3k֟8Ϝ_Y c,E_k" dy?ٿ<0?O~.Oڹ+&fR(0x0;0coijNq!L. 4ckF b\ 0a@L!rd&t$| ^8f}wA5J(PٷTUeo#[[,Q ""B nǝ"^5PF)RJ]m4\=?u[nZV xT 7ѶF$kL;\cc+o) 'nI3ZߨW}iD3.QvèjbP"QAc X"X! ibs#"ݵT$6>C(zbB+?] K_xr<A<֦eQ cB ܮ|MWU勾:(RJ) f7goӏ| , pYI{ 1qL.,'O}J]y1ٞ_\&v2ױ6  t G! 3-A)M2 ;rd2doX|G214M` 7&1u=˲AJhJ=32yvRJ)uLGeѹ>c8/8lmnAl>g.S=&FTg*@Y؄ Diܭ9㛧NoYQ0PT10p ``fB!skm]&"jTvcӆ0C$ɗܰ>oԏg$&&U}l7-O o&[,4tf`2. fz`hS)-RSi=~wƟ/.#/Iلځnw,X#'B'1 D$E@DbYDb愛, 7bs{iBqL tYPy{D3hvB XH@AJ)RJ]sm]8?alYI=ƱK6R^pGܬ˺[YJ- ,`lB".Pp$thg/F+Μ4e8/<4a[B0u&"="웝[ `7>3/?",xGf޳%vA˷[JtB aRJ)uX@tR#>I(&i]Fޟ'&&bzήTfb nmfn¹zx?t5Zcv5|={}*ʱsg`*zICRfB# i3"H$:Dbٿ8hyylvЃEO ІԱ'~ם,lf~6]wrkaZǹؓB;"qg]d6nAd7his4E{/?n޸[ ZX#̆3Y\a`$ Mv]5\LR{=%I_O÷NMW##[#YX)m!*l$BED#VJ8yvs_yMk065DUDaj4l ,,A)EHZkE@"4iX$p8x-\e@< NBSΗq 8$h|8Н˩ZN&;w\{mgs<Aرّ 6( o-ynףO :HlW%Jh3K8B-溌/9-Wv=}8M( |eR L (\X`Q#[F@d'DhSEZ ,RHzup8 2!bCj?82 t t_yCKre(6 {:s7 ]cL9Nd`ݐy މhFXbZT*}Ežjb4l@ *blQih{gdGnPIҎBW 0XƤ%h %"Xˉeu!q@ NeQ'es8ps#;(8)#h<*M@97|7vLTΜ{𡣓caM%} ^y!8>g?c<ݑlwq_\M&9ptd SO&EA_+/J# %n3(Z89 |GF|OaRˀ@Z5f6x*P*VIʉk-ƐIj|?8I_+p8gIU=,V*LllVN?tmp񥩩Jjʇ/;;ݰkv7ţU8=IZs=cGOQRFeD( UB ^ :6\{A7DB큈I (B(aT-BMT1BJcjj>ij׬;aP˯%s8ps$bT_zHleIc k| c'?hX_O㚫1w3?J+35=6zeN0;}@>  6w\K&oٚ?P G*S`3I0'9,bbe^d )11y_>?85356 ŨtN:p"*VT9TA)D*WzAIJe`bgOEOl(p8Dǝs#vUdvX!Z ˪PMpmwM\:N[5v.г&өɁWKlU8UlݺAU˵\410_sub+GǸUXLL")7%ud,҈)d]b3ҐMI/tm 6O-0LOv>j-N,0ڳ ,A/0L4MHYqp8q ek5N*@{)է[/=5XWqB]3SPHOU\CD  biPVBZakGw,'f}n#c{ȞQ, t*Z0 (}2!vu7\~߿FF5JbQm"FĊ%AB@|d5":zjg#_zC|71,CRADS60)ImNM7꣈"1lL 5|cqnvvR `bQep8bAp) ??@;䡱re%`4Z"T!R 2Tb `HR", 1xq \uu7^vفa{bVJ!*bT؊Zk-YD$dD`# P)ZA$?=( HNT)ɩŇ.:;xZK#3: r\uDB`&fijQD,#"+c E"eF*X6ј&$g1$"HJhpknk֯ r>yp慑K&) p!bm$ e%AA&B 3?r`JiOA 9<=Cvsq{^>xU+Vse 51HE0Ԟ1)i-"1,ր0R$ Z\sssHr"4>5׉EwN Ԝ7[ p#xz0 ]@9kO.̗g q΍۹]:Kh t DbhSLZ+"DDD( H1ZK.s.D:R}/ݿ6q{pb/0zh,eAD #5Y)SSSDhL=ec*"1@ 1#jP*Rztd/92Ȓ1B a h8Cʂfni}뇯*1.YpP_2ߴ jל6<~62/NdZ22|# (cKn%͓\^Yyw"\v|[46]*E-XV9" ګn+vE^W2:A[NL5_}O3l/ՋGAhUnoW\-Œ7ǁѲ*Ϗs鳲h'e-=Ȳ6vs}cEM[괸;Yx嗲z*]v l *GMKR#Nջy_jp)0}Qm-93 UڟWڳf8[xCC7tyEHDIMQA'̬N)mU$0,T`Vd)mE@|p×|l9Mԓ X -vDF8-uep:S,vfm<7T#`}A"ĺ])stABFLR_.xqbS^xK%kX0@k1 Mz2^b 4/Mgw!fCD$֊p voYa*a!-26Rdq!ɩ_7v/6.y5c |fX@\LKO5I*mTK{T.ȴ;lAbm.d+|ą6[KtpUWmm o-}jN^eTtnwQ~<{rj3_@i(%N K6|EN? /cXMYmNڼ}JyXuh9y(lq6@$"ط寧T,3 Y8Hԕgs6BP)hVXP"%~ZD6+d!@?vm'~Bm,K&eg@3i\1*ڜNNG&M˵Jhd  ٲ@E"dQWghJre -[zԒ$a@$+@HI`cr֚ <󬵙jւTjX;JW^yss 3ql˴&aKh6 fH+Geo+-"@X0BJ|IP(Vh76g[l鎬'Kd VB-z+-;iD $2UiҢ]PzK8o_E LUuA`K, \yq渶ove?UWo6'e,,T^gi[ZtgX$ȢbdQ jHInXtGYƂmA,N_ r慝WN=e %oخ{q} ـӜ9,rڂS |?H]Ksh 􍾗%8ޢxE)Etl9b3]t7xy"] ܁,*&U$E<-dT-h a# €((4m=D[ M"%vvvbϋ5K-YbkmnqN Z_˸mO9m&վraTo&/ =!:۴Uf?3}o<ٝӮekЮ4gM4rS+ XiYBҾ6iX܀ցEӂZҳM- d~j,7iRY*Uqsnm[HOt/?8 @'dO ]R9 e}fF@{qW.6pX g , :-0x,ja5">XZ߸<7 =Mi$pA< ׋zrp 2yZVggg?SQ-Qm`pM$I(bq;: Xq[tl!33SbQHL+X*MOO]p8$*KkZ03V\z(!wqGZ~|p8Lf"ȡ3'*v(uv:3=z',TqV( B>zp8Ǜ#Z>3;X|.~a#,1g.'Zk֯rzYk֮]8|HY,D}C8@a¥GMݳk(A),Lc: ƮyY_~a#H7hV,.(W}(r`q<{f?>4"56:y|np8D7{"Z>;ޱ}ۢu0jȡ/"Mӷ햟>CHǎJ%J{Ƥӄ^# a}vaa+F{Vf\Pra (2zzlѦo3MHSS.+ %gZ5/heP\p|fº@o_/`qf)ӏf?Q;F-60jJAmkԈؘ_n{"jp8qȓܢtl9?sL )}7+91]56`Dp8,]tr-bOk"̇ri$?wow/=OR!hYg5gƘ?r?|ǥ{;rGs *IMfVJ=]J)f‹3 wMr:55ZB."֢45jiƤ8ijY$I%Gt;1Hv? zꥹ;>| |j\]~˕Xc üIձݷ9Z.nCw߲6S~謹^;?tే]'+>e@1FmjZ||-F ĭ—K}>yosx"gt-z^Q=p_=|욷r[/BRKM釞KoЭ̅7vp8"t淬ڱqpPϾcl]itt+@:kI#I#yYTX9Km9hH$&Qлcc~yD ]&)U^|eY)iZ'" ]89~Z֚T*0sj,,V,.&Ґrul"7>{cNw{_<#@?嫻{?K?Ll}pHōw5Cz MafkIX ƤB&5HIR{K7_;5z\;?]W1q|x{%Gzy8O>ww9&Fe`,Z43|l{_;Ϟ}i];\ܲr@krp8E)^cl/|-xF$d;4QjV(l$~7|7nFَugZNGw@$R΁&J$STJ=9RJ3d..f6 DilJh o%zo{{:/yڞ/r5p,ow[" ֫'v>$"Q)px WdfdѰ[af6k@o/d(iZT =7|ܮs69k;O}އ. $BXhM]Gv\tE)(LP43|EUp8R^יL"2+ʂ\ZNO"\Quvv^y}7u)5ɖםwӦ_~f*E_DSF+I uWky;l⮖Db% Ik*@Ռ9ئfF(T^ZQw"$BA0z-kW1 AP^{맶ٵkϗ;?/-HJ=%@\κG7|]ik~G.̱]OǷp8L,sJ\3ۉALCn $H+oק-th_34Xi&rjlZxPaZ b}2D#LJHD֬nj}$ `}"A&uy/~k逸^zINZ4]>ul}zj*ܺ?3|%\Am:_e0s?vȪWY0 csMPZw;̷^6j0l}>viCO=tUChT6/x۪kvlϰw'~ x#׿cCsvAtù-ۇ0{d7i -":z9p=G~IjH!tW\*]yE\߃cDPE{87=bf VDHIOF ~& E e%D20swOO,uwv<1>uȈViU':%I|&3b#нg_3w J[}G7vɉfg$+?p}sB|pu}zؿL&qB`~)tY]$?cG"M]3>g<ɗ_>n@= ՇwMD)z;;ZTIbhxhmI]FT=WG{}{~\?;wؓ?E Pk,p8^7`.JjI RaWy)Hŏ VQ%[Fww]}pa]/|wyWl8-xЙ]ۻǎ \+%I=Ӥ EdvMI7A4i7b6;q&IjS$6x+4e\[ֿӿ m,woք Wi6ޭ8̵,(aff*hj6 QT?x&V8FlMwMb74ݵ>9M'jԥ,3L-6)\?yc#(,7}+~U%5Gi)^@Ԟv>p8^GnjcSeֵEI7ת?{$F ?1:;ޔϽ!<ԓJrc_hp6TУkVv2J󶌎Oc_v@)Z"c?:ydj`P4VE@6Mbc( Ic[L㽿|x Һ]znmŧq<kZ#r@0q`Q9 M#?{ϟx|BiRk-ȴ~ĭX5ְ:YhAi|bֵAZ=Mڔ( Z9Wp8lR6׆no/d$^.l j( tʟG_n"zss};zyb^DQ(V>hdf(/e+NImQJAf_$]ì_p)3?xվ>oR".x@7^dIp87]sǙ*džG>{57"vvvZ% DRtcNN@EKGQWWw/>mPE.Sd~(6+9b]~cZ9uVp8WRꩇ#gAךןR=+# )r|.ß#'mъk FwS" +ssw8qVIZ+C@ 9~`fRtUrfIENDB`Mopidy-2.0.0/docs/clients/upnp.rst0000664000175000017500000001320212660436420017266 0ustar jodaljodal00000000000000.. _upnp-clients: ************ UPnP clients ************ `UPnP `_ is a set of specifications for media sharing, playing, remote control, etc, across a home network. The specs are supported by a lot of consumer devices (like smartphones, TVs, Xbox, and PlayStation) that are often labeled as being `DLNA `_ compatible or certified. The DLNA guidelines and UPnP specifications defines several device roles, of which Mopidy may play two: DLNA Digital Media Server (DMS) / UPnP AV MediaServer: A MediaServer provides a library of media and is capable of streaming that media to a MediaRenderer. If Mopidy was a MediaServer, you could browse and play Mopidy's music on a TV, smartphone, or tablet supporting UPnP. Mopidy does not currently support this, but we may in the future. :issue:`52` is the relevant wishlist issue. DLNA Digital Media Renderer (DMR) / UPnP AV MediaRenderer: A MediaRenderer is asked by some remote controller to play some given media, typically served by a MediaServer. If Mopidy was a MediaRenderer, you could use e.g. your smartphone or tablet to make Mopidy play media. Mopidy *does already* have experimental support for being a MediaRenderer, as you can read more about below. Mopidy as an UPnP MediaRenderer =============================== There are two ways Mopidy can be made available as an UPnP MediaRenderer: Using Mopidy-MPRIS and Rygel, or using Mopidy-MPD and upmpdcli. upmpdcli -------- `upmpdcli `_ is recommended, since it is easier to setup, and offers `OpenHome `_ compatibility. upmpdcli exposes a UPnP MediaRenderer to the network, while using the MPD protocol to control Mopidy. 1. Install upmpdcli. On Debian/Ubuntu:: apt-get install upmpdcli Alternatively, follow the instructions from the upmpdcli website. 2. The default settings of upmpdcli will work with the default settings of :ref:`ext-mpd`. Edit :file:`/etc/upmpdcli.conf` if you want to use different ports, hosts, or other settings. 3. Start upmpdcli using the command:: upmpdcli Or, run it in the background as a service:: sudo service upmpdcli start 4. A UPnP renderer should be available now. Rygel ----- With the help of `the Rygel project `_ Mopidy can be made available as an UPnP MediaRenderer. Rygel will interface with the MPRIS interface provided by the `Mopidy-MPRIS extension `_, and make Mopidy available as a MediaRenderer on the local network. Since this depends on the MPRIS frontend, which again depends on D-Bus being available, this will only work on Linux, and not OS X. MPRIS/D-Bus is only available to other applications on the same host, so Rygel must be running on the same machine as Mopidy. 1. Start Mopidy and make sure the MPRIS frontend is working. It is activated by default when the Mopidy-MPRIS extension is installed, but you may miss dependencies or be using OS X, in which case it will not work. Check the console output when Mopidy is started for any errors related to the MPRIS frontend. If you're unsure it is working, there are instructions for how to test it in the `Mopidy-MPRIS readme `_. 2. Install Rygel. On Debian/Ubuntu:: sudo apt-get install rygel 3. Enable Rygel's MPRIS plugin. On Debian/Ubuntu, edit ``/etc/rygel.conf``, find the ``[MPRIS]`` section, and change ``enabled=false`` to ``enabled=true``. 4. Start Rygel by running:: rygel Example output:: $ rygel Rygel-Message: New plugin 'MediaExport' available Rygel-Message: New plugin 'org.mpris.MediaPlayer2.mopidy' available In the above example, you can see that Rygel found Mopidy, and it is now making Mopidy available through Rygel. The UPnP-Inspector client ========================= `UPnP-Inspector `_ is a graphical analyzer and debugging tool for UPnP services. It will detect any UPnP devices on your network, and show these in a tree structure. This is not a tool for your everyday music listening while relaxing on the couch, but it may be of use for testing that your setup works correctly. 1. Install UPnP-Inspector. On Debian/Ubuntu:: sudo apt-get install upnp-inspector 2. Run it:: upnp-inspector 3. Assuming that Mopidy is running with a working MPRIS frontend, and that Rygel is running on the same machine, Mopidy should now appear in UPnP-Inspector's device list. 4. If you expand the tree item saying ``Mopidy (MediaRenderer:2)`` or similiar, and then the sub element named ``AVTransport:2`` or similar, you'll find a list of commands you can invoke. E.g. if you double-click the ``Pause`` command, you'll get a new window where you can press an ``Invoke`` button, and then Mopidy should be paused. Note that if you have a firewall on the host running Mopidy and Rygel, and you want this to be exposed to the rest of your local network, you need to open up your firewall for UPnP traffic. UPnP use UDP port 1900 as well as some dynamically assigned ports. I've only verified that this procedure works across the network by temporarily disabling the firewall on the the two hosts involved, so I'll leave any firewall configuration as an exercise to the reader. Other clients ============= For a long list of UPnP clients for all possible platforms, see Wikipedia's `List of UPnP AV media servers and clients `_. Mopidy-2.0.0/docs/clients/http.rst0000664000175000017500000000011712446377410017271 0ustar jodaljodal00000000000000.. _http-clients: ************ HTTP clients ************ See :ref:`ext-web`. Mopidy-2.0.0/docs/clients/mpd-client-ncmpcpp.png0000644000175000017500000005362212441116635021762 0ustar jodaljodal00000000000000PNG  IHDR8zGWYIDATx=.[*2)qLЫ<8F*)S<6aLp2}*|\\/Xf_O|@?0} U`܀`F翿W__<ys|{Ē$߫4 ka~d_ W(OOg2 s1&}M[Oo(o?CzȰ5ѬϦOrթ\ie{ӭN۠ UĄ&km~7+MMQv W9_OF?̌ҜҬ}))j*x9{s? 3&P&8,-=jxC$+ɮ.o8p>W5%_O7vTK/d{BA 6 .L{}B{ej}Tr!J {Ĭ98b*cυ_CDr\s0zL;5;87uIzz Y!]3WjsGʯnegee+p)EvT%+Ϟ5t FR 3nb\ہV|YN/Ou+&l^%ZQQX]K-Gyz}nKM@}>h-c lwH6o!vOA~[3!NԴ1^@}^o;wx܆SWes[0g{joQ~\ Qۑsv/h9^}j el0.A% /^w=؞HGz"l}*o~[%#1EBj /c|XU-=\#K0,>ES,G g g-Vgu={}gO_ (mӖ$$(~ [c:|Tf!걀s3>zcIX"+ZLn|Y^K%tR7|ڋFs޲Si$ -7gg*,GKsjVVN|2vK`1/˪Tig|&>4}^pC7~vUx.nbǩ8_L%[97eՎVY`{c|GIQ>5j|ݓGMVeBMo tkl_Q{g3"*%HHHG8`R Z?Qy+ "YQN%KyL,_xr{륋Kc?M}/)~e7ZV1sn>[=`YtQ a I.9?}}H׽G!Ofo`KHn{e%_ñ{V'4v^ ֮`T궭0.A%]D}::/`# }n,(>no돣U lį-.S Gj[;e(^/mgćW??^g8psxѝ&׏1> DžV[|dٷtш kK{<>(z.ٟ9@ZY rsdc'Gpq2 6ΰGY̬Gzۈ`i;W=록^Qx6x;7A{thxs4].ovKn%9|&ɶǦX|f@rHUnH8꧟~@+?` ?-X{}`66666/8V@LhkuVh38`#0}`#0}`#0}`#0}`#0}`#` OU?ͪ8d^WoVu4<~pA+;~dF[2O5칓  wt˿Xu\y ?2ƿ 1q ^#ݣ<âϲWJj͵!R}JH9B<1a5erxoIfc>=kQG\J%2O۟ ꣿ eߒ.@-)\\6()ۆ>#rtRV}`H /+oY_f`eҹ0pH/.GDbٟ! .!>m*RL?%SNg[mTЂ^`Z$>o[N;4Cmj "4RZ*([H۱n;taf2ۜk-%_eǁ+u< 2G^}+YO!V^^ 9#FsRv*70WN6Tӹ>n!˼ONvOG8d~!__{VA_}LN=1LbaeF*ۦ۷$꧟~@+-2x4g<`s0} Cm Mnnoz~Oi>)=彐~[%ĞDӚ\#үux/m=QVtqRy`rM^?_J5uOGxсb:jmkRKjs{nLy4buxͩ}%7,= BynYsj0q;hrsPB|M_8֩U2%}quI iS(^4"(G)ޮGߴ^Ј&7KXZ-^Ǹ,vâ㠩~]O6L2X8 V!3z'vO*S/^T+.Mt͇k׬T;9wNtffˊmۥʖ(> [cA,{ܤѧznE?rMHqs&`:J|^0=88>ΈKNA{ -*+ @6]l5>K 03[xy7 v%`3*ps#M&m|䰝VW7iU2]e8.Cg{,XYg{=Xb-^UU!h۵+?nrFvm9M?R4GQ6* Yhbii .z)9W֯_۝9ϩҨ/xW_[s0JB$^ke>z MaxÓ{Kkzi@ڃwuǤDW7'E!1U٘T୆ps{^~g.ůp@6c_yIZK0=i٥IJQh@ ۦSo8SL*&3sy_߬|[+zx:ܾ*Qrh'ySoݸL<5Q5F [p =+Q`j*yk ܞkIB=zSԩqX^]8!cX6 Z QrT6Ý}&ծ&7q3(܂Qu򔈲_;z=8⛾DUK1_ȷz6*k3&׬T&5g-ϩΩT+vW*]S_jn+r3Al<hrӜ;R7 -~dJWɫ;ʡ)zZƫ' x},rwxub x7BdxŏYA?297|TYWDrN~W^er+m4v &ѢemݏfҮb~sQ棨~|on =;>plfG)FMzʱnQX#ɏ6GSaiWѡ ݪM/\R#eᕽN^Y o@5{nnTS9t[]WlR9s4ŇB@~կ8vK:+5$]3.-8 eۍ  ȕ=6Az9i_vWT\QN׏I1vߘ5#CL0@7[kd(+\Fv29YQzx&QcgMJ3rŠs-#WR|c29GQD9R#cY{.YGnn7xذiY` L"`3@6D z7_IJw⏰Gҧn?*>{)~vt͇u}N-}]𶓐;Fʟ $Rֻ )3>Ob1%1Qcݾ>P}5ⱈ^nh=k$?<#~¾3V[W_V)҆ToҼCX^VnJ)Q|a*)]Sͣvsb_*ӗE1䨤d_ۉ*˵q̲TVJPz7K!pߏkqˎv(JC*[T͏SۢR3G9ҸTpgdӍvGߘPv"`Ώqfc{-I <-A#^`hC?vQEbPN޽,Q0?œ{sq+K;=͎>J L5l}1r8MMq[H; Znafֻ=p|m8˴Ak |9..5bv>jZq9+]5ir*Ҡ.-,K)k}lvGE۬|A/'WrZy%ӵHY!r#Z]6]x"ao<(`H{OUܮˁm%+16Rkx1Qb<(ctx/oQlsX>QGc;GYQ~n8vr+]>^@dL7d*^FfK|yǫ(s70s/U\'lF.dl?AvOgM5^pc#Iv&j:z( [r(/'P߾uWV˰$S^CMy+U?>tNv(qIҳxՔ*P[ijH=㧿l$`'+&QѡCBޅұ H;Ec{M")韏[R{2.eF!Z,WpTAJ&Hc|AP>kxLҤzRڧ]T,/U7gVe ^ k\+3K5yZ 8=CنϨU]RvFA}q&w85GQACD`P03F+O(mp litT>iTܞ \BZȿ.9ҽo3#zi]]r*ӵ !( * . ڕA|ۋSN\âvLؙu s46bۍ:2PH@#mNؚ(} ;t֣;6toMz;ml5X0~ZDY)ESWW ;i6gXit 8F?k(r-K4R?[O,IFE睱:Zs%FP1p *@ޚv?0gM+)\Sv2mY]޺Q'P6SM"ǵ&@QxOMǓb9*HQKx5ڤ;.Orng刔@}7[J; pkW!]Pdf;7L> NGR?9h|6;DʩWY|%\֮_nMǮHO>])}dG-iL81q*=_b#g5zt{&D*;z~=sm^SXba[US yٮ{TF#( H{Ո՗ָ,WywT0u,ys(9}XK7!UF^ =ҊCXP:ʇzIPd/3$]}N-\Æ0~B=ܞz) o}O/"^9+\>kc|cohze GvR $ 7ÛaUcQZqdUo9aQpxҳ5oW^HZ8ǫms;mpErnqΟ*='鵣ݶЧ)Q{ܱ>d+kvp&rnqf?s+njM)mNק6@#/+=l^y(S @Sh-(88'^Pkx=M^bt})kJnZ~|Ep$npzT>ٟ`8nn7_jA!^q$JΡR翍 eՌ3ƨkRTyzS=[|lZzԿLyh7̃b# gٽrRQ힣+uDdhatD>p|M0t5}{(1H!9t辆[! t߸ڡ PֲzOכq`X%Dzq_ҪOP,}Z?U(jhzlOe۟nы©3,] _eD0D\o{t{(p)@gHz +?J4CG pA?>h մO nn7+@I"fK13>` ۜ`#nn/ct-6gi~MI;9s5ۜ;F;ƥG);'T,ߒ}}2/ ̀} 7j.PϿ)S4>՚v<ݕZ[Zɇauxь50,-3EЂ nG onWTp ɆlS&u /6eG})he\5Gx0TsԈr_ٮZ),/-e| &lGȆls!^~6k (4jڄ5yБy#$B1)~@{Vbe4PN*JTB):"M24E0Vޯ[v[SonX'3JWTCsDJ8%gkʐV5l7+&54`E^,AW=Ǐ+^ʂV6|>B9 Fٲ3DoOČقW2ӜVPFz*잶GkR/ǫf)ݨ/qeߓ_I\݈mXԮe܎ @kdoªl/tljfǩ6'ۨïOv$9Q-?[竦H Q"@'Inbx8F`,ϻ_?yÏC|MXz;|sJy-aux^Q Va=4pmg iJ%ME|)~^^CGb=KgF痤!y,VD%CVʴ5Eeu"U09vXBm*N ce$~>H|v}>Y:P*5[ԁ([vmN FGk?RW v#BZ>Nӭ?VeYߓl1nP>R>&jGA?::{8e'npon-P-veiz>D3`3(~YO4Löz{.+з9Kg<+Gy7^ʖnCVVy.+FӴ[ëa<&T}\qǴdl}y{3(hxQLn[=y.ˢgPLӱL o`¸ԏnk7 r ߡ2!stܖm~*u~oTny{-9tA3R G%hj)̀ReMJ $Zps{w;)ߖ25$\ZZ v;gE|MC_+`$v~5^ct?V\ 㰇ߚUR;ʼ0Uo=}:V3H([v oEjn@b PϘۯD-,G9-d#_KPCvU#[v]tY9~Zc^\K)J2t)=_zK,ĩ8Pz6+~Q7$潹eoP [AudRg >/sJmc1ܞHۜ=Õm;<>nXӿFۃgW?$4K*}fn0- vn[xɇ+Ąv;١]M =soË0p$0w'tskAh8_ÜU7B=c: Ʊ!ť'au4[cQ״g/vʗXjYaH3ѐMF؜l#I)xUC9(rB\; Pu({QsOo{Q])zܞ,Y,BL6<]y10jIpRT>I6"ҪL94YJ>w&ƓټPY/o՞f0|לc )ٷwiМj֮f>|@o-;w{*M+ J=jij]Mq@ ?#56jiq G3=]^dNA=lgeۉ=[ڭ]kz7cPc7+da,yV*@̛W4}6qmbfuշ9Kg{<M7J. Ӷ|jo;/Gp}.^G_J/Z9-]~8j(9zE)y3aG0HqW&5C0I?V%9F(n{sM׻V=w!jr|H¥.)k5KLCz,^j*z:娒l=ڴsI?/%=Th*;v[|}5z.(_|M}sDcX=]WZ.u/ixYW?tUU+*jڹ@NJKT~GuI׸3$^x+ /?]^PR˘b=?L}s(fСz4g8夢$nZT-ŕh, -yon/K: ̮B2F7>T= J(9ЎQc+݂0ռ0J^SfCS}xU>Ջš` RoW?2t:]sߡΙoŧ^Wvy| ;Xvc&[@*驔O6ݨK,=mYˑ{!*!cn)Qrߺ< 6 Ϩ-[܎ l˲ԧ9 8S> C`3lnnNwwos~-W&E6~e(3pը)]qcEK}O+RєW,g[vx43Oo-S2=m*| ,⹼{{S$|ѹbmQ!X:4Y-[t؁Mwz7˂KNV.^`{TNwu7?59:˩qZjRFTZ>!k%xl7+r$ʯ.9?2$SaeS~VNpm&!Mqq W'7{_}SfԸ;vo; %UyWdۥt۽L3=[`x߲jci!~~Tal~snn_yoHrMT= JLS&G!}d~A~V?nn7zDýX۳C6 >0̷ W)_av ДnnO۾r--˶ic=~7{+<~>S),.upk] SI^֞YvXՀ *4 Nsc`R0wZ6g؈7^H4}#17˂# 9ɕ\8Vk;DF2,{~8WK2=QE[\ GM{T=a!Qͅf 50#_y Sܮɾ?,zAm0p/Q-ΰxɇzIwyL׸'/@/T~qi:d_4"F)][cFl$^64g=7znjdճ8pZc7=}lz'%tMnnoEɳrLp[5Ul+W]SZvE@J:C5Pi((1VN(tAqEɬdqx=2>Dؙ&7W;%b bI>̦(=_9.) 7r}6ZVQ _11( }h~se[EC}Y9R(`f{ڲU/o=W/ǝzyzWtu=Wdק8.F~Ku1 nn7ë<  k^)>>sOY:17K;4MEG~="Fxon?!Ӷٰ c;|RjPά'ŸayQ.T^#&2wƁr0Rڒu@HA[>>ƞUvtۯ},a )4+' &[,J WU d]"LbX"K[oQrion LHJO5XxNݣĴ[9IT2{Qk#nn"TQdI#pN$-u[~5-eNQ Ңͩ6M3t:X7w WF6ʇb*hNRv;ۺmeNXnn73w?,;7V@翌V&U؈_9r$հ,hY9[ }tc㑃YMɹZQ+ѐ) 9*x&b7]SӪ5GkNj#(9NZnxp/|K dWC#K ͯ$>dM7e /Ekx&/@)kqܶJUv50ʅQ0NYZҷ-+hꛖ_6$͹s+w">=Yb:JGA%+ `O_xUZ? 6{uJu|!nUD`kZZajqXxLDŌj=^\](+@ϻߢ.'#R&Rn>x>@|?~h7V5& j eӜ`Zl],7}9~9c#TB86gic㑃YM\>Je\cg.hE!+jpxU7V\GM-#}~^lKܞMn?Ew0m9T _K|jޡ^@NK{7vy(pON[xL+*PIlMER)'~CWrV+F{!_R=t nn_vnppZ& R ^GL2jUGkTܾ-z!gDR9QX_[Y]ɋi9nn7} k>.JGA)ɶòOҢbzJTۓ(d`8ml{DMKy1;GeVZehjYki~v(O˞bZ`nK̗I@XBIIW$==ZrWl|o9?ceCӢ8Ǻ',Bnon~]6C=}Zڏ ~ps`BiLSY>/@)Ns}$5>o&&Cfmvr$j!4?'v 6g[xeWjAJ{֣"iVo5)^g} Mhyn,uj˒ִߺ|Gx Vf)5w(j օ|C5 .ek*zxM%Nj4>Xs7p*t/e:g@=_I}*H; {QBI K֖뀓@ };vH c,-~Dm.s]qyMշnϫ 0~LS&ֵ,5 Qi(i5-R{>}J_nW !"E^k7GO6: aoO!mcb4~ 0 ^򦷜8 eN٧^5 lʎCWi2AIlxgYr"ϯR{+Hx1+GkHt뿖KTZc!+ nn7d٤alM/;` ll7}9VXByTJIwۣosv[!&*}bOlsiQIƟ=8=FzLB:=Y'7\EPt6V~^@WyEVrVl? ;+v1|{\}.ԅ׻-z'Qp`cx#֗$GDzAoo/=on/p(ء" =Cvte@z*jZ_]@K=Monꍫ_Pcdmnn7} zT^kt-{Qʡ$]n[_=5VVps;=>,ǥs_e Q kVQS,X&mR^9Dgo}Snp4:hg\ oKy'rnZVY7=:(z sǏץ].J}Wr+휕z=u!nD;^ϔ%K*cLKY:UWI%_j![nR4~re=&_n;d\*5{nr9YɟW; BHWqo>WM&t[_O.K4K!r Ml.l~n 0'~o0d97Pϐ:{$v+'PNSx8ul(x]k,Xnn?f$:&d=%$4])Xr HT;m`V7}Br]ncY77P `AIlMHͫaI<9֑g㥗7\?PN[LWᒜbܤYvx3+ƷI6Lq3@U%>`6G0wۜpsqܗcwJ儘 ܎}t)g}~5i>~m_]ӡ}G)^mѱ@\gvV,笝nGQf~׹RɦX]'=Q^Ew@9KvϨv5>VWJ].eo^9Dg3^QG%]|Ex=OkR=/!k<"恛ۭOr9d z8Gɔq՗wIiQSnAFm8Pq%|'>/T1F:%] IZ#Pב)U{~ӷ%(U[#QPqv]!vOq~(=ϮjyدۏٶʦD%aWT5sL9dsK~oe&e}k\7ժ{Nf IZR_̆߄㥸o%9p|$[eZ/W]%el| 0\a9yxկqf\ /;>>{\9O{N`J)Z"\T)ۜmʑM4-NHhѴ 1]K%Nε^mx=@e Q)d힬pݜgb[Z]O9 S!lF+rod8:Ǐ]v\Ybiekb:>%yNWQyl$ WSm*H6s޴G`}V{W7 50~o[[d]ޓeg*9-l|7GmURIt V(i鹍Ir@OV׿9 go_˞FS)'*_^9RH#e? g6&DWl.9^7 ,{R)XI)`Wyy/&Z)'ps;}RhYwxyQÄ2(d,SvPzeYُE G.g$Aҡ eSF^fps{Ɔ+{7{^W4#g4֎"-|nnXRo& eo, +> 7?ʷHB>]<S#miU-Feur-[l^V3`,p 0*+3R5(QW,+srYY!V(hiT5ˤ+=nn;=]RYyb]em4( Fi ΐ#"I@ex (L8v[KA{n?4J)!XpE]N2ާT=O B6ZZf2]lD[a}Y9ΜZ5%̶^nnE3~R'UT=F_FC3c"5, מnoyW_um%9]n*ps;fx ey`D1E}7Z԰󪏴맗R[6U|om|p?.5I%mSi,}@~6}!,ʣMp W)&Y&7]7 o9tcWفo7\j~E[+pA:xtpm*\sVH;LTNScȻRvҕ-D$LO1T0NRNROE9r8]~$SvE /K4|鞵Rx>tb(M':w2??~G ̸zGPe{0('E7 /#+bs/*'ޢT-Y-֌]N/(%e/lOki 0>7xnޖUl~c3LvKz|ΑI׽!_ eݬOہtIZq [ OHs5oq'NuF7S|’ }ʣxa_EOW{|UHizz[0Vl74G$mFQkHykê7?꣨n*'*b=o4v;vg&VLedܶ|' PP '5}e^9zO@on,ps՞t-N4Nrc,iz9ܚRG|ٽ;Aoq0& 0X3,8`#0}`#0}`#Zci ۜjM)ԩ؎"ͻ}8rs;@vOv 7jSN37 q >Ÿyd#sr腴`Q{ ?(RBC8nty&2{r[xOrB6/Gez'y4,5q)nnDV۳ >M\&GŢgr ^FכUSi((1; % kUx-S ^FǥczJgE 7b 5([v=_ouvB?6~ R8ӖHjop]vr|=Fst(;^o4o vlll7-Rm6pE49ލY9=]/Ϩ#s;_@p&/Y؏n/(̅?@ Vߓi cQPv7_UV>_3e 2je%on\؄!JÐW6ʱ+(7׸pܧpb+۳ -3!,nnϰNݣ"#nnL3 چTHɉ 5vp!leg>Yly-x!k =mُ 5"OG7 fX j l9LnnMoۏK)e_91]{%N˵^vu JK]bo~vkc|WxLqZq5 <,t`.V݁~y}O`x=8GjWךp9}7t^GgM! }qv ͑̚Bo|>t[_M::+DZtks n$vF;., lts;3p*9-l|i&=/Kv imťOOM$깍K7 /א7S)'*_^9ʷ i gߛ۽+[П잶8cF=w$էQr˳RIYoxhTf޺g /LL؈_L:GwZK#auxa. h ˹#'| /[n@OB\wEᰋ-V :ܞbfϢP.0û٤aF7# E8=DHQ0i,obߛ[ׯN(jvx3aghYpxF`F`FY?%31W|[jxz{Ȟ דhlؽPɾ7go7juV:_TbcZ Oz%Mx|;H_ <_7>Qwgieps;lũ iuSPFI}onE򩱊 `#  }{2ܐyHT¥mѷ7Û;CȂ 6UI/> {`66666/oI>@q;q;@ pxF`F`F`F`F`Fq;l>>>>> ]^`IENDB`Mopidy-2.0.0/docs/clients/mpd-client-mpod.jpg0000644000175000017500000010472712441116635021260 0ustar jodaljodal00000000000000JFIFHHC     C   @ Y !1Q"ARTd2SUabq #BV$34CcEr58efsuF9!1AQ"aq2BCRSTr ?=c [HB@)[ G.^hxJ?jL&bZlx,[-ٖQ7 d8$BxY nnt}bt]pK.S"c[Km8JVRG:'mf'x݉mg:33kvgde|vI{e k̺3kbSOn4\ ) J@N05gl췩b[ЧJt}?~m-f+A&4 Q*BX߾u?f+f:ͮkRg.uI=;Bxݰڭ=_1veao-1nv J:x U[6|Ŗ6yJfd;wJ&^;Y̺\ ȑ!ӢZm (@h ܱ[WYCtRdxSJX 6ȱ }M͸K]a 0Bԕ( ;5t/, gCw&*3jxjQw 4$x VgŴ[Me gŌqԶbKn()d G bm1]AQKm.iPEIJ&҉th(J&҉th(J&҉th(J&4z U(m([O9”_at(jAG IsKMf'hyP]2 gnڜ Mr-:e58HkENhx>Z՞D ;yk{e>Ma^/WkMǫ\f7xX1rl=F1r- w,JT,/z=qS/|ҕiԠ )cyf8,Zb[3,<()C; %>0OzKl#5jnjڒnR7ZVᦧ m&Q]^uKܘ&,I*h2qĒ7uZucih;)=]R#59ˍ)xn:NXf^q\N:neevCҔ*RBc˥F7NvbeiC9O+P0hԭj=S->mjz"gg}Ľ2J^yu,|zuJtb׬^՟>{ÙF%h;{n[)XU6+ҕ&&CwV!IZJ@#SANEό{47r'xu{pHLZwcה=`~Q6U<-d]4KiF^V#vf~n˶`6"[I'ΌN2P!J׈F<5xb'mCvݱ;7#) ȃ}I𓼠nHxV'Kn!+fsGf'9mu~4`~4>w@~4>w@~4>w@~4>w@~4>w@~4>wAq+RVFF.VnZ Z$$ۡ+y_}U&6LxLm}IOo'}>[3wWK-6K[R:u׷S&խb>?ψG׿s2| ^ a())h O@x ׃rtٿj}ggkG&Ǘ0sqv箧;?K_Ns~7}ڿL.n>:gsqv9?KF=9Ι\}7ݫt_Ns~7@jzs3oWӜ韥#}ڿL.n>:gsqv9?KF=9Λt s-r}\~7@jzs3oWӜ韥#}ڿL.n>:gsqv9?KF=9Ι\}7ݫt_Ns~7@jzs3oWӜ韥#}ڿL.n>:gsqv9?KF=9Ι\}7ݫt_Ns~7@jzs3oWӜ韥#}ڿL.n>:gsqv9?KF=9Ι\~jzs3:gsqv9jr.u[:$v{yR R(T@>n'!zWkgE_cnO=v~/! Ky nC :Zk5zM~^_@נtO6!-۬9Ҕ2)VV"ur{#m}bxI╦ DOY]|pYmm,yۼkUtC.gnvǝDvyz5&S%IJ҂NuIԝ5h+t⸪Q/Quv5Kd%DD Qs8ÒM4曤(u| Ew{c;<6Um_{yB kqh @gMb#.yEbېRBh'ĝH<^m]Z}͚pt>2R@;5OKtӐn0 Hc*%=v[ *Cj{CSAƂrfyqg(H'MJ;ȎFN;Q:ꠅmofvk`27yfשi[/8i5zM~^_@נt:k5ݛN}vl~~Ĭ z|]t'sh45뮜hi7"vqgd+8s$$޿Ƿ5m/ Ru2y*x~8QW[:1E22՚Dt[o7 xqW>bŷ BWVSdOn6?v5mC$p1wLf<ԇt:y>(O q^-W7Gs3-ЖY*Hp!CˡP|1 ۈ܇%hJzkNȅ ok ?jOZ@֠tP:2&ɹE]7&V>-^ l,)|H)%zuPM/ܶra["\,벿~[}/#T$ꂒA)¹TY3T-vd]0A.+Bx/t']yw/4+62˓w z5{q !s*%xp( vXsbp?A\RۨVkANu+k{zOFj)-0H$4\VRĚ ?+#a6H;BT yk.;-*FK%\]RxA6;E&#Pł͙ʋmKPqZx r?Kl2iƷE^PA#Mt#Aw n}nL!mv J7WP P"| EqbtP/)e.iջNy`U4i.Efy-K 4Z@֠tP:?jOZ4rS9yMZдtW8+sK;_nno;wlUn2vx`C;j׏4R"Ifm6*m E%q[*'UiI=S|[mm;q7M\Ƨ>쎮-ĿuEԸm|q)K={Z+'6ݟg.4bwoknC %+i"\}~-kb:_,'R&kR">̓p^q +&Rs٘[fZtƥr9GM -y?-NGX)m҂nDwH#.cY-/X6ĮƴuV@w@5ԓAv7lQvuIP`* )P[A`e7|(=+@_; OJP:zWҾw󿀠t|(';Fᓯba&yF$kS6Dymï/[{Dkq(JJs_>m[fdxd%TcdIN('h#[ nڝpOqJ=m"$t$ה8j gohvd6!6dB|$#E+@ox4:I[ ڬ3KmDD7Rt*h K.RI[umĒ!AKҾw󿀠t|(=+@_; OJP:zWҾw󿀠܎XR-T:J2[ǫOUN>Jn2/qۼ=ooNk*$Zʕ<1L51JL-|#VyLÒIJJTtMZ\:ݳqRhhV>53^fޞ>I,~DbSh/Qx~)66yɩҞQW2S{r(xpqzt֮4xOR>ط{zR$=]U4xPe/.Ұ1FbSh] mD*iZ|v&Ʈ&$X-QfB֠R|҃[O~5\RSꊖu*$N>Œ\)PA?@s~9 92]s-'/Nk^O ͞፷n[ \PLxqA9sƶ?`.dm b%H|AO-T K 8bN\a}Fkvf+Z*%qt[Q0IuHKaMy(<^ߏvͻL';2_uDҭ4daBl[o,#^m2RJHWAٹX )dKB' (׎t-]| Rd#T0#zPk3v )qWY7㺠V]uK $p$i‚+9?@s~9 *"n[A$[ƒ|ZXUshV,,5\BKASi'eON9##r|? ̴v4!շIf\e>6&آ#TGr)6曏:VX#$45aFם<}~zcgwIʵKkhp: SS9Un/|^6ǂNJ4 MZT8VzW$:Ty̯\s_@2+rMSǡMygKmDy _rʁ7_e}ʿ*@~*u6Wܫt _rʁ7_e}ʿ*@~*u6Wܫt _rʁ7_e}ʿ*@~*u6Wܫt _rʁ7_e}ʿ*@~*u6Wܫt _rʁ7_e}ʿ*@~*u6Wܫt _rʁ7_e}ʿ*@~*u6Wܫt _rʁ7_e}ʿ*@~*u6Wܫt _rʂ>;)Rmhۤ);ءmPty55v9gt=ןCcf'nhQvRuP}u.=_}vs7Kr5!R<W:{1ourRdSsHJwנ'ԫZ6l!j{MH5=4Yx MkY)RXyiy(Y(%Sh@QjI^v12 }֐p3%Dh4KV#~Z;u>oNɑ-mGOj .!~ flp_n288'yo3lsq,ȸ6ʋ _%*^| f{=LfuiKZ Rڒz5=4 Oij{MSh@5=4 Oij{MSh@5=4 Oij{MSh@q@Pmֆ_p{^12,#Q~n41>}MƠNwCJA]s1䗳-`VAb SJ]($39lݐcW"ĉ;h߰?FiЭuF| z'be1fnY0eĄAu lס)|6MrțeW+s>GÚ< ^;Bɱ/ۍYVi6ru$/I 夥sG>J 'zce " aK%TV&m*uH:%Ѷ"ʢ~u6!zoo'@d>J[UL[MGR;8h#^l#do jik|} |$ $:yh 'u$uEZ@P( @P( @P( K3b%6,ߢr'6R}JzTX6Zlg􅕕J n$뢵']FT-Onҕ(f^SORG堩3Ci,Gzڨnf3ZJt.5VAotl)%e]HHR5O"m73oSqFSpC hG8\'x/gy4lK1v\9*@R@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>@>A P(6 h\8#͙^,_k⺴8PwA/'mŋ;] 2=!љRJTJ7:A>2}"|] wT5J$ A;o&lKfMo7YArdkXզ)w4: _gRbYχ$$mN!+Ep) J g9q}`Ұ~L vlOF\iJ \R[HQ mnֳ `3ą&K#ԇVAւv)'5cW(6Ɛ7 #Ϻ+P^np :qhX~/C?Ai4JΉl yâHb]rj6al☏9,ʎSZ8Pc@P( @P( @P( @Poʼn6}x9d8t1͸\qБ BfXvo+2Xƒ˙J v0˷2T'bOe3 C+*);O0yx% ɭ=gR e<7:;׉:y(4>@P( @P( @P( @P(qf[e"TR#@4'P&cw[}5UT{|eIm#C䠷P(4[a>[%핬A_+`[dTEGPq` I R4ʤ(@% @P( @P( @P( @P(6 cSto'; fC/ +sQ%7>oȕij9{Ka$BAh,J#-VxM  2!+tyDK&!erF9ؘcvw(I~VqPF=C>?g\IPĂRwYVNcM8^eo滟#tA1KYvvXTC"`{'^QPYav-Xb-jZLRsUxq v(/r6Wdqg%IUm}v>n4[J!; uxnWӉO]:PBIA @P( @P( @P( weq^iP7e6Mz .sYyCʾQG_.rkfCW9̦=n<{z+dm *o=]uCR,8C#QAnEeGDIaOJl:sM~WAWwr;1r\g[r$-iV MuXeT2^(.sn+>n+>n+>n+>n+>n+>n+>n+>n+>n+>n+>n+>n+> Ǹ4{(G4{($8f2}v&/\r -'}V@V:~ uW헛dMŃ&AҠ'|ۄbaR$L-D$҂1۴]"rmr-ڻ5렗=iQ*S=Q).K:kTE5#uI:@O=r\~tnV<;|mqdhG+g$]F;rc|ur"I%7N䠰'|n' _m+2|kaٲcGSAE84H:'ո4{(G4{(G4{(G4{(G4{(G4{(G4{(G4{(G4{(G4{(G4{(9P(7j]Sȓco/r{d:BRB('9gm2zߘXp]ȓE%jQnxAfoY`Ľ1k+z&lbK{!ē@:{h'rxlc7Xy%䆳W.JR+Fh4Gg2Ża+c*͟bo1 e%.H ը17-_ݓ ϶c)|, C%ZFXASMՠ:6sd@'zîw E*mwWbޗꔔV:J95ML!M3+rӌo)!0ZktTuh*9#g7h1$͏;Y2aJK {KK hԑt,6; fy͡7k"KԦR4[n+E%z+CAKV?"Ȟ_;Y *:RA@P( @P( @AWD:=>4qz[~jMq{R;I'W[96|?XӢF|Wkަu|C,>">ǎ㖑qmC-Ӥ%9); C4AblzaebGr%;ϹDTfBd9!I#x >J٥u_JZm %K'Aelm=Ӓ۬maL6ځC^; ; 3*Jm[iQV@[1hQ(NgM0%L5=I0AVvAαX[" n.;* VuНS~α&/D[B[!NTw zHl[I $ ~f*~nz {&|{(Vtl!*m%a= ޏ ~I_x%R JN @P( @P( @P( j na 9ϓR4<45gkŢS qjyKHJHFPHQ2xɈ$44 '}tRxqƒ"-ϑ]-k&*[YuNV8~Am6ݯ[ Vx뻠N4J]E"]cl[Eu<\׫ ڭ[-;.A%j@u'^P^;YZTK.>$pAȮ]ɔ,HЕ$QA6r|/-ɻzj[H ׎pA|FA he1נJr<4 $- tRxq‚hy:;KN* (S* B$^:uj<,m*K37,)<ҝʤt":Xl*HkAց@P( @P( @P( gOQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ OQ mA8G&9,Tڝ5''*[Ǒ[N8)Zuu4mrr)hokvaKI y$7>MuMEF;f. swT%.<)#ExuPO ȒW:k&)*=¹O 2YQ AMG\ <cvÐuRP׊NnF-KI@hRFn)!K>DOÍmg.9;ݿ1IS:-.wHXIQzA>Fd04f6L:*M '-I?VCldnmW\$=whQ.6W*$t(5v1f9uoCaȂw\m҂;uWBtY/i=1 ~iZR@YZP% ǰyfdppW)Vy54I #pVv7L)ԅ;h=^r9Xwyn\헫k%iISRNsH:[3 Mq.Ԕu(iA6,ExfHgpJ^gv, 8wA_7v-ЯVDrœkx) RN Rt @P( @P( @ߛ{-w!L{gz:tFu{8HNHP=t#g6Kbm-J8K-)}*Bt'y#MGZjCrH Ivņfכ r,TyАYm!my']5?.ZSaݢαEeuA~$!-Ŷ0\ZWGfOǯ?<ļݗ̘K*}m{ԒOoK\ ڗ"׶I;hVd֜߹uqDNA^6 i]>㴔 Sv15´l-ZЍCA3}mAX&͢}nC-%PHuA6?Kn_/Ͻ0z"9+C4(*:zs,\'\1pIieL5IOybܶ1v !حуkH- *8u!iLZ^9dAel=+-"ΩF~QSZ" ^9vsW4^cbbo}3a<VFweOm6͆i{~EsCDW).$< NiAw?vI"ݎfP2 GT2"d-)_PK#re3kZ[,"QU}I D٭@P( @P( @P^1: hs \1XS?AAL1=B+HgW?<~/h(e/6HS!#Rxm|TǢLYLq^AJ{q2yw@!P?#J]bqerشJb8Դ!@4p-"G̿jӱgG%N-GwRsJJըqPp&+;aN{7]yzǁvmll3vzGG5Qצ9ɶi6ڞٴǚͱ#q0Gp$u4v(د7EѢGrV8y1%aO^QJ {|c@jIq^-(hT8\c?:%bebB?S|1&xb\y׋vL6jBҹa甕 S\]$U%"@HBqs@dAbl‹^n=iI! E%N%$: M^"liV͈P\4l \7h}=RPJt%MYζIaԭ`O 'OfsaG*q:rBPSH'e[k*qKK$-h* 3 hj&>E7"t$A⼎.ʹLU/+ ^Tdލ3V\ǭ[z\#.Dz,&3(qe-'OA/;ɧI7ۓK\e+R |hP7evqtd24j[6#P'BC8̩QJq;I`h:n'|n'|n'|n'|n'|n'|@Cɂm{KpRN/sEKʕlnqƐD Ц.:RR|`Mt- Bv$˘K:*Jụ?{gYQXmwfM񓸥(A@׀Ǹ'mkiV<#㋸PKtX.BxئݨC߰{kkWIw%zO2T FNAgܔ6;%$ʳnYz.K#)JKa{ 6HqC@P( @P(2>yAf{*rS{6P3pmBFJJTA?̮;pr ;hW;'N N*0ȃt֥w(K-IHZun*pi|0YJu *H$~ ?ke/Bڴ2kjT`WuCTxJ agn\XԎzznI!;{]UK 1ma/CRL96M7|H,ܮEmlWX].SQRc{P$k/(/7TXhaK]ևH,%R` }}d{gɑ{d0c74x%#0hOj}gL_}ql[6vyNVx4)=Xv??Esr;jfsKe@ˍ[A+N; ڑ:6mGq`NJ $Eam,Ց"g% 8 -_'aw嗴{3.%92ki7Gkp'O- P( @P( ͘'f0(/AdEQ 5_AQcy%0E}.Q^U ҂pٵ̕G>o%NVuNh+3=ܮi~RR֤nxx-)vN:؅nv[|UMh,~vK8D:qEJ J:ԥ 73vQmRm?(@;::l 01ԧTIp7붞Lfe 3'SC`o]⟮5@P( @P( @P( @P( ғgMCmHpmV|A-s‹!k0%:$HW(,FU^bnjiՄhAӀ#:څ:r*<ڐЄ6'N4e6^sUl"olj $ı2ZG"LTqt)SzuՍ[ ]cR˼COtӨkAr4G[1J % u]kpܵGs~lk0D>ayЮp4M(P( @P( @P( @P(~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~r.N y+[Uu8D| cJiŀӅ&;rK԰8 ۪I 06bmI1(2[XCu .LX9MeDzs{8H$i^;VRpybؘ=&[/%w.Ϳi]ު B8Nv6{d8phs/DKZ蓯u=Ph̆R@h< @P( @P( @P( |C*=?4vnvEeN:/uĐuOǍ{-g6m4}1l%AG6`C$ꮳA)mc@!y%V~sUX @e O_-?ڽlyz??8] }qŲ_.G}`>V &Сb+hsg-c 55NeQNԓǶ4r巙 jA,Y'JK 1NPl͈9ic 9lKTٹ敏RwT0@H^ n9FlcZnyp 8x'm|:է )$꒵{xyP( @P( @P( @P( ˃dY'fZZ-ԴAov=x'X6C|ouo䠣PLgfg.M-H&4}u\:kխZmx81-ӣl0h<mM8-% I)RT4 %Z أƑq˂đ˒RRGlx'SɴZ\9VQ]#sȋOYPGhfϲ{[nk" aI>p]h u렝w}Lp\XS+r:]ӯrn OfQړ*W+*N]AikgUlfl]:)RIӯւocuf#toqgs ct]}y luܟK"RWF@FT @1?Ō7*1my"0E ARI8utFŻ]gG8xKP< GTqQU_T}/?@*>xKP< GTqQU_!|DI4 "0x s>9?#g=AL΁fK`9/& g~t}3?:y9?2p3%m ,@-new}/?=e@@(_:xG2;>΁6Q t񲏠e|: \,Hn-nele@@(_:xG2;>΁6Q tw/?^R$xGT/؟΁NM b:;ɾdAK΁nK`8;$ϲ?:21#,s>-ڑBˎgthE?AA}t=8ʢ8 K[zlߍ~4ܯ8h?w|w|w|w|0b<(*{>y;>y;>y;>y\G;oƁ~4[w|Ƃߖ¼@@@9q;oƁ~4[wߍ;yiT=Nn/q۶'iU\Bͩ\B|P^rp|{M y7uH]2"ƺe(B]P}@ O8:|Ƃ?] $tQ?kAbi4>@>}Ӿ|>ELof|W.LneA+q)}ZO tr([0_) krŶxQ|MBOTӾ|| g_=c;h;>@>}Ӿ|/z n>@>}Ӿ|FNzwϟ`t>A-j$H7`Ϣ_Mz02AtیG`% Ǯ(=;ϰP:zwϟ`/|ON(8]v꿯=]d_bXqkGqrKMsԕ>V_(,&pu|b5P|\}k{fҗJo@]'| ONEDЗ+HI i(=ﮂO/x :c\\Wc"ZNCͮSBVTׯZO]rK#yv\Znq̸nA8_6\lQ2ɹ^KeĶԕ7>Z$~t Q;@G0[L<sVo+ɿ#\c?Tl|v-kίmw;LS; VI;P(*m)6{IS"u=hZHRT?adͰr^mP-H/dFe ww6tq4.ګ)7^TÄA;yu77up4 Q_PvPAkЭp2lD];q[3IBBRy* Tu4M2K׫l:#ͥ %EI)RHſ~ƒ<T uJBoxh9f?}8m'U%<t=#x(8#pyC%AY*1CBwi?mA8IL=MQxPmG}J4{æk4K8ڀ)RORq;pA?sw3 sw+#^SOCI?X=T}>QN*;>};>}-dξ[lGDu@^PK(Y)P:Pu^}yRI_[i5PTw|}gϠw|}J}J}J 8#{iw x+iHւAuNOiAH=+(+(.zZ[fŶӘ CN5#C _}gϠw| yQS)#Pϟ@KүXc*nmSɖšÊw׬{h-}+(+(0* 6HR({Ic,Rc7ҥZt l~F}% j<`wR 5Q(+f?`3]l˼hs҄s(@*N젷ퟖ ˥le;E`_r?"C}*:A4'S˴<9~KƈIQRzIO=I8yN 9h5*j^q @ӫ4I0xLaIS;uWZ/@mv\Kfq# PIӷAot p[AE/h=zwfL;3ǻ&16{:Lòi8RR5 =z~ b/ȧF['M{rB(lI1Bt'P|{%{tl08ݟ4rkEkJR4lP:m>6~ں1uj-ɔ Qx :<A {;њ\]"7A_ەջ!!A==Ƕ ȑY.C_3[iv96/Z<y:A]zɟ2XmmGm3whHJPPP|@A&cN^K!7h>[65ͻgޤY n9ն p @M5At?eIܻܝqeǛiQs 4$Pk.>݉_q Cvݏ/l4d6H\i技 tm;]˻lPS5SA8v:|˨q'U!A@u+gdw.[;zD&uTV4:j HւC'=:ǎ}QKyZ 5$:HPYls^Qs-N'#uJu¤z䠾& CG8o O%)R$+P|=A(zmh&. jȸeQ,nXM ݳ|{fvv7cu7vZ\LHPZu ;vK rmΈ*N$P<`SVp`ttKIo:gA7iyq=B4Ca;q;șM .2.0Җ Ӂh`[m,įt"-awii<4deUӏ;r 5*mJBdo(k<8^vm ͒me7BaW{uPɎQ[ҭqeaJZ؎O^ ?PYh]#@P^<  ٠tIsNI>٠tIs L'S2|s-]T7|P}t~CWϴ+q2|R|w ]λZn$>Ľ9'\fӒ}%϶h9'\fӒ}%϶h9'\fӒ}%϶h9'\fӒ}%϶h9'\fӒ}%϶h.l. ;s:in?SӟQa]v\Vh\ն] RJT quaZY31Z͡OL{o`W;nRn[:;VfLff"՚5[lIaFj+ɪCσtb.ӔJ*΂ ݟ]> .Vzmd6^vm+ djJSJFA6Kم;WkF.IZuCCtwH3͇hBlF\7ޚȭ5PM6ǙD6|PTG] mca) kA Ϯݟ]> 䥌Fb!*TY[e'Mf' m #E'R(o#(OAf:\z6of[fBPG5ꠉrƣg;>BM؅J0j~g@Ϯݟ]>v}tfӒͳwyp& 㐀\BG G$aA=/\\Nxd$t9Rh17v}t;[6hzkBZS7F<L'g<6^4!8%> ǀ]^vg7eW],%ʈ $yO]aNBGtHit$;렒uu. zͻkmHENRx& b]M>v9E\6yx%KmQFjC<[[3m7"_mtp/m#;wY×+ ]ZR-sYeÑu,O@|[EN[Wi11ᩥj [x㷤LKmNG_s4w:=z#VN֬m_76ߚ ?Xܴertu Uj{Mdȕh(#@|D|7hϴJ7s56Dz.|r^U8ކm -EJeM,N]UMG \s m7.m'hŞ""yݢbc1܇kOÅ+,̡2nN7$@)&9dž&&w;x }'eh˞bb9wfg5˕=˶̓T_-m1_LۄRgߛ{MV1'$q6 :YkSջr[K]4AP5ʛA~ze}VI2"JxS堘e\+; s䬭NJ'4u ?unoOo+y\VFSh@5=*l;k..-ajPM;h'ߑecOjn>Hm)I :kAțҝyFZ&\沈<\*Jz B{R|/Sh@lW%۵Ą.w6<;5:(+9Pd\ (JT5=Pajλ6( H A#Zy 0/9NTR7P7PJGUSh@5=4 Oij{MShA6G=ٽ~22!{ĸ$U]5>J c9įYeY7;mFIt{?e߲Zn̽¹510v3.6$)t68m<=U:^/,Xg{#lݬHƭ{Y]1\䇂iWOV뺯m˙ÿ,d|/ɾd7ۤqM!lxHۨ뺯mÿݱ0Yrr!<Ҝ)e;/NM5[^״xS #:V6RTB^p A歒Έk=S2uΎ) wGOg_ LgW:d]"n)TRBN(#4 imE[jҘoT”yBJt┕I.p.M:6{Kn+@8n=*6Lwc^.*-i{VȢFxv[`H7ʼn;IJNDկ/@P(';*/9d˫ֻBmuJKeA#D4 Ďìx޶o=s.;.[tHeCw{]<$#iAn- -RLun,CF~&hk%)7ׯMF@P(&2=jiz;oZ*j}D@:n#@|bz;$bLdJq1 EJ!!#]T8=}T*o7E(џ{T7Fjm-o.PZhUNOv .5\Q-m㭽odl2SJiהHI5( dlnA#DE,QEA9sf&8ᰃs^{SC8|Xm[~C9p1s̙3g}4{ɚS/sF?8 tUg~\vɏ !(#"}mwl /,eM“rU8wQ!dEn{{}?ό.-θ??IIdr%snfۏs׿=}zؾg{L\*^cs)F.]?ȣ~㉇Ey|}q!NgϻcCWm{yk.:."|xއ" 2rP~խiF܃ms薣e~ņyځwp[~Mįg/7}Z+_}/{f/ڃhZڳv۸lOse?teOhkeD%g]we}VaX+.γ{\ut_) W uK#_3ɧ{1>K{txuFN'c~Ԁ+xOѢۿ>WӪ<~~-W]%P;.M'"wc.y׉CB_Ћ/~lWNkn۰fX‰}H/ʧۗDYʩ i>īP8Xd] \3C\\1xqM*c#}=I_|̻2|Ƌ<)O^O|>~~M?Cjf[0ĉ =D&W7]yAwȱ^?au'3@") (bL V=s~Zc03]k!v K/j͡|y,O\G']TC9?ʼn8򪓧qooۺBk<-4ccO1a '*\X=~I."lf#Q0] (8@g~ߺmyYlQTblGSXlc3\z]no>`k)3,`+;JsY?ؘN)q {9 㚻4V#=_hm=Gu- #.Ϟ87rwvd Ʊ|^A_u>o>A#DaX;ևrn>D9)r$D pӥ1cB^v#)t=ʤOO:c߼UO(7p2 T7aV߾U!Txn1n|b?=*FKw.:lfLdݘ#~|p/.{ݢ\_?*8 `^QmJxwoݪŕ_vN!k2wλRUU;an_~]>A#DW{uw<\+ e^8,9[眳x3zkN\ | 's-wUO{+lUwp=W{Y_a o^{#?Ow~ u<糮uF_ \5yǾ7~ո~vV-+̙wtu?S^1K}ܔ\WaГϻ?nP*xS;h48ۆuq|' 0Zª!3g,9U#J'z}?ԚNj=ۼV(AOҡQ8^LnBAifpfzf8`&Mz>l}WQQLmpxm DCS+pyY|}at!B 彥ҋKGi;@i*U2vls\~ /M{eq}u"D11r`B5H&1V$Bja]H/^E!BQ"D9B" nh!"DI'"DiA"D1r"D9B"F!B#G!¦ q K=D|Aee "b`2DL@Dl`fCDD`f"CLDD"&&60D @LLa`g%&5cZk"VF3#,P "Q!–aDP_[ikF!&===\Rj cƌFjM RJ"`~=N1 6V"1 cH 20hf& "bP;W( 8kl!7fD&6Sk/A6 F֥Y ]@!&/3aD$˅FR.2FZcIkJ+)ښ̬FbF3@jm4QJeD!ZLJ۹ZSYPp!&ȴ!MR:af2F a.ц5!b&6Fe01*P@Q #X2P 0 @X QYH鸎D R1 PX,%GabdKk1 E&@b`c`` I^f !Z+)&cafkMF t 0*y4cAr9?j\4 b㸎( &t6L 1H0TƄQm"lj6ښEI&6RQ%EDfCd Z D   2hQ )0!? 2lۭ9!ր@q(8 @dV* ("˖-!‡cn3Z[/dFA@KD.L @D,Fa1ȄxC*emR*8cOeVw~z,`RLd=>a4 /C-º ~oCOK&sQf ZCQ:@Hƈ dWN3ZJ`FDcȐI29#fkPHv~vΎ! t@}6'0;##RR;;seƞ9o-X@l_=i!mOSrܖ;~诎/s6W . /+++M,mΫT06^w>F^ 梆 h~&UUW~Ig L:{o*Yʆaرu C8l {SR"aC`RJbB84XUzDTwKgwf~3Μ2yG}ݶXŗ^0Z{yM#|ꩍGuRDhla3X F4l_X(5 `L88FeW^u9X[o;m1uZ|)K/^wܪ_vu-y/Svi\zgL'J-=Ic<~}WA瞭o~_r ([CuYxG~cR~/K\9͚ƮdɒYo1s3wW_^t3<e& X#b(ul Sc,,TcR!a̤|?XuuG|1Z)#t6ߺ|E}]mCm:B~ ~v\ D(@FhQD=o%q6{kmii~Gy\ry"?e8|qûE,`AznLuMUW^}A۟~橒0u-淎#_ .谻C 41<{ܽXň~w<3y;~wƋm?7jnWsX^.9{n;2 79}c֧0{߽{'}>2FoW45Ⱥޠ';İPf| !x<2RA(Q(K./Vpž@B! MMMerK:뺮b !"tR)em0Df`͌R aW^{ .Wɦsv]>}pرzz:0(qF3r?3Z6r-j>y(F.3ߓj F| kfdsg</jll |^ʆ{6u{WN8a`Y\[yVz;OzCm77}zʿ>fNf|);6>2r/b摕MMM ֠J300r#Xܰyk0 "2F5m"!8?@+}uwWBbC*Q.3 嫶f˚JWPC1z #"dR2d Wz(ؐ&T$"Zy.?Nur 3 )Kx"AJ \]K%R2lπR:Dvxܽ&Pˈ@/}=[nDY]]}Օy'ND%@JsGֽHOݲx_7zZgkݬ/pJ|9)'xUߩ-{e wnˏ̬l}R?)1*sWQI>F4C($_YD&TDdB:%2pʶT*Z+!@Ah])Jfjb[ycYR$2d4¬8*@Ds[Zn8Jʪ/}ޔgD7$5\ Eƶup ,(,zE<[ > =+NZGV%9эt˶BӰYCcaPj΄b檤Qk[Xs؍6X92Z+6a6WXpi.WRa&AuU1{6+Wrd` h(@ u'hUȄh!pxLsjkk汓'N1De?JDtqk 1\w[& 3mw_{ 4c>zӥ=ܒ~ǃ}uYțИ0#yq7_}tכ=z!/ HAV bv|UcUlygkˇuFhf  [0 U*7ѦXtI`kR8cC@ LdKgB䑅J lv?flB$VBCLy'DDՌ)>~y_[S &MS~zQG}c]v+>\w"!'[4\3Dz^:pQ>lwSC~>wNf_ IDAT ]]?gӧ8Y߷@.'zݯ9-{~]~w<\wc借ʇf5sy {~rBFNq܎qe Wdv[t0 H!D,Z " =Jjy;gbt]0Iύ lZd[Ծn l.9icBJ_ʲe.=ܫg[m5ŗ^vI'njUWr teB ,=7r=,www7Λ;8ttkc{~ȰO յW9lۘ?w֎ $˒&N.KmͶ|~E~ -O8M)w7EH6T\^y!Hd؟#l8=8aڌBD$( c8՗3v  aH-V5fKYю,Q糷9?Ͱ(/+?渣+kl3f駝O?ὧv}w|'Ǐ@uUڦz?uԮ˧Fʊ!b؂ óK9F^^^L&O9~vD/|GGSC\12o*J6LxK_ c( 3F&f"F@C7k;)rB[Q`"Ң} nI𵢈 !D|<8ֆ%jRx1lV%ш8>@!9 8\:' 4."~k-y/)euu,1B{jhhP(jkk8(jkJnÂy| h?{?+Vx< /^%?K#M֕Uӷ1ol6C)?r)ddVJS1v3abe[c] Me,dUJGDT+Zdl6Z  Jb-]7P]XWSW_#WututL&aV&(]a\B@&2qoj/}O?Ag"('9Ygx΍|=v}":QIJ%euƆ_~q#Bm`c4@CBe,X |^խ !MRfZc؀#$JLѕ` ؤ*-HP (3vāݿϵx.\ޛ60ؘ"B|eS6|q[׾(mM0ijAD9)Wj%F:j+0 hJHda 7>3R!Q:B0 [ 6sܚ}soz#3n/\bf 0R:,ESqEtfdcg H$l Ï[(~+oՐ.Y!fk*Sl1&J)$ B: 2zA@k-e4b,]61QWM<#H*X6S:2D61MJd3# G7|sXh"l|٨=2X԰1ڬhM\:ә K +=V!1b~-)%h P@.HLƐ@@d3eQ#ڲvLolxY]S@Xr$m"R:(fvm|R%#DHffbF&d=}2t\pH+T CMDa`a`yik?PJ'pyYAbK2xDD$%6D a#mƎ-B͜K~ Q_e:!&!ZZ}P)?I){qRicƝBHBGb2 ZwuøqRTEEʕfϞ)j"R )jQGӄXbbNhqF"JZk azzs. 'N=a\׵^u=GJ?tscL(([r_~Us̩bTomR @BF6r61F.G@)Mљ En̓K@& L *նh,K)F) ’ݻt @lQ Lb1yNjbIωAWOÏ<Jn1cƐ28B DG W I:"&!¦g#R @H7Wh= Q62]]=;1Mu5@LB 3"q;?`+b.Vū*UeIsE2+K&*ʒdL._/8q 'O2rd9(q1HCJ)ER)JGS[71+epvc&TBJ m ,8.j;n+iU 9 Y3#BsjkZF\wUF1.vt~cǴ=uyeUuee% CqP)e҄ƑR2b"lb}K6&yxg9m4v=jR"l""BlAOo/(A( HfҚR+%#gD&ݗOeAU#(+ >8R2 u[M6g)'Κ֊+;:OVSS=bDSSZeKL?y{z 00`1jmB"A!Dd!GaSW-r\Ow.&& 5U]Y+ֆ2UmM uhbر|Er~6ZܱG2k[dlE5 M_: M -l_zwho_KuvtھґJ)Zi)2("lr6@F^|Q BzkK@Q8B35 $uI&<"uŖ[Nd+0\jUyyy~U+>[tglM:}z2|AiB p]H<lV#D"Dl^;::uDa7Ɩ=/nK!(ֵdGbUE+cBfRt]ݽahdy2^U(/3v>Dsի|]mMxl3k/wttӡưI)],*\!iBd (9Bj B60Vwv,5a4C$Z WVbtP!9fLS&NXUUYQY&С~و0iҤѣ3e+{SB:Qkeb-P AD"D؄=Ykn9W8Blx?1ؔLj66uqk] hՙ?o~hujjTd2iƏol7뺕 ,lkkoi#>y ӹ2V`` (P0Fp򧃑:(>UߌO"Dfd3R+B~:E ZR ]nj>{f2ٶUsahֶIZt+++ŋE "\/vuukcֱXX A@DesYvSR;}?O ܺ1#:{|:g!BCU- wl5x8N63S11XYY2}jl9/+~:˙;m?ytZHQ]S=݅ aXQQs"رc\oo*O03hHHZ3)*.[w-׿«v'EYEa}Oӥf#%4ĖDZߘ]!0 QNyErt,Y6iDGzVLvll+KM.S컽sYDI{rw8Pdoﶶ+͑;aFmlSU2rIX0)m)jWb1%1!$p%(p=[޺rٲ坝]2jd&kl5:Ww{駟['UV\/\f&|W!O @02JR( $a ?z./2~`/|>кv/{y!߿覛osھ¾Y|0lyU읿g~˾o~|Oo݄f!BYXX)Ux>SD00}}i"jm]ye(HK.(5zdMm%3wvv/\4))S'a!˖%#G6N#G 0HD c S6u.*ZFF|;PLe GdU6. =15IUs- RZl ;>'B`B1 )j̙3K.RӦM[Ϗ{yɮGM-5.>=W11,EW<p<Pgu̷&T~UT4 l,@6AXX+`t&,]Լ瞻74zzz)S&I `ugEo3oūۻ@JL2R*)e" 8X\' D &<bڴi~eeu]`*++ch9O\oytu~/ʉ}p˺Ϻ^ӝe<ط:+q!uo/O1L7}W9"mၔD ,ZJe"rXDF/e⦦ƭڢP)7Z 1'[3c.s9s ujj&L'h[Ammm&cP@)0ո (!8DjӦM;S>+bx>SO=uP:ķ.zw>_O[vĸa[?ȽqQO6N(V~sqmw?|g *Q0i6{~F\O"D 70{wq+ꫯ2)Dŀ=2OmQ%&BdD!]WhaO/t8Rf'@s]Z붘:%&tIU soo_[[-<󽩬6l lx)%2@@-]v~ZSs׿Ԝ9sځt!BCI'"BaÑ* m\q\EB"`fc 9FB6SMe/o;w^mmMSsCRwޞƬƎmijhھyl&ITU2qUUUooԩ;zzS}/8yѺVu0 !F00u >03:R0CZrD"DT e p6̄ԟ(jVϚ5oeʪʊ -\'Dy1/VV(++K&Ksn 4˖Ο7\׭Z|RIJOJ k[q]O)e41@FD(NKʇ~?񏈎#D2uF^pֺ͊ m6zbgGDpGԊ\6\Lcd>ttFswOud<=,lT__3v9s̙nSSSKKK===#|@2)uVP@)ZJa# Jb`&RZ w,)_r%ʋ!Bץ~23D@fDו: X,СM]o !-) !r: h/Y/AAhCD>P2+O=Q__?ztʕ+njљMIѡB0G )fB0HJ`=iӦtMXaYr.E"D2#an#<X @$b aXV#@0!l].f0} lt3&~\.WSS5bD+VrABGJRQ Aa#:J)Dd6Z1Ah6:^!~"$lA @ 2@ a\Z+1dtB!ֺP(9%HēfBؼB ]PU Awvuah[|R+f•1aFD!6!B&?dϰmfW=uwaQ+]dK[zQEڻ  JQT***(Pz;vWJXzْ#w]\tgw239s&$q1ipbJPQ BAR@y 8 @\@AT*NW`  pEq۷\NfN7N1cBP)(BA@)4 Ag(!_~p ZDGx:y^^}mv^[b۳"*4-#1%Pҷ 啥Y#$1"zq-L{hˌ@ (<9~*0Q;9k 7,p_r#tdSʀG{__;Gԓx.7`YumAV 2HyphÆ^/?9{Ÿ~]O+"_[!{攧^[vوY~y$^]iM O?wNsJb\Wbcc<^` pߢbL"(rVSJbHtoG[twP> -=F8Qϴ  BI h4:9'BѨBmG222t:B!@  Bt: a8N 9y#NqJ`<> (3R " p 0@@FAPr&[f4v/y 3Ş >5{hN3nڽuͨPMOKyRQHL磊Hl9rke]'%V/u" FS(*FIM \ c59q F`ɏi }t^%}``ݡ#11G2(U=ۏM~b^83G-$P)(A@i4*@BACVVL/RUcF*#F##((《R0KNKL;ArdA  ǯp A(p AQ)ceuI:={[TYmyuuysoܹvD/MV7s&e.6%L-PT2*8xq,&gC3T~J7!3s^>3tVm}4Y>6^4v pA ':d֠.\jòM~pAׁk7^>|kwF'Ǽn;ZDžzwttDDFAsw2! u/$`ǏI,fJy% TAT? #(a8 4cJ@h4*ǧh(x>B'_P  R(A |i:A J o!oM qlCr_FC'r m.|9Gtn( sIVq|'_@AVU!Rrb{E!,Aɱ0N'W"Br-8(@T0 N @ee@FT*ռP(dm p+Q*ǎʫ:/ gn36icϟ?报wڝ@ (9%) L0Dc8A88(E~%p 2Ȍ8a(% .w[ d P Rԩh0 Eȸ4PP 56124|llllll =@ -@} Ғɷ *2@"C Td@E@  @ P!@"C *2@E@ HsLC%%%IIIN;t蠠9))SNNdee˦lfPۍT*H z-ŋߵim/^ܹ3(2~Fߵim&#d1##DSS @ZNoUT[[ԔTTTdgg]tQNinn]yqJj;i;wk>"ŝ17,!!˗/8h4&qqg&Nmx<2ԆZdl m;wTVVtڵ;wޢGܻw F0[7S om%M+Wd;;'O6rUl jIWLJ=zT4K.˖-MBrF[=qіS䜜슊=z{F#hj:FEQw>ɮS~qik9\occcTb # $ޣ5GE9z4Zbӌzdpa0rCfrN?zט_~zj2}=z++(O:wɥ\md:fz[ocxAC mQE8\EQaT*b0L&3[*x< Æ 1E.ϸb=EKu|uКA6W0@p@ T˳Tѡȶ _Yy?iF80-I\aӧ1s8k9[d|ys-. Ox|r򊜑SPP@>)cTi_*@SS+##] U@W7 q*ǹŕ11b_!}|OܝUYxϵW"d.Vfqٺ| Вstt$7)c8@pL1A8 $Yt)QLX/,"d3FE^z[T~HPۙt E4[va~] /Bi<`Ҽ|ȵ-oOɛ/f92ʺgBuG9+;o_ߜA kkkooo1E[1eEz]yt'J6NV3_T{vdSQ.vR!.=x;pq9Eeu(Lۛ`B.[iѢrbN 3®'ʵ]dPT-jLzɧOvY%ǿa^y~a-ZC'lÉ;]ccc BYY۷oQRR"G^ '5;(³׬4 iBNQ_-Bx=6k^&W36NTdtl\GE~vqq&BAeѳ+C%Z>wKT= -fͫ 3n>'O]5#}ֺb<{ ЫWM6Z[[.;J].[NSGDE;1DTm9~u;]瞩ϫ!~Fn[{~p L%Q Q}۸GKyw}gȺcO ^g}bePx&,`mAmmEpRL&BArM<O 7jMf4JMs-}J]& 3|"=CVųkQ96#eeeuzYYY# &khhN^q(Lm^;Vd4u. f<3kwz$xm],*[?Tdr@D3u&UR* r Z*zf5>O]E FԞN$Mv7eg yxOYq+YYQA咊rm'M HYYYY{ihi(w~%5J&qU %ƿxyy9R%%% `V4fww0H% Ï\75+ Z; <]J߃>J?7Ƈ}%Z.+Ώ:qiݱ2&.Gqyj񴵵.\Jvt}p\^C/h'~ϜQ(sU(AD?8g=.D8BP'W*3ެ7 ks)nl===淼\^6~!ϗX1rg?3e\P dkue}Ab0 )****g/ǫlߖbw<#zkڞŠX4wΛ\:v|vGFXҞ \S [6"E7Nw={(.eH{Ԇ>32bP#tuJٹRIM"" r~ l酾eddT-/71Kyy9J37DqUUs &V| 6_4gț{Õ{ /ɺzt:[nqrlafҍiɯNC>q*59ׯ_¦9U@ |5;Ϯ;#͗  9(FLnQKusHvKc#A88?V"p AriL]딱PP;ACV*ԓ޽ð4mmEj1F%MNӣ./y~%&61ܬ;lRj_9QΕ? btZcӉ&5v]`ӡ5σ\%R?R22leNԪJKKutt}$+3SEU5:6j%7'MQV: SSS'b('@ԩvdv[&v׮]+zùO>Ϟ=x>}llli\GGH,5eʔqփT1ىmo݊i;i"*))IIM9w.9Eےc()))**jii\.и1Ɲ;8%hypAD] YM!Uz%B^Zԟ' בV^-$%%ݡCK]EǧM*'"ƢN:tXFFw3335ro3&}_bRl"ۘ t4^h1ȟ[*-RdATo򱁾aȶfkL'((=i!E;t淌K@ *2@OZH|6 x]1D9W+W@A HC$?IS}?# @ 4eF÷h4۷%G-!Dj?^YYYUUU) SG.++@ F g- @wÇ,-ZBBB޼yB\@~8ܹsN{"C H  /^DFF֓}g<>>19s̙30E^9u%n2W5Tk'BU@d~U"H߹4)Ҵ䷤q{/;:;;_^mv6#-,1p0+䄷Q#':NI4s~{T|7tx1#G[y,RY4WŅu`5zꖿWZ+Ko4- #'Yk6" UܞƏ9}6^IqdvofG37[}2UtGL0 IDAT`sM.G9-'R2<#g:Щ˫Wrrr,--Ç~N]ɹrp4mmmYYGΞ'@Z P!*2@"C 6G׮][P!*2@GO<@ iPQQi^Eԩ2HCAAASUZ @ P!@"C QdB6lƖT=n SNݩ]ir ܢW\l:kqv ƻ]I)n:o%p/$y]|ՄO@Y@9յL 1=50nJqt4 yې"btEBׄ?^p4M%<]2eߒ683iBL}Zʳ|-@ZgnF.uz+Z0Y4布Mͽi)}6ȇR#(tHl,F gO051wea`M4v>]U뾢gYw@ziiyo-Tf~}ՀZjF݇|Lc5fc!7 _ώԵ\y Vr4 CMU ݡ-KaPMMǐIgKO@,DМ;V &u~hGћu E-ѷEvm)SzQG˭cJK?i\ԣOu_s.Բ:ivur+=2Y'Lv[7n񔬜W#߬ong5@Ut .Wl߷?cfs;-qM38tw)9io{::?>eGz:]gC mo] ʢijKOY~'ŗ2TQ䀛g\wGxj;i[O{ee{ISgD؜ʲĆ݃\=bC,pɁkɜ W'R#PRiD3j}oCu֐ɪ:mLN,Ħ%=;;q@g&BaE=CQCBg]t-PwJ/|>lj.grه\1^NQdY矒pb7V\>ޕ(23+ οXzcCotɺ?B~%s~{{հE襴zdjD3j2cG]555UUU ~b%6-Y؁:l6[U*4X!6G5՗MTB%Y954| /5DeAϙ*۱(ҬgdIanLڃ30]EJPK S5 <}46ucn_Ocv\I.ymYnYM̞L̼cHlE}x95 px6B Q׵5:9é2*)赔o_^,tOgXZ*E\[/4_iE\nQ9d=pB&lP}כ/V֔ª”-ַ]Mf\.@QTN&lFn37I+p^ֿgYeشee L" f7T8jX8>#!㖾zp꼗ی"G8XOC]^ uigk|:_yn{ŶZ=,\95¾!rIǼv22FnZ&,<uRM̞K+cO[CoceشVSinoA `̈{QpUBa]5w]`{6r9}/^ɱ .>{H3!)((r劥%5--M[[[YYYgϞWOO&{vUTf)"?i۬zC @ZoB|Z7P6n_H3!/T ՜fB =F@ TdMB@ HRRR MNMMMYYY@Aۜݻ  wFF*#C H3"JJ3-#C Hk*2@E@ TdM &^B7o8i-|=e2`u<1»'rS'wǭj(wG-M/j&l8;Q~i_rt_f\O/GRjt"|mdd&/܏I+I2u[U^D Wç/L ȦbIumm}$0omȕ yeey e̫KOj'Ă[j7ONl3'( Bgvb{!q2WT]ם5BDrD߃'0RSAFFA=PPߴK[tm/K~Z n GwjLAUSSEs^aRxW+x~ .X\S$q}L2/lY+K iJCf05s:"r$5&T]YsZgddl:5mFg(:DO˯kH|g/3 ΗN:Wƅd8n'׃'__ 3f9/c/raˤ> S2FQlɯE kJ:2W@f·r#Vg0|ᯅⴥO:.D}<  Uw, HP](+υ(q,(cy=*k -wX"`|- 3u-UefO&RYPUxY=LfIW&1#Lgu{#t}Sm2%OZl=j5aF o::~k(C!SE7r8P4H{ô ^(&-\T2X_UX8?OFiz<@2\IfiJ @PYNm2}m@ TpU7 RbHJqAHLL]Cƍ&&&ҼGE5Dʽ6>%S$r JKs(KSD9`We\~_xS\Sh~kG2χ6e"Qy%И='./Pg|تx}fR(䕽$?{|fP7e&A ]]P)ՊԏJyXT*BЙ]!Y7toe\nٷ:jҌ`40N2)tТ=`;-N+nƢ."?W eQ&*A)mvLփE^XSuY״ wSr n]tOG. S"{l.-Vϒ[4f͘SᄀNī'}X0q@BN[a+;O+%?Swh3e\$fXMF.[R S6;fQ.K?oqt̊fKd!(SYIZo~S@rEy|rq\"~\Q]3XdSpraUUarӜNNdb1FȱO!c;Pne5laVLp^Ƈ+"=ZLs|~Em,-C wOyyO;<ȣ(r,#+kӕm|]wYzj˫HAn/;4KE׮`Fgtݵ h~SYG[w[3O)CgY̎tz\gsp짅)>Qqۭ$yJ]R͍Tuzeϼ}x%zupY*Ͱm>EL5Rb_2yxgYi @gXUʲhj}IT;: 2al8"b'"5ᕅn7'Ll@ ?I% OYvqZlﺿq@ZcBfVwWm:j4X4"#}$iB a @ @ M8raa!t 12@E@ Td@E@ iۊL¦rwYtw8H~fٿВ?muTjEt6Uh9Տwhk66JJp97}G/'0>ݎ2a`8 kSxFgxδ<}T%K.HԵLS{YOKKuH*E59拄 W8Ns8氉p8KF㳬683=Lʎb~=Ąݶ̱{2ZS:2>'R+$jɧnF.upHxWˤTs9fPjq";].u Aa F=n\xS4v>]UUT)6nΊBPXOKK{k?nݵՌYilj/N0T:i2~m̢ub3l|$.Y_ǘK|:dНi N=_PPg,ҞL-{}?CfW^''#㐕_Z֍p<%+75f..%3'wTG3'{E*"Z3^Wr*-U~SZ6ۆV|o̓/Xu" gmhʒ>foQV~Ni2{gVd@ݭo͢K;]P˪vsek|?t޴r[dO\m]olmH%[tCp. t0ܿXCtfO/ ђ\&m2\ioa8AKm&ZVOk/1RǧvSq otɺ?B~%s~{{հE襴@Ig1chlUNdiTP/CUJcsQqq1Q1@( ݚέ]'pa(畿I;']J+{2Ky7Hfal ]D-fe=%KRwcW=ܒ{t) 40%]G]myX7&vQM4fj5~/v:ne_55N^{%_e|m]p41zVא,M_ܥə O{YfH"=j^"{$.8":(`(bovx1eb#-J>g0}&s;47a\džG޴xնݷVUoj2ŬO pee [ ݜZ7BPV [uh0F]'Ql\ZL4e".(5l Ox@0 f7TaP וKȯOXrxVdTbw,c= .w=V:z-H\7_g֥afne1 :hipƆL{sHe'ctBrrkUӇjiT@ԙu}KhpJ7aؙJMlɽ<̤}{ayMjc/(ubmq{y/kl>a;g\8'l(e/ݨno6gFL]OaR,i8ʡlz45 fĵTT r.adM+Zcu҄wlSUQ;+s3N~9p*?]N$(nрQOE7惡}?į%[$"I*񟮧¤Й݇^lE8k̕,U}ub?闃=(u_35&,= A 22 Aj?]:k{YU}}Q*^bPY; =fJ/*Jac|TN;w'N K5Ff(t,NIW$ˁ+2*+U/,h.M}iƆ0NګY2./)V_y45#F lK>x?<,8Ak߄ ˰VfܾQkǁ-͠nL (q׏JyXT*BЙ].փ+ΪNhQaeRBm,ٺ gr {w^[W,݌E]TE~*ˢH VA 2LYl뵳Fg,šj^R5-ԼG[ 2q[%y\ȸm8 Tq+G(3{_r0pӥY^ +a88_KMKغIF8!jrJϥi/;CCB "Jjo˸XUI.,'ȋ˖ª”s3Yo Onj]a6Y6wrJTVrR IDAT{uT=4l%dZkz\X9-umds{OEb%!ǒWd2J?xi:m˔| e|bz\*Jʭ6l;q@gVSʫ|AǑ=G,rK2j ju3Ȋt%n[+yMJ|]g֭*F#zmA!gЭef l/2"핕x3~rnk=e,ٱN]oP͊zʜ>-㹰SWQP4\^1\J3kfѻvȠ"e+X7yO=\KEj,nt'=mUu]{^_R)nW/:(7'ޙ#yM4'?7Үom=Wt}lvwǣzs1?zZv( {OqV+1i,uѸ-3s.{t)q_lyO֍Ʒڜي{T<̈V[}NJyݶ#E 1kG0FEݑxL`۽'wVg &A2f+[ 05RSUЭOIAR,bWOKC]b rʍٳ?2:=d+9Fu ohOc 5 #B/l6[EUk?˃Z3 Vٜ I=TuH |e98mPљsw*ϵE6Fw2oϑ3r$_<[g6v;CsM%o_w|ʫgM`7*}ɔ!<6aڇ}Nn<9~KoNZvAfʇ~_0d0Ecb@ڙ׻퓝⟐lÁ.w-hKVӓ_j̀@Z-+kVǽ7qZCW^@Jf%w,Z-hyo©XF)r3Bnt&Ȩ`l{_sz輲zl6 s3MG:Jx[lP4: 1N_q 7>=׌I㗿hgPz UK-W ӵsWv R9pVD)yb׵/3)/[M+CqhvJnr4TQAJAA^4i4zJ" cn=8AF\ITT0pPl5|ާ^h-Ւ\{w=UUНIЙk=(B8NFE׫}%h\W)j"Z>m€F|!M*,K$U D:>@\5bj]]* LOZHeҙofˉ?^|Z.(\P uE%7+XĪc~YڧTQ=xd*^v W|hrKw/!1c+:^d~ލs S_m;Ay+L}v5@˩Vbj {j=& ͵ ?빉f((z (rCqwKq@tQl\:Yů]JtC'i49 3Uns뜍K_W][ 1{=sݏE71 4n!bFm̤]9dttVk_x.)q9/OzOР3 V6 >.xwqmjs HkUkۂqie\[i|rym@1[5HMM{ǾSŝ-A.>qƹT;z;|l2wǍU}Ν^?D_KCIV^׶H;{g\I HD **('X r*{E=Eņ;H~渐?Nɬdy7O~=M^R\j(!iHױq?);kQж0k"`UW@;qDdsL`h@0G Dd "2ef?w {g,Fmem;}S\G$ ])"7c'RGM?p61L7 Qf2rڿ|TYuT]u`żxh+W#o0'iw #ez:D,ZT_]tj˻p9+C\.˭*J^?dnYUKUVɘ;YU?m2<'IGTLÅ'[O+lHe엧eu|0'-,AkIs4.,ٜ&M(x.jڈ`/0TJl2}_EgB\VyI$A嬦aͺb[)joSϟDYOf";̸>$ontogtґpGo9m'g]&"{9J*5%{ttd/tHR7o$9K%Ewͥh /1=3qFYYM屴]5}2FCʓ5m8{?c~h?yQULGL}\}Uͨ>磳ty}o},};3ztUCd7T=XVGҏr}W| 2#d,|PW1>a \Tl]aT9' ]ejpOY }S\ c|]ai5"V>j4R8ke + !ĮOQux$6yx}I {5TV (,{"-cщ|~oiϸ^;}u0C "NJ 'DpX,/m~&6?f3O]5e3B(?aI{C{F:nB1BpO+s łv]PU6vBM9"QNs;7ȶ7"]epzv}=~:Y SmAHnShZCn^"G*V~-<o[a]ԕŵO8MNOc%g&hu gKmӑi])g4:f9EFgmwdG𸔵;CPZQ޳ Ӛ,KJ !Ėش^ʓ7zZdT8l}i Bn}bF ya0/Own~o7˪}rrYU{bqw}C;}`$/'嘿=trNIf:ZSBy-f.ȫjh _:S4D}W;R !OC2ЅrD?6m`0F5G=H!6">+|6=߬H'K6J 4BOk;Iӗ Ftտf5Y+'+++>e !JH޽0'ł ?lϞ=%ə_VV<9ryy9 d0G "Dd"rv;|7N[k"rCkW랺ʪ#&ߨ뎞v=ҚIo93rWWw7]7T_d8ⲢָRS& Fϝ6ZKMYS jLD4aVW_ ^o!iTmQʲ$Xp<}d-#Zs`,,0r @ו}kg\7=TxIF׳t/&. }xkiۻLD>YyF.%H>%S92U}4]uM~w>޳|p/EQ)J_@qY!^6ZjjZ6^!_ɹjx_5e%iS3ZF)6̱vQnٌZ|jN?vj?RG`7r Qݢ  7Eҟ5H&5fe"mۢ>唊9'CVKZ~nO%"Jj\"f:7q/o܆/9*[Aݻ;m;8o執<'99*Odl¬w^3 #a o Rw9Q W?uo;%IJd?йzM o(9ײ{tpq-gj ,mx&-RmDY*Iv/Ҧ#iߞ}VBhCLzȑzt2sdgz^NY,,'=J^]$ZCuV{s#^\> #gE'#aü TXep#W}za1yl5%^xŕ]&";Szֻ=3l&nTؔYV_y@w]{TJm+|y$ 4g2n׵,Q%;mm|*b1>dZk!J^t8BWCN+`է #a!}Ro`T..:Z].oc2At?,T39{i>~Ò˷,U]&"iZ>2#ҍe+& U$)L5)mjfM玟36߯V~=qS?Wߦ sa/" ܁l{TzaFo^6q4)#[c0'NzS&bB,sfIiYqaiq 's{jg]B,rtn o&7=FFs4uIG9χvCK{=e5]7"k"o~lUGԱ8E_,vlk!T.~!;ԙa愔"%%bKXlffNMTA80o:)YiBMї}˄0O*n>5nK Qek䨵.Gy?|DљMZ*:FG 㪽̣ѽ #FK{jN.뮦oܜahĝv 3Ҳn_Wq;My{wZJs2SS6mEl@y q˨)79&(mP3sz8#!wD5 IE5jmkb]ZZ\U=bX'41BC]&"گt*Gs%*K ۆ9rUNC2D,Ս ywpD~|(`إ B^/^C!cn!eIa9&U!T<+)tB쪼gQ 44KǦ*-e3s>=G?ϳUnSSS3Dɸu/mj"Gd KPf1}(5/ 0XY.Xi+YVx%:OWVw.5jaڲg{h!V3*Qp8%b}8U5ӒFjYlG}6!#G4IDAT; ߪ&ɏg${FI$<~\.YWӑ\Xٮ+q6O/~wNu_jmL_:-9RXpY 9\n5㤍 9f#fh uDFxAj0_^bl6>>JY^߾s,4y#yå x7JydסCͦ\.yVC]:OG$vg?q󨬎i8MuT Nw˶6qVyBs!oƇNڽbČ6!~cyڮPPXwĪ%3Yn:a/*"۝pF;O]ȹ}.`r8U_#li^1x^C.)L\g?YkJ(WH4G_EdC'l\x+yOrC'?Q!QBzƞ]9eLO2:nǎjiJBJ3Ǎw ɫlRxcdƖyL]0' K|~ږ+.lvODw@{FngYV-@D` " r92@DDd Ed:. oCghaGS ;'E=f뮩m޶?F,2Ԍoz4]knPjԜ@ 0p5.=ԴzfkΏZ1yrVAfrVM(++u]ZȖ׼ȢG̽:k/ꜰ ̃j岯x`7릇/)GK\a7NOCUQElw5 ׎0SRRg:Bj@ z^Z{vlx;!ĩ_u97IqtSVӱPrӷ{75^{+^cF[мB.jLBwٯ o Zi>Kݐ>޾ˁ2Dmw 0TV4 r-4"}_ff?}ٲ3 y tt>ߵ"uma^.'$ʚz*lXJ̰ JLD~ܐd,6I?҆~zm0yW֙>XOz};bW*r9StBŌ3 /R2 eiwz'yN~׽|c')NBJ]4)m c-QPPhIYA߮%tzqKtxxϽVT5,-ȟqnhf:oj<';w-vכ D>UCߥ(i-ՖδVRsx! Ɂa*bʔ.1;FD5鿳 6B՞7DS9Çv1 8ͷ JJqqY jbmҎzNtI <Kܒ'}z%`BBrBjk՟S3ʤQڳ)i7f~Qs&B(h=:ok-[W;ml3 =_t1c2[rcMOm~-8']Z]1/[eTݍEXc-";>U}u|41_Iո #5ߪi5Es~w3Ld>d D.Pw+ȒX>0~F R7׹w_Sc2mKx/ܝYP{ww@ {'yNcT/s֎t(5rZvLrxr?o?=55nKՕM9mnkUFlS",ӖEi/!m4ih"*Zs7S-C1ΫgƬaZ'$*QSor|5Es f4)mխ#%sds@.zWk]]:sk%@kDd "2@DG`h@0G Dd "2@DS"2ķ3:Y Vd0V>U6]$Tv5{)$e&̾]ӱWZ2ZE}m#\۠+|Ue82jc훷st$hh;ޔln^ws,ߡSgZT!6!$b.}rc+e/AO+G_Vʻ/N 7̉(ᩯַ:U>5Dz%PUÕ$ZhHyrFLbssφq؉ v+KL |QVx@A%ZvC#euq$([G^zŗ*>2xAfV>8` ' ]<&aèhJA2,K`ssoΞ=wޔ09$ͱ_҆ um%̟zjS\.w,52*;ay6厖#^+cr6%ez T=Lx!P$l)Ռ*1B%:tsni B^y[8D%\.m:X 'W<֗ߢ es\mNtew8^ZRAˁjzpX>vy}ORRRx'&&$5%%%111gϞhnn.Yg'z1M`e#ː,hyݕ,K+B+;r0mٳ=SBOS(8dz> iI#,z[(j+}dݏλۢ&]o֋bsVHD5ߺ@Y4ּi:2Y}7|)BD MG3RfXKr̺M]VSh,AlוOQ ;VܱMb2a2\6iw-9RXpY 9\.GcqFAZ3B3ֆ:"m:B/cXl6p8%n׷K# <,}z-iD$%BYҏ ~M RoHneq O1sSe١CCpA'ӭU sY(jKDWݙOƇ<*cq>=mSl4*'C.ݲMbܳB`xvlk>1a~_4DF?T7VA(C`XON +4=d1ḻ׾!fp1k >094CCg}!d&5%}`$/"2U OԡHrj~{.'G!zCffi(zr!y= Ycψ/ڃ9eLO2:nǎjiJB "r @Զ[b{ ~4 cyAwP tؔ=)`0Yuw֡ۇK/~vta{'W Ǟw-*aO0n-W]hN#yDDI&6Ū`(Nׯ_4;7oFY[[:z;wg?~ܹsB222OHJejSٚaZ=|A`^ңKq?#UנYHQxF޽{ּ?nOlk|vH˻g >;p>YoTz6tg=S=Eu)42qS/TG'B7ݻűCJtkkkți"MX|b/B3B>Y]J@Hm1~a[_1w3*&;afrj2Id>JF 񴰲cEU!.J),O/w{g5Fd,KwwNyСC˯/ˌ=q:6V 7O-V8p"y҈ `ߦlm`<ε>!ǖ3Y)CO}]YYsWuX"K9N mA}meǟ+}G{ϔb~ 3U_'m?f)ydESf#.X$Bϧv<(3ICW8}잼'(٦BAD^!O/E7_x1C~],Vó^^ۿ@ŽTrzǣwxUŕYEum'w-ˮm%ظnp H.pKۿ +Y DW{, ܩ"LhQ ~?Й+Z(z<)l. tc4.!$)T[R\VŐiLtn=/` /gu`!m.QPZ=;lşCW 3/VaբQ~kjJR %!FIJoQ*Xj,MT L=K`Bf$E}d[/M"q=Z'Fc?Z>E\` }93b}5?lB:DM->~ ,VHj|;2䧹j4O7a |AuccuZJBMYCOF1u"N(%3*YRX[rqh?Ryí,~l$EsD"U7v^0Gn66>!4OБl.4b^z_[^cWҋ*\n}uKd18A Iwmoq1e\8P:b!g"/ ?$Ũ sՒZ$tBc?3ʩZhguhR"hd(醎#: O8%o+\#-'ʩ8iF-%ɩ oОVvq&l`_ is a graphical MPD client (GTK+) which works well with Mopidy. .. image:: mpd-client-gmpc.png :width: 1000 :height: 565 GMPC may sometimes requests a lot of meta data of related albums, artists, etc. This takes more time with Mopidy, which needs to query Spotify for the data, than with a normal MPD server, which has a local cache of meta data. Thus, GMPC may sometimes feel frozen, but usually you just need to give it a bit of slack before it will catch up. Sonata ------ `Sonata `_ is a graphical MPD client (GTK+). It generally works well with Mopidy, except for search. .. image:: mpd-client-sonata.png :width: 475 :height: 424 When you search in Sonata, it only sends the first to letters of the search query to Mopidy, and then does the rest of the filtering itself on the client side. Since Spotify has a collection of millions of tracks and they only return the first 100 hits for any search query, searching for two-letter combinations seldom returns any useful results. See :issue:`1` for details. Theremin -------- `Theremin `_ is a graphical MPD client for OS X. It is unmaintained, but generally works well with Mopidy. .. _android_mpd_clients: MPD Android clients =================== MPDroid ------- .. image:: mpd-client-mpdroid.jpg :width: 288 :height: 512 You can get `MPDroid from Google Play `_. MPDroid is a good MPD client, and really the only one we can recommend. .. _ios_mpd_clients: MPD iOS clients =============== MPoD ---- .. image:: mpd-client-mpod.jpg :width: 320 :height: 480 The `MPoD `_ iPhone/iPod Touch app can be installed from `MPoD at iTunes Store `_. MPaD ---- .. image:: mpd-client-mpad.jpg :width: 480 :height: 360 The `MPaD `_ iPad app can be purchased from `MPaD at iTunes Store `_ .. _mpd-web-clients: MPD web clients =============== The following web clients use the MPD protocol to communicate with Mopidy. For other web clients, see :ref:`http-clients`. Rompr ----- .. image:: rompr.png :width: 557 :height: 600 `Rompr `_ is a web based MPD client. `mrvanes `_, a Mopidy and Rompr user, said: "These projects are a real match made in heaven." Partify ------- `Partify `_ is a web based MPD client focussing on making music playing collaborative and social. Mopidy-2.0.0/docs/clients/mpd-client-mpad.jpg0000644000175000017500000016724212441116635021243 0ustar jodaljodal00000000000000JFIFHHC     C   h  j !1A"Qa 2RSVq#BW3TUrs$45Cbu%6FG&7cde8Etv';!Q1RAa"q2Sb#B$3Cr ?-=ᆖv1wU*< IvV[Js-{,;Zh>/Y5/SmJiD8J*@gN={An Fe&DI>G'Hr@A<[qZC RS@N;( T@@@@@@@@@@@@\!TUc>~X'_Lp:w且su%J!yWۻjgx>J[ڟlxr^pn. `!]PPPPPPPPPPPPP[4}uѽ_%m";k_W %i#i*Zy]?{l'd"PrC$-oCdASǬ<3Ϙ <%nm_6VTɂ年Pێn`@ek- \wקᛀE..xuKϤx6y1ƑHOX!kV;ԣ̚aM5<ǩ{ m쩧'Cqn<=e*j9:Sp/SNN`=x{6Tӓ87+1ωo87a|L}4vǩ{ pe쩧'C=M{ pm쩧'C=McleM9:qnު𰽅[!ҒOEl շ-|>p5m.|ҔDN_geE]ĢVNɗ=Gfe[pgٛa{jǃ!iei)\DyraO JXgpKC:h aJAT \,/alշ={>3z,/a7xswAZFpTbG ݲY+=8{6T.=Mg,eMO'Cqn<&Yʚrt;1߉o؇1߉o87aL}4v!ǩ{ pm쩩v=McleM9:Sp7SNN`=8{2QQCSp7E9:qn=f('C=MgleM9*Sp7SNJjz>&پʚrT;G1߉ort;=8{2QNNbz~&Y)CSp32͏?N`=x{6Tӓ87aM4v=Mc,e87aL}S؇1߉ort;=8{2QNNbz~&~ʚN`=x{6Tӓ87aL}587aL}4v!ǩ{ pe쩧'C=MgleMO'Cqn>9ƌs?ܢǩbww>Α2AOX! N{ҡղǨ=zg0xyf#KPV!ږq֊z eUEj [[%tԘR̕\A$5m%v"*2þ $_%ohRZזQiNjĎFMyQ&`>ء稔"ht5FYJ $vk+6t63?%k~luM۔PȤ k9Aǩfнٚ\byVOfu$W\UZ-va{Kϝ ף*yi3ݧ]ʙ|mҔ`<<ˬГͱuy-7.]uK*V|8=sEBuܭдܔ^tUMRlw+؄2+ӼQ*nb9Ȑy}V !55K]M԰?Y[n2g᝶"mPƌK8yK䬣nW6GkMhhJxeJLt>Vێ%G JڜZ}]Vg%xX񏦖M, L}4) ?*=,.ScbCc`3KXǟ3!fs鏽LY[tm#mYe)$|Uz'QO[8F1I cL!]k6_ZDG70U=CQK%lYa_{\,mgUO/Kk}sw~qfkz'F9z]߱OkswƝW lyܙm2w+ݡ)T%f 4͝К{Bqn|m7G9v1-@ڐwUYc+/# BzEiQii< +) (0RZ)B>4 }K My98'~U)ބу6*m[`f4UŹYJ#-p@"v"t% -V~GNe/3/l![?2\~DŠR9$s#d|?Yt7m{g5}K'R|?UR: ,FwVh.joW\^rr'_9[RJ{^cֆ15Od▢h11Q^2î_Z#8ܾSKr[T┙ H)l[;Zf˲G/.uڊ;T*8-Kq%k m5>%@c8{6W;~4eƃS(x{e}L ʨmW|~U,IiW?+I-PI\X<U.MRq`TO\h{]?܋Ǿtx{Kue%oݬnžV'5ImvwM6C qN:q\I'5dЋ)p,.{j]C>RH"EŻ>X,VѵߚmP B +a(E2^'knQ}&;2)0%1姙BPyVkbtݕ=:kJd7=ZnI8t$gJvj]q\9m̼ܞ[:rIr!$] '$=ib?륅gap񟦖6F?te'`YV޴`yd1џ x@![w/rA9#'0j9ܞ>\=x< MAunsU߸SEc] x))Q$z{{9Ӛ j2=*W5_>|QӚ jjw (iW~O;_aq§ڃڝ¯0u~sU߸SANW:m9pp?_~8TP{SUFίNj{*} ?O_~8TQ' $|kwq§ڄ FiHә 56KjSj2T3EhdʸR^Hm iE}X9=܌F">'RG~*>F|Os'--M~ω~--mSߦ|Osilo*>3{mSߦ|OsmSߦ|OsmSߦ|OsmSߦ|OsmSߦ|OsmSߦ|OsP8;Pɵ- Cߩω~ZdupL/J+ߦ|OsildSUFίNf{*}=*W5_>|QӚ ju7C>_OFcMBCrb8DYP%k↖}z\?n|ŧ* l5^bjnxUaÛ(p R7KJR2R9+Ԝ&JqZ baQ^;9B$-Npq^7~5zgOY$:Ѧ*ZáBRT {N750ud|kEJ ʰyua8#I9@xy 9xq=,ϜQb8,χ#~T-Yhǣ*奃?;^uR?ݳW^LƟ$$:V@RrQQVF x},.War|>sAp4>zXǥ~p|<jes2wlR JA9+KNf8R"6rҒrit(v`c8^+_[;j].ƥNMOoKo؞=. q˃ -XY6HI"'߰FlNyCTz9n?vr3}%#Ӓz]=%#Ӓz]=%#Ӓz]ȧM=~rX~ÏKM=~rX~ÏKM=~rX~ÏK󇧾܏NKoqw 󇧾܏NKoqw#˝'M4ۑib~=."ܺOpiIvCPᲷuZȿMWŧs6psjVU)j}?Ɠ(Z9Rڎ@P.yDH|wv*J:2S EPd $CGNZ QR6NAPܵ@(|:25/.7Zs:vDJGN3v$e Y WP緡+I>Ru=/rgGHԞm27)=Wv{(f5j2my6R q݌w#RN<NY^]3|{)%B|?7aIŅ.uC-J@ʀI98i! *K)mIA+>{IQ|Hgu y(RT+JO5en28IUls q n*igً [:ԡ!*OM4R莞7D=f^w+]c:HH->>Yȫrx~q!Q='Ki#X~qw"q4ۑib~=.q4ۑib~=.%j~Iơz8vĶ@Z hi\_w)GaLtVǬ"5eq\"C%ީG.40'i¹\\_w)GaBۣvňUq.RTW%G~rv2<^u [ Mtvsًۊ[XB [#o⸾RuApXfLBRi\_w)GbNu&$ԷWa04嶞K[B6OD9J;#+ m| ߑcq}ߢON;k^.M\;N pJPppAǦ~rv3RY't/C~ ~|6O~+(__ S|W!Q?QXt/C tSn,Κ~%]昊3fghOb9zX}z\Ih#ȺۻϷp)+\t=6$(@WK,z3١_hYZ>k[= 3Gݦyn,bGi[-ؾ>}g`/|,ϭv帲= 3Gݦyn,bGi[-{>g֏LYlŶ<[mf}h-Ŗ} 3Gݦyn,bGi[-+}eh-ŖdZ+9N[Il5z&ʹ9'+sH׋iD):5S X󚛐+i]s[Lw;%KlDe'^zRS0;!>0s]9uâKv+CqBR8ϧNUk 6G'-H fRTdff$zIд,!'v+'()Y=5*)vR>EB[[F9WR v39y{i^ɴ;PXʼnSM3TPTm*#$nZY*MA1rIAnB O`{01S(ff <[ֵWR?/0ʯr.\PԖ.EH! 6`˘j"Qxk:Ӎq\qR2 2<˼S"n3.?ī_wd4bs@ DDsHK)z]RV BvvC OHsV phV*Zܚ;-'kN$` sIl9pԴ/쮵9ndPO _֏W-i_bGi[-ؾ>}g`.|,ϭv帲= 3Gݦyn,)]hYZ>3qe_bGi[-غ}g`.|,v帲~t}Aς;/] T^jp(yHQJG<4'A- (Eܫzܐ-}ԺNY$`@:[裦[$&!̵6á' iAN⒠pG* ЋEE*y=Ii S!JZJ9G@4/]QuIQV!Gj꡽ $m{>9%àx͒r!mN4⒜$S#?]oh" f\.&N򳌌#A:"ojk;[Rҥ`5s)#blyۑ6ӎܑ9r}le&CH-R'ewϾsˉu(H ,%@$)IH*>zB ѭ5ya =WA2dZO1s4E -'K36 y6 ,8ٴjUx>jpxPFrק*e!E1i|J[ p'wWĐ;N+Շaq V]Z}R)S]*Xvru7A_ʖ?wtM{E5>)Gg'StMucwOQ?1S]*Xvru7A_ʖ?wtM{E5>)Gg'StMucwOQ?1S]*Xvru7A_ʖ?wtM{E5>)Gg'StZBI>)Gf9:{YbEw+1sVo&Ya&jތT]l8?sM3U}M@R5>n)BBjHX>sXbkJGk҂m 냘tf_@Ύ"&W Q2:BJKy4*6=:)ߚif|Fmq'B 6 ]9V;{p 9ÇhGe{Ә8qس#gG8i v&AO 2{\s{; OiB"= !K@lQPiU8g@:=~k,;~^ÎۡF{ ]χn܇7*T%JpT|Nbpc1i#!IZ 1WpYf*vhGe{Ә8q=5oNbp`zYw9Ç[fO}s{t{پw9Ç otc6ɥobRa-4ꃩVzqϙ5Ҭ.S5,G诪HlpW+h%̞o̔ e+}^'Ey37_oP Y>+GfW=E5>)Gg'StMucwOQ?1S]*Xvru7A_ʖ?wtM{E5>)Gg'StMucwOQ?1S]*Y>'tE5i=R>)J܇hݿ cS_.{O\Dm1rjJ= ʀk8a=|A+Pp8) y` v@f7s.j%Isl,a[w,gi$"hXT cS@¦<5Mxk >4|*h޻_ij3cFqj}rpB0AI ݮ-y PK8R@I3j ֕vsven2)^+ۛe{ҿnoӕz҄r[e{ҿnoӕJB}NV[/~+ 9Yn8^+ۛe{ҿnoӕJB}NV[/~+ 9Yn8^+ۛe{ҿnoӕTzZT+ۛe"}WM,vsvdkƩ}$:]}XŭVxKPb T{;VOBHBY&oebhĖfZN HmDWwjk|ʻ2 }.J _U9 ϙ;$ sKIf5lICDKPI4>qLωs,+MfC @X}4{ A'#*mr h?b]sWw&rH%$s0 9ǟUpaR3K-HzE2aO ]($vjS IammY>.hCm-)@HH@6hOxi~ؘУKr((%*W,$aP WWme{Zh2+J0ø3Wh se:YvMTqn $F؇CEPݒ:Gr jPz,l ~^|2\BŔ\`U!Mj߱(Zy9v 71m=)~KJ@PpHT9jѤݳS;-1l-䰤%ARUrSP@ Z ZjsnFr JGq>^@ \ ϳ6އI Wx;B=gїkTr9{?dJ5nSLHnR$:(s#ö_a*tQ>% qw6C]!4vu3A/ȶqG[Ip\=a|"TgK lg ҁw+ߝGZN:Gytu&OBMO_Wrbӱ-8ܔV3rN!=YBȐ;**Q.62JeR} _H@.z|4ǮRCae$x+'{YGok_ [1.qq–Tx*p9ОYiHF2-/kʏ/T·!ޚ;FI'Tc8T;2 hk$ 3~Wוk"F*x;BmGLF1l:B#8*u$ӳ_Ps/j?5G?ܮ^$Hbz6pBlqpFB?"Km RJpiğs#z/ΰ,U=%LHRZT6ܤdq'H>}hrI1;N )8{RdZ k y)*8q'Hc5=l\[zu4|Zۅn#OXps[q'H"jt|-0C;3oKJlXm82{P(I}d´^\eaqx+IOػXHSGO=hrI1;c#MS>2Gcѿf[zíCrM3iSPJ O5)D;I4Os]q'HTtZ%MӋ>2Gc1fNm8,vzf(d1[qEʂ$9iBu0]Y+B6J0LJgr\bL7')RT2x):u?Hȝն[ Q|}ݿ=ynUF|o=* ɍ쥵AI?u[Y-KEUj+&IA@ *'<|՜jѨtOPESmڔ$95ڢ`Pٙv->ڜRI~^tt,΀3%,:u2aR+'#iPvU pj@5>:pw).suiv;.cķʜBmNZTN7Tp9΀Sr;MJj2崉)UqHHfEwo]܉3*Z:6eD̷_16ˬK>DWP}kN -xv4 j^]O:JWy$${w=1ת!)d»m6TKQ I myJS8T6*NFsymݶM%i1֔6#r9('g %M/SNCr\Ѱ\^bI}|fQYKJ`xՐf.lvaj[P4\ZR]Rps *n=%m돇:[/VIXnFqzZ\<9Ki' Q'  b*/,;~SUQA:r^nu"܂ nBҤ[歡 I%Xܰ2:u'xNRr7DfAt kQpRϲW#gOP-Yh+-K*_<q12Vc䷩ [Hۼ>K. DoU'd,=a9 a?)6+VгՁss>1T]>ȷXZaG+,.H$J@ {xJiI:RbӋxIj#k%?:a}~j)#u]l CJq Σ(:*j 28}aݟZw^ E eT3dpg 띿R\%ڭ\Ǹ-ͫp! 8U̴ !\2sb8K„"*"_\ӥ!-T~1A͊P<*rr,=xmvd& &/a2)A%[.GHvv(I4/8w}f)me(͸[a ]BmŐyd%o8۝Y< e"vW.d^aR@vncpn+ @jrC\(RTVL"^U3Po6.F?q=L6Eq6T.n.ێF-#,$sQf,_:q7Q7x9}kwR9bzv%[BU#bjr԰[@|3=|EBz35}ozYEބ} /QA"J[aFJwÌyF167'%hџ xXmKܓ5O]OWUh$2R(?IwZ5\:HZ=B~D.%;\Y}iJVG$z9ZFn7:jZ`te֏[Qf--î;r@OuZ*U^VZJqm''qnRpFP~|<=X]rdѺ;ڎRB ̀G/EfWp2bv\ q[R*VyJ$yVdi.V| k̥oGs"A$; ^q]cW diL{kSm u% !J\zݎ}魡mjX R!#Hm !Jyv ]vF [4q-(s o=űhwZi%r[{@8{H )N먅NfW65:, œZz$乍`=trrŸ]2IJSݡi3M:1j)#:5,%BbuH!Z:oO@fjޏZZ,M FQ#P5ZQε8<(އZ78`jRv2Òd[!XJ5hA]-ZSla;y)IXrN0zN,i&{8gy3B(\teJvYp|+z65ヒZczaO!(_2Oe|xMh]J[&%;bN'iR2ّq_Δ*O;R_=RQVЏGT::P겤-g\z"Oќӓ{F^z+W25-K.4ۊ))PXW4(_%r6 ^?s_ޣy0WOU~W~+'wNWq^`֪9_Q{Ⴘ~n"Zt}G jӕW~+'wNWq^`֪9_Q{Ⴘ~n"Zt}G jӕW~+'wNWq^`֪9_Q{Ⴘ~n"Zt}GTz7?Ur.#{?U~oCZK4û"2[,ۢmҤ)r3J$㳺Ia|wAj"S&&*6niS@YVӂ1,mu=sF@UZ\(3-1Ԥ!/YfӒP`d;Uh揙%6-Vy%- xGDFK!INԨ^IҩxA}KE% Pa֐֕gg* g~GNpK<Ѩ Wqz2Yֹ3i& !C  sy49yDG>EahH=bs_9dwF O[p|FHSdRnLY-̌ԆUR`2 A&.{ViId#-P#5׳iukU92ZSd9`Zշ HqeŞ r]w^B]#, 6U'@w#[#ym, E$dP{ Xa7h=75_Xm"Dwvа@j12j~GM')=D^~intpKX0J!vv Zi *Z LYޒ׽̨lr3 ˰V[iiiLYs|%ˤh. &S4Gphl8mQ9粙-O !jwFt%KP6 Ý3Kqd']m-& EŔ?Sv]BR@("1躉,8]pW [iR$@)[!n>d܍#|.Z\R%,=-ŐvZa˵G?B 5QM&uopMlӒ96jLYAZ`q )[#xMÅ%:7KDZN3qLY}m7QS4[?DLY鍣lz/q6vhҭdRjpU HBs^[xi\i*iz1=^D7Uϳ yr,]HKWdj[%"Ҟ^vqm)J\VJ!cT6EY>@]zl+h gI|̊ûƯv 3-ϔ! (' `s5,%H;FtqQϒ/#tmv3ps^rj[Ҟk};6z"q!$-E]mbR_-E$G d BRBّ<3ENF +Hps>%6ub:]^&j=; UZ"R% )9J1;jCW3aDFkwVirpMA#lm$G"uMq# if&ٕ:vnk'xCe.-89l%Jws G%ܭMqz][@TeCSh}% x'}d^j]]ieQ[ېqO2\i()H j^ Ӝ C\|F1d";C%.:JA81L=; I]oM9|5#2|,4m+)XmݜwrmKыklORyLaAdA'@yih`Cȇ8XmFARn\hF(95in\(Li ӎT)aL/7`^<€60 (h <€602, h OV!<*Ĕs%j( ޞzO){%]r+:|[ufOگP_/G)a1uJq[rެgW5j4hvM6] իW.:k@ி?-k6PT@W~NPO)翝||k gj]l{S E$5%A+a1JNlI;yg+fpsir9:Ri_x˭5X_BJJRSْ|sϲRVԕFܼĭEڮҘL R6r~RO}lWmEDB(itݰ97j"ܕ)('`<u͞ղ ?ӿLrH5DY]4b!npjDFEmuciQ7r;I9G+Cͦi<|¸{ŘS u~M̼Z%eom+u!%Dq TeBD=q^^jL+ܯݔ:Ф2XF䂠\8Mfч:hDX*#D\ ж she&3Nж=x阏vD|.8eHJq-X q"}m葩 \#],KCW.$*$h )Ղv n9QsV ?šZZl["b#୰r=EŝE%GirtS7G\oirr33 Y]5=s71ڎUҬ;ɵ[܋pTYl>bu(yJ BF$\B\grSzcm҉R\=0/OAi[kȺɖekZ-RfFbƐauSt*[/xS.? j;})N{V)f7PϻBv?{ߏS*g1zlTΟ%j9>*?\Ji}L[Tl5<^cuJYgs-|NqgNk!~{E  7<O:ӂJgI*ؓ`faM%q<85z-ѕ2QeDK͋n|fP.ou$%Dwg+J.V[A¼хvZܜ}u  eoIV9dZR,^UB _tF&:H'=5j 40 u- ^2V#)I%x6tDz.n%)eq?6jU; eו!Uڲ2~ZMnc=v+zEK?4s`r] R)"Dÿ?N7ڿ[NΦk-/Z4֣ɿ#]7na.ܒG?_;αh8iHy~,4́M‡vЃ6(ۿ ?/鷹që6R%$D@Q4t5jxqF[]cHlduѶWV!mY\4тBCG( -p,RQLǀ-IsI>+./uFC ;4;JGȐ; U,_[V^Xqo551!ũjRĸo9T@@%jMKJ[:zԖP7-d~@h놿瘅o/[Sm +ٞgyzr6L2{PFE#/vYM1|Yx'\['9ڞ5M}A\웗"zm[Q#ҷJGx=o*q=OzKe-ODf"_e-lH)O㹔(z9#Zk+]&˷U[3 o$%$r^NAV5BWb]&jD~[aqJV2=o0Euݜ.ߛH?k]IîcOxϫ\vnٝV1]z7pI(u/>5㷉ԉ M}(@)BNV3NW/eƥcjŅ B-f46d$IQD$ߘ:[pR{~?UO hOrsb\h\Q B9ܠAԪp]r}Y\+:0k=VBk6H)P 篣tRnq5WzMdϖqn+ش}U(H}LNZq^ǟ»/C#? ~$XOߧ-{]ċ8`+Qb?~;w?,_S'Ӗp=GEdrG~(H}LNZÈ»/CqWx"2~9hw#? ~$XOߧ-{]ċ8cV"2~9hw+غU_6!S'Ӗp⽈8_A:QF n"4D)-(JT4$Q6tw@Djt}l$:䉦:8;V4=szT༉m.p i6QQb8bv.,nRD(Rpwli o)O(6W=A h.[eƜsT CJGXWA):;ʚEJ -X&Se3!ݥxR tP?0nqtJ"ԷC'rvIP9e?Z?*tkᵮS2"xÈXy܅>U7} 6 NLy6T%#$9̚Cv=)&<èѫ]A^M#qɊ}#+sG2yF{xgĻי.Kei#虓֕ɵ_ZZC!nItXZFv(fk0 n%RNĂr1a\ _M0sӄ HTR=~xx@#'PHTR=~xx@`޴}EĹ@bdsc8>qˑ(7ʑ׻s2O>G#e- %F)mЬ@vAv)&lg#xT|uoIN#pF{kGX[V1Zl& KS8>ůz bIS[)m"> 1؍O$n̝;9" f%͉F8r((~ mp!JfWZe#$+<€C}>%V_eiRnR^Jz3ڠǜ`q ^aj붫xN}Dy+ԁEB2ٌ3!W7^ߌn)S!9i}t\Ho3oVio}}B;@49҂̫5]|k"z+SK$+# lH-{EṦ\{>mBZH*xI '9>#ko6[ ;mB/io-h miP ^ZR Ѧ-.{L$I( 8ʉHHHPPPP E@@D'w3c @-⒆<3H= ]Uq"K'Pm6Gv{h:Nj%.) AyX|w( (3@<ARNTFW3@@@@n@bv}OJ_ZX P'52e-:NZm TPv۷G6Ԏu ӷb/p33@D>ێZ5<*l-]%ޙ.}b8U)[ғ9wf HmӅtkoTvdR?QGr3M:٥޷ͼ\kTGg.I{%+q %$saN`BRZUZ_$DuOgH RhVԩ'$Aq΢u%4Tv"%#Zn q;ys2{4HsSX\>/$tdvgc:RbߵEnǵ[f xt)D-*)8Gb@FaolFoT\{H^L0Е*y-iwKܘ.VkBT3!cG,(,q[#v ߢi[Ll,0PQZTA.4't,l**ÆnV}fw$2i5]u-\[}$Lo\y!&6=ChNIWQfW^箩oUw~y7uZP iH!CFbx( wKUBYq}{RI b(hLgpA)Q*&sdWbVHǭTma!^jNr!_-TXCy*S*_;݃y` k.j\\vN0ydtk&;iת1q8h5, Վ@OFDmw%L =cjB@AsߎT\)אeR%ڜRi[HK J{KvZNJB#[lw<}vvPi[H{nO9z;Mu=EbENxCXސA##Jv!WJdX*RIZrJsww BA#;rJ@HoP Whvc*@ o^ R*@'׈%JeOV y;!ۺ_G,xBpvoA; r}o9u%ORB(jqẁ%XaJR6 ޸] H(D} I*H LP,ڑr6dDd;n]^Ͻ?H*iH ێKl<Ǻz^0Q%p8Wf]A]G W;kzzRKtɀr\XۆkK 48evRV&E-޼.PGYP3f2-֏Cߤз^UiZ7l)v+Rk'x>#jHgޟmx>#h+YڈP|3hEƽU\Ar"6H9mrčY3DY#a]\PS6}-czT =ꪴjB*n_JTjeN? ?y}i>}Oڮ3N_ -W`d/{wLS}w UeQx[ѤGHeeT%e*/# ~K}_W`*]=-%~n]qwؕڛệv¡߃bWi|7pT8z[J?zgKc+wjKwNnhLKlr#>N"’FATJI$lvu|?j"v>H8+Jm[RaBmDOիIRpBϚFi|K-鲲h)V2v p;y^ci+''d!ںkp>;(RqJJ2aI>#.vLǥWg>*K=*>%^ݬ%~o[ |d7M&3Oa{FGñ=,ҟ(FRG#?¯ñ]1KIbH'Ḯ`t|XQ8rLRM4j8^>#V^L~Ͻ?HO8Lғ` qU-Ȭ԰;;RAI0)i=$o_¹qg*B!7})n­+ghWZ=<ҿ7?*+R !ꚕ %Tq;S.sAP'8zMA}/e[ȋ* z8%AN8a[ܭV1@eM!{s2b!Q 9PNIZ4;S',VMvC Z6cjo8< ք陡9s%9?Mb+s&KK48'jDsD/4Ds]IqH!A x8j'<8˗I l&+O-T!\+V# 82)>c@gxk7" sQBʐkK̰˘Q{t7᳏\v-n[O*RTF{O)NO1z6hkH1zHPzvԂVG*j3/+ms Y$ 4QuE7hkK|)j g#9Ê9d@`&h;maə') z4岰1%thn>.ȶ6-)I\Ve Z@":XJKY[8<rĨERq!I*FYƫoIA vY7d[$BBA H(`+$:488+ A>zWv Z*=ʛQI#nO^争eFdJj#N ZZq桢v/pQcg]YjX ,Yz=ȸLf[IQDZfSɴ?iKq*)R[VN"WHݚ$kS'[u VsSčp2 2f d%G r ˙y tJ2 g*)F坩yCMJbzo4q!iP ZQ|P=oBC[>jmjQ"_xm]>=ҥpPY3X%:KmhSmvH|Cn, %t:Z ϐI ''њ\%˵rm-a 2]i(RJI<3(DC'ozcr8*%;@>N5$4IO@n;:="A [tAmKԀGX|ݚ'm?{h[> kDPd #Tj-ɠ0o:!9&0cuIiM^q^w8u5dX/Wdwd͸|YJPihm=Qq|\:Khe$YmpaT =JthմtR?&E{{mu)q1Jn;LqT4֯sЙcmsG`+*0jRaZ1[us} )u8MyѓSQᾝCxiw I syM}. /.5qZnRPl;uQԫKN,6tt:_r~b]5?֧o\i2Jmmؐcl(Hd:~ 5xҫ\K?.6RBҽ ۉkd%SJ .!avաIG-$`@f]\]R8' JF ;Fs'G{ NKoJzH#>Hr<  p:S*CG>_5Ӻ6)mJR7̅Y;r@.j%;KwPP/߂_@"ޭͶJ)qG;/D׃}2m% Bs>}n ;a%-3M( t_IN%m߮Zeo3Iޒ^!WZ׃墨%[RZHX>4ˉQF{3ʓ6gFr q.(rO"Gn}j„sh Wa@EӼh_-~(J:;jeWtq?u=M*'z@Y?'M IZ(ն4ϲ:=$N@H_$9#'cRYJbweDRətXZLu8-ޝ#+$կbcztջkiłCa`ħy %JAiFZfӶim:DŽ !M-DVwmڴ(g)fסZQZQ9lY4) u#TT23UrPͰ3dFyBl($q(+;UbȹyHxjp@|qGH^$YǮ 7&C* hqeO$6ZSO$m ȧ%O|3@$RASep֌,h~m -x mKMD2˭ԚTuiE;9֋Bl*+k |bgx m KAY!?SZ./L @!qj{WVu~ľއ6G $w_!Jr_,*NDOzr2@{mk%N|jMe6V 9+R0J Sq'q!ڌPCqRW,o@+7 {$sZ@ pp8*N`gQUjk/gW^޴H?{*vvNw)#VZ>B2v62?MZ'/>:_~~"oL^Vmn &Gx+[sR}Hw3l~ Ɉqje%:(]nTa$:D^kUFvy\)G K E[r(FqVVl61ι|8)+uT$ 83G~ojk.ޭBf2>ԅmqh _V `Y# Ǟ68Kz>-zd zKsJ!%)h6Rž& Z6=!-1C1 '(Cn$(` ;N1KeZZi yjXC%de NP4}kM Ӷ WnkWC BT\Ԅ! R(1怵'ul' EC(1mےe$5 Kid_%^(މ@?ЫYVRɼe~^)IR ɪI'AZkPeK 1N ” 0 z.uvѢT:}}ښv ϶Ž\wy0dUeе4V5p.Q_^%1-hvgz{.T/!h|8t9`% cjy%99ܳwhTm}f$ۢ!(B,gGMId\ P(._W.?%ɂ= :Ìb6>ٖ@C\%GsXI^eҹa^/1eNIQj9Wli. Rs+^PwV/&] _QR@P & U/J>P?U/ hPNG*%lH;?.$L%1l)k*-ڮS>[%,75- 6w`Js\aʢWfYl]l 6s7%yܬvkω+ -59J \rdvBTJJ Q<~j(sjR登%>|<Ze'w{XtbtpKkcvW&2VTTz}Q)[R?}YxƱ[-E) )NO"+m@0hO$f eC-SJ@N֒+'$@%G3ZzPKkX.:!ZH  ܞt"qe7bShm(@m*[Brv'9xDgL(@m~9Rˉ :}#x梓 :/ FJ@)=Y#pӡ,V):Bw-d)M@I <ʀz7{5ň)KZ-!`-n 4srVVzvVhp2zBO>XR&tW^^g,"CPI; syJ0Fu++ԗcOv2cE mOVj.蹯;@HPrSZRW~'?}auDރ9ɈKPS.ĊGe;i!NI'E yد7Zֳg;%{cu¾հXQbW-G1+^ǁCXE_z܎eW#~¯T=n9vH}_{CY}_{?X= >=ţ*>=ţ*>=?zs/bgf~[eZsVo{l` P]dJ7f?hs98wǗhz,rpylK\uWۊnuBF ~C9\،:O*Fɺ^ڂO 7,sGoL@ڔF$wv]tu#kiq;!˝Tk_n1VIaI)N6?j5Q[j[OQ¹X.Tgu$Rd9Ԁ b%J{ $VR>bַxo7vt¤(Og@@vP>69㨼|5v3-&CS8݊NWЕbm>,̏'!EW7 EDP!6g$Q`ogo-hJD9h~#vTP|:aeJV@YGOol-%]c%ߞW-qYLsr'ʂjx!R^QʜQ'kUɏmq1%\8`vL3 ,֗_*QX!_-k})>d!GwߎgdMV:$},.i-9PEd(}zq;F3Mr/c3vBJ\eKjB N+YSmIjJ*Dw4{ u IJrI\uWs谉*i I]r^KPO U#L 5K.H)qAgZӍoÑIN҉RH%KyaMn2۶p|zVz*2 ݐ]jˡ~IJ)I!@l^[K},:7k&tsV[+i<wr$iXBIZ'-sy Gg66K1s \t45P,#J m4$Ar5tT|ԀECj(]*%KSJJG?IYki4EG)ЇWfV!R2;|uO O/C'էOv )%l6Bn7c<:Xq/ RVz5O%䗜x)7(?YG JN:ėЛF:PO$111)c+JO xӧKJ[ZdөJ9 oxZ_d‡}&Ӫ.VDHpC2\O읯Ǽ7Wmia+G8hqj}O`)q|gçBpEvu.H:+frwAn8g8TrsLߙ~w"^YezOçgrezO}1L^,)3ù{NjoҦTnJgVNh^B6OYR^{0v /.fzfj]gzGH;8ޡrEߩۃ:_6z@KZQ22㲤09;v:GORijkR"1E iշ@0(oNRE3l9!pte|cfʊVV@ @@@A$x׭xQ6;ZOK H,;*8mJʓ?^PX4r<ٗ)tT<ˎ6QAZ -c8}B6RCC4T^oI7|?DaWTۖ@S@Iʛ* A䤜8[XtxpV*&;nJ_SMZ~Z;R:w @'ZN\@nUfuC3Im2$%!(W\$a#n1@'P/NF֚2[KWw^Xi-A[dud ܔ9NyPct̸JӳoIB#Td#scӈYX%|!/VFpAϰ@;Z]Zv;2ﰠdKioc JHQNqa飪Ϝx ffF|eIRU旱>NQMH[ƶtdnη%K)ۏ)B$$ǚʀүۡl/ν ʊs.C Q[TvW(b:__" Pe!?Դ)^zs8bŝ6pBKlkepBe%A)(R O zᆴ$]v!!RGв#i%$灎t((( KGJ>$'T\-i%كn.Ʊ.CNK74vJcj34-xM&\+[ϸR[H Hkq쑴,]W)]T oۓah( oA`cymPRHP4CXH;hsHMPP}f;9= ;jMǞ며(Ϸ5>t@S$f9:/5n%]^ u\eL8>JZB@ Z^fn*:Kb2umP) #A*H(Zmf\N+#c3q 3Nq@)nCs[)snw*ۀ7-)Ϝ q߃1.4(ܧԕnrOx3Yï7w*mӇ| )qqB;IrhA旐x$(-!I9I@@F"6ƽ#w${QҸ'W ԍ8$T(J$KCTl)h^VqaXRO%$wRj7eNB>̈́;*7v͋*?-bIlz( IzLHi:V۩ JXAEU聺:>%F)oJ\@[9 aCnij#"6[w+m 92RJ@(M9Z:-3tB8:]&kvLH K!$5o1eO#7Z1C̻yq!IP/' *t+|{ o4%>KH !T$z U6YpCE:dfŖ K)ӼHA{jU9jҗYRmP\}H%JϝXm-zƋ"i<%mԑR0AT´& H_^ 1<ӷY#ch8(ģZ+ *>/HAԮ~ڸ(`RA=捰 H^EhnМg@ Ui  ;f3[Bwn<Z6vqPyq1 R|JN|jyVV|sW3Ϸ=,Rsiaþ3G*¾_)\'@y,${uiksZQ؜mNH@- ʀ~W*~W*~W*-i߈opVT2 @?GwEi\vo $g>d@d,;֬r1`qrP }j_8֯U@j_8֯U@j_8֯U@j_8d7i+2$j9* 'y  LgX4 aD}@RNL Kڽ%}D|mK xRv`cI/OPSn \V v{BRJR0)ZW?mu|ZW?mu|ZW?mu|2l&>~áBY=g( }:N$b#Czq\9P)0y)KG2֯U@j_8֯U@$ꋍn`ThCk)i ' ;*U~uFTjim%2%xI$tC$V@NEPWD6{9%=Y_?}棠nOJjВY)ei`,)rGq[J#&Sl8[-R^|_=JAsng:oN=[QI@$s;Qo*c}8g]#vx}m$N%{7s ճyhsτ"9,$LiB㌜IJ@ebٯsxAPRuR]-ەhDIn:--ng'RA-#f0Od`UOAWBG(`)R:̫?.*].xᤘp*6I1 TT-8y~-'~hlsƎ#·fŵͳ/%(] ++w%)2*^R~fz[([DpbShZ Tr<0zA':΀H[ ${bw/PiE?<K)_OC}H[9( G:St MJU Ce-X.1ݓd+17u+f.m) PNy~|W3:jw:ׅW~ҿL>B1+VaRrڂ8wSg=:=͠Uvព]gcɐ$$R!w,vT(< R{24{vv$sKDud3kX_ZSKJ*zǑ?Ic$VӖ^'љ ƶ ޥQL$yKWJT}E"ČC*1-PJHSҼzԗGRPP `5GM%rѯ&6rhR V $d@yXLˈ_w;(iy,Aŝk4nmJ) mK ##`C=bvja$\J-eHOa<$&#6sNb2uI}k[ ^T{{xڸ\;f۝mQ`)Xi89֐SA-:YqFKpB Im(K ${OuieԭJ&ni}jC`%Cm68=QO17^D F|L7qe$^d-N p>LS{Lg'׉_Zĩ,FPljy M8J 'm@bi&J4`)Qg*, ^m6RtڡsI?\#Gu*8Y3XtMֵ䖑r*eV;F2k>#Ⱥze} |nciS!ڶ6ښSy%zJy%)-CzUؕP6M KʜK?)@=5GTtqTHPtbKiIGERw!Y+w0H u<ҩ%kY^ݤvR7e@ʳXOg\'#]hTPJ$@9:,*ʲMkp0wYXh܊\¼Z#m(/QJYBФRs";ԥOڣ[ӱbՎj<7}Bxf$NMn9DBܒ6*1u / T2R[KvtdB*2GfO.UZvF;]SuϖPT{BR kSGet.HeY_̨ddUo/Bɲ̎$O]:qnr\[)Z\B`IJ}<f}n4xVfMgcIoi$nrQ,2 m>Z[67w-q +JA9Ƕ[xdn' rB@8[mq''\WSlׂa |3R+T ҋ:@4\+rtIz8W"tyg)خEi&]ոn$Vx,&Ce$8e] |WQ"989.WĈ \m[|q䌨i&/|FZb+wЖה0!(DVbI?.SpX?߉h/$hl*ZcL,%IJ@ϣ:-yk^K%+ҮT,95VFrO"(Eb=:vXEjMEI@1./R} ! N kб\ʵ!y RrJ,Z; 0OxVZ6V$ðd_8[pw dTʆh.HVM!f9xrYZ9sx=x}n5yǮ"kKi~=n7GNV?C#M3>65us!&K<iQ.5,'E-ݧ8 ]$0->JJSzYqT-=Jr{?WZO@=xzt 6rg7k:FۦrgAKiH?_!iIY %k}Ln -rH=ηŽHT^UvR@ B P]D1rYIA9VD˚qEJ9hiZH#$vE1,Uެ,-enejܦ[IQ #&àf>ϼw-TT9':+5n%]^ u\?(. Wm)[h(wdY#t; ֓b@9 Л\gu8%C--Cs2SCYRZا| mrP0}pP A^^,cŒYZ=?B?@@piYUGdծ|RiM4H;O/Yvkd=8lm]xbվB^>K ?AɋJT⺣ )$#^:,m1&Tk-"bpԦCtpʼ ׆vnՈŹnÛC .9lc*4R-m%,y@0Mz,>y-F:L\5\i en v]RTdrxQQսMgS] E8WY ^ Tξve#@;';6唵:XM K(a38OYn(k*$<.U,jbf ֡THB+yzw5L/WBu{309QS!A^IBH!UQtO:t''kZWqRv=-‰|)aCáK 5lݵe'x= oI6$A5mЬQI,-QF `滝/^dS驸160TEZolJw')=)kr堣:c76 wSN9>6$$6yd׆n%zrHaҕۻl*H  4!W і:a@mJ?<تTj4C<V\+wwxx>њ>{:躴)H֯F;ð^F.:ݛ^FMKݔv JR ͵[b`-%9] >j]Q V@@zI;m.#Nu†T`v7o-/SEe!ZRw gh莨D&vք^i`$'q%k>L$ i-HFcVYUv"{Ng88Pȫ*G` bU BRr=#5 ܓ eH h @tglNkį|ODMOgEzfᨘe%OD%|@&Jic]q!K;F{|$ciM{pH$= S \9z8a>`;nϡrI[!SwM8wej<ͮSޥc>_I0mz(o |5[UX5 dB9 :G ֺjBR6KdZ*=LUe􃿐3 zRoUl׋$ڤjIIO< VJ7q"bEVR!Vz j)^Rmc ǐ2Ԅ4$SO":۷,YCCl'-8oҒ:z j(PRr@G"=5)\Z6I%BĻBMJ!9gxH$ht<`{g% R5c&ֵyj+w.yVC1z!]tO^2%8J$'#>au@a"+q jS+mE! j* Jg(4tn}yg'W,so!2F\8?;2t4۠Gri@O`}A'+ܜWYS@8*;( dt~jwu3u;mvԤ +5EkӃqJL LBJV:ĤYrTѢc[,LE H{'+51mKicP8Lӗ.}#VƶZ1iq@` W]Q).YlR%U Q<40PQI6.u2 ?џ-8!iyĠ)#>Uւw1-pK`͒{<@y{_LP$nJQȃh @T{b\ɔ1,HeK$s1ʼ:8ĺjGq~3gMh%NFi^Oc>nU\ͻ"(Ȇ3YnY,L\c֥*@W˅ʛ+t%)Rh $3zRj+KCnL(n=w3$# r4>cZI] vL蚔C9lKO┽שCʓW%n-)HR{~%$Ŭ_u,/ VSyXy-Md/YvSM,,'rb_! wrMKp'YڋR ơŖFW{^=k3Xi-)Rϡ *R خ~ѕL³dgVv T|׺rZĒ| wPzRio(A *5"6?zvbLzsȱb;' /+NQ<1\5Z?6½;lozUtO6 kVW-'ZeīʫZVH]QrU!y)\+ nHI( =C n*I vբLF @( J|OD>_-DXD4!r䌌竃$R\Jp pAƇ>%n&%*o.!e93WT_?eӎ2:Pq )Y< |!fLu A G#/x@#be:]!&N )V2{y"ˊ̸Ršu%MRJys2(׮2tu_x7 5fjp*)m#)ަgG̙v$0)}JTY)AP) 9TeDeO#-!Hq$b*(I&l%8uRHm)$j |SbRq2u7NʐI VJT5KroY>GQ~Bu 6jRi$TԠpmN7MjmKj$I$'A6o\|1~LY_ܵnVUBrwcP=MNjEvEnrݙ#Aqŕg!yU2#uZqVCESG®T8KS~ dydO(ɖu,r)HJFsSd}D~F>;׋ eojTY $y]5-'RdG5{\u7#31.~gGcoP:mxM7/jw$dH~G,5&2u 캐n+I`ѷz7JAкJͧ-amj`m!){RU4 (2 Pyh' x^Ӷh"'s\!8*o~0A;lya3zR%DLXsJR d qlyв1KZ +#88n(6Sźmz.LUnFN2?(yŞ y<)Trn: !.RJPH*I)iG/ObQɷ/ Orxȃ-nĶ-C,8ڋ8P9ڳaPwHyF52g$H#IfcyP+K)*(q+dBi(78$>N9?msUZ >ʚ[e29!W:*H5UosK)$zA"8jQhGͳ7r2<_6+bjJTG1"N3[ 44lg)Imvӆ0#9%jʳ歒yK|p&߮Lt]E;:9)@tglI7DZAЮ-bI[ ]ڹ$]siB  PՍ,?6Vv+ig*Z9AXp G] 5]RCl0Kԉ ,u)/8h` /C}DnK' _TɊۭ&+.uRf?KtP-{m';0ېJR%Ҡ])?:gXQ" #6X6JT,)[@ Y(ߢ^߷kW YbzZp֑v5C_ j-X;֙md) L4$R:O?g>kԜ!&l=nmۣZ]H!nOwYq`z>$坝YL +Vr;0PϢ<[֮iy ^ Ӭ9͖=$eՐ@$gv/S[C\e[iA0;ꋩ9HH4+VV;mFv"[;)'Aq{n1=„b_T{jP| n'xDL_~Lz+Sj)^q.:BY)21:ÖU[i[m=Aw@@ea%.P@j|.O[gq1q|`9%,–IڗR;hC{Ҹr4IQ$ƘԄRwYMW}}|Bi#ܭkclYqoތ Bw(@7]zDžۤ"ɆV R9:Ǔ ,սNz.S}$Ii[Dij|aHFFgbsm I8}+Leϸ%iHIi9qGY'7juySR=XW&bKm#*3HߥJX5X\GCŇnΈQBp`+.JVm]fF4J£Y %}#Z8!Njn لh+(J6.NSu!!uDZ<U@x'J:15n%]Z u\⦜ 1vKISUkX dvnQHj{G0(tw6ąu`0PV-h桕NFgxNeD/pj.3ӌa<N00]d Ӧ諥)Axq;NIV4m]\KM҈˒l$eAw r;sTc^bYWM̜WnF' 3\@@@@Ct"\n½!/A/uml ^n<ؑ@<8Y~D7WTbx)Ip8>@@@@ _)_T[֜*8<] B<2 rѻ,纀ˠ Z.U: +ӷekC\asp1r8v___gmbIax 'oZ 'VO*$mzܕI;W,5 MFZSgd"k[(1Ip87cmn[At5dfŎ*BQI9Y'ƍF.'˃m%{jGRmKݴmϸW`֤+^߭H}3SN&:Tue Dsk#*Zۖn&k=s.c5(jAyJ$%c) MtͤLݛuCr2RݞJ( (dUfTHtKhXv`ƚ셩‚t+'bx 52#EۢRRއ_wv;2L@k O*t.OFҜc`z@Yjr߶n%6H խĭ#!X9 wyq@7}yP]Ddn9o:b3{`qJ[*NqPz_ۉ_W:W6XcWT_BI]\=]ɅYmSp2+Xf3#dvW;ѝi\ZѺ2vȅh}ː/n<$$QmS5_m l~|PpKT; 7#k+-*2JFyUEޤfbpdǘqAp#R:+R( >DN2$FRT;J)!Hg'j룣L笳EԒ[l\JAX߷`'=5cV ̀ImF8* :)8W[]T`RVw%#a=%yՒl鄬I qm"$w)Oy9|.}:b-Tؐ#,󭨭neY744y3QPoiTۊ9q=wZڼ}MjPԉBHyC$wF= ³,@@@@@@@@@@<1pމ-8Rh ``P3ru_-d udB9vԂ=vR΀ L:WvW'բ͟V'θc`筚{VDC̗q魢je8蘾θw Z#2Riz9s!i?T3`2qJM1/ZXT௻ヌiԕⶣ@~&ˋ)RTM|yI= 2D0p؀i$)RMRrJ^en3r^r 2gEjP.T>mG]K) n'81S9+O-3FK R6R8@<2g{j't+#ȃi9N>𤤶!- s$W6%IC0+[qiFj. =!6ITsJg}B/sRi1/\3r%URwܖ<0EIzO*FܴWRIS QЛ ܼ d׭J7nե LiiIFT%HO!mh#e9ԲSxz< BԹSd|VgK쓝`j@@@@@@@@@@O^mq>R-`845t*Zb $eZKgs{sWyeD?>zG3yY8>>3W/g/hwϠq,%zC+p4}|;g=ƏO~!f?儯S߈s>p4}|;g=6ƏO~!b_9B{? cGާÿ}/g/hwϠ{E%zC(z Y4}|;g=2ƏO~!E=De|]ᆘqT v/w}Jp} O߫rs>ҞJ=ą׀0Wu3z=G}~D41 omȫ+LbRFchẈW,1mEC!@ ڤ\CʹgjSja([F44mc.x4iX550PW; ḶrvoY:2~^*%0hC@$'1\P0J2Y E^QxEu_UvNN=Ÿ'nu>984.uYj=r2sTz)2䅩%9)$HPN+[9qǮjwRTHNG9o * FST28`ˌޥQLꔨ+$(g嫪i;܉7/!`J2jN `u e;*es]HU ,IgVHB)I+uGu>h/)|%~/ ڞx~P>!?!`M}R\c!!%Cl[e*^CmŲwawHNl6Os܁ij̴&WS@ҰeBF뮦L%gz`g%(X}s_f{q힙GCHbϽgjB}9>lT^uLgރA,>!XEhv۝r/h4QǸ4C۝r/h4NN=Í-,oXKC8ڻ߫R*ꋾ}IB<#m>!grs>ԟ,A߿NCgJBjqVd!#*RuϠιHNN=nu>9846HEgU@9U98<.vi`Ռv(Լ]dBDZ\>#Trq'-۝r/h4NN=Í-æ2z/RTJG,s_f98Ɩι}Gާ'Ɩ1K-GR_N~u OE"vhPO߫3}IB?}O?Y~Ϡ{i' ?wӐC5W?>ks\LGLt+ AO` m,Cύ:)t\E;LĐRl,%De'H\yfi+JȌR!5<DZtDdDl3"}- ͱj|tĖc1אd%@Yq%%"NRI&*x#ion7cӻ5*U,=N^Zdfn!ԅ)1uE.{\uOkUkUkUu/ZMKLA\x!. cj:èE28fɈZ6gVN8iI&;z]S_\c:ީY CHJqDg#*9zkM; q!!{Dq3Ru;V;]8{ՆfcU(JozO~f^)ONS🮜1=x?]8c1Epz6բSXJXe-8Yړp+,cӢzTFqی|)9+] AJ\oi ˴Oʲ BzNVcr-֑M07SkNvV[Yy"D6\#LnE'aHYFA=,r&R#ue{eG]9|—'f^)ONS🮜1Dպ@s_OUhiNZ:MӬ,,#Hp^ClPYR00;Us;(JKjRնAc̀yA'}u!ƤoiFlbq l)؊%s1u Ռ!.CKr jJ% Y<%Dr#\(HeueLYweD]`G"OAVFBsUјV boW-mn8RR, @ ڬ#+wюuJR[p5en8%Rh$gi=7qK̷.2زt?$^0J:).ך7/:9n2xz9-z2-z2-z2-z2-EkwV0Q4ȅך埞(#^hy(Eg\QRR{Ji\^.L^.Lq4& QBNINnIp1G@\E6~%="9TloAryS>@c\#'yR?Lq rR:AqD9B +VTb--n<5e7ws+ךrp~~ze^jQs{M o Jz;.Z2ROyިISWLq !",^78XV3 u2?!=Mj* -y9K!oISWLqIR?S21v[`"*S$*TlRR^9J=yߟEך\p1eԔPrSR?TdfCġ+8aM$vy\q"ԀyܔGx cLq vSɜB 7v vyR/~ddIR?S212\fn:ݼ}'jb?j'M_S212-[5 zmi:CךLL^.y{\-mrCJ*+dMopidy-2.0.0/docs/clients/ubuntu-sound-menu.png0000644000175000017500000025750712441116635021712 0ustar jodaljodal00000000000000PNG  IHDR. pHYs   IDATx]wU?7m!=PCST@P@ATP@P@*%  ՙcڝ޾ߟvꭿ瞃 !!!!!!!n}lX$NsND$&~DDOȘa"ɤkjӚ}/A-䋲$$$$$$$oh 5LmMEUƐ!C1((r{sĉsJ^,B[GW: ØV{|w`ڔI4CM(:|L:L[Fd2ˢ~0f.[0 L&N67mjl4 }ϮۗUI'/,Be@Xlnj1Y@|u̞9yYjo%j 4KzC}=qDh[[0t:/Y;sIukN혦˺xנ?;$t}];fϚ޶g4y@6+E0@QD^; .:M޻ſ-Fhe䪪Q{[”qƙg]}Z}Ɍ1:mwumܸq挙GڲuKgW>c464434M򳬶]W  QZp^xL+7j@q4ĺMOc)UI?Ÿ>#Dy[ ǜm/087%N9);;7R1=^M;k[&ZBz !&ڕO?7[ߵEwu.և-i3:KW뗖㼳vE7wRp_|-u 6퍠lv=/ G.oMJ^p6uEdԘ%$$$$$-& + cX̞8s=.>{ВoN0Gf7x+~Ӄ%#7A@vqՏ%ؾGIVp;'pʹ9#E;acǎinjZ5/ϚӴkWWIE.G`wkۭk9b [ZFj9wwwƿZESL.M?_<|= Wz W[ba޺Z!m̺ըPZt~[}c@Q;;w×^tb~򹛶ag|S$얗vˏ=yu.> Ot㻲&E ݉y'~Sg2&sۗn'Zf}?lUHh>oT.Emk>2nͦ8zX&k)Fkz<k_MwKI-v׹ .=sAn{릞Gp5Z {=lox *Nש1_2 T1GBBBBB] r`8\a_S<1r1X>4A'"~fBm{?<VO4W=۝Qlڴ9͚1s1}jMMUKb.F ps/B@9C:)S&)!. ^}홅Ͼ+oo\{'>Fo~W?+g/s/_YWWJsl]]}</\uoԂxU ǂY?×~zF?]c>Gev':qo/5Mxس. }?5ĵ7<v68.RD3omU. u~Gɶ]Rs[wrQץ3+;@W &I(v )7Z_}8q{8S=lo֮J)x(Fc w}4n 7il9,)Ļ@d! Ι)ZL3$GtHPz~OXW?O[y W^[xfmf<|GO3 BQկO<1H.]4tXTܠ( c}9gRRr 7t&L$'N7ޜXi656Df1Ϛ9޲e+5jٲ|TM#b@ɰxwu|w oOws2;l̯BzW|yH'/:_?Bg?/ޑJƂOL:xܹqGNDXpыۋ͍kJF5j5 3j֮XY:s s5 7~_>W<7Gf $ga[m-"GMF!⶚ZҔB֨rDBBBBB]C 6G80c“sa;C>|ͻoO5T2%Z1XӠ^78" Uab^__sEw-^Rakn<g8'iJ oi+?=gHɝ/si8r~k^k:[]5-$8,]_fuϙua3/ nF nXe*C{*JR_?搣N?΍i[ NQ)G,!!!!!."`r1z%|/}mkI8#cO!xȇ~|q?vl/]yD=r҄gz\qٹ> ۃi3ag\l_UK`w=\pϞ=:;_]>ē> a <*u*"s"ɉC0MS JcFn:ŻHS|2^ͤ D+ҫ?;Η1Eb814޹'Odt9q/xPJJT0rmpt@ScD-)nH*/V3u/κv/G\#{צݻ6}s߿\~U~ۛ \tۑL^@}d ~-i0$cx1f%V@ف\>ā8~+VʤSab7߸sTiZ~z>;oLUMm0&[FW1ߠX۪][SYiS]nʔOk K=N%~fԨ_O>鸶=h"WQXƊ>lqcN"P,i0HJxώ3]jbk6-͸Wdt#2f=s|ǓY1wmk^=uHloDy 9o6ba{s&21$ja͔HE>9{j㏒ĻJNŴj;g]ZǏf O=_<пez!_ _@>_P%ӦOɤXY,o͛;::әu6 [ێ7/I!}sY **o/MQj&PzGM?vm튢SL:ҥ*ʈŵ|FhD"nco_;';v f[[[XI@R楒lW0;SXLvo02矄;/dذ;v^!===_o"BMMfHrjìwӦ-(ʤ WEwVSCd7N5bYBBBBBB-a۷TUeƘ< VQ&jkmeK9A+K%DBBBBBB⭅@±2,EDMʤpH`R\Z??P(K`m,wdYH 893.ND%z#oU*:GjBtѺi__υIRCG/V: \^Dz_-2wD".%2% tJW}g!y/nd"jѻXyNK1n&3 vz#Dj$t E?ⅰř)߃!BS7s[!Չ{gk #˼r51|uٵ+(s(xҔUl)͐P$\ݚ*Fr#W7E`xq?zyŌ:Y"#(r\Cr U1+P"9KBbPVDVAr#"@ZfLя62`; &7UsS=Y ]'c_G/#sI^hp0ui<$"V`6_&Y, ;C%L,G X,+ukY9/K^ Ƿ 0;^vk#`¸]ogEA>S*hd`͠(c~㟟<_N3zdǶ^A0f˖jgc }ZFL-B^b@W wK2-{vGi #϶Ơр"t߅3`v?t#s:+;.8Լ գ0; _!8{0V`n#? L4exGD"˶ҘV8)4H{xFƋ&M1z " V(%CS+b [;d792X*,i{ĩSe B=lj)?xlfMEH=(#C`=D. 3]b뎂g0]h?A)#9!q.ʭU,$F 1ĸ:pd_Ba":2TV8WB<+!((咰u" "EAfG~N3 Whؽ%sJ휫Nu8z՘C6œ'S{m!F;Xoߔ3mΛaEOZm)~5Dגp5]4,; E~a;DDd:m.^7 E=o8BS4q6ʹVrZnGkqŁ_/ zWz?-GJEVezB k~c݁dps@=;6 [ 5͊;dlZQV&WtPd&DfoSJfF.Gq0h#n#DLgig/E,isš99|]dGGEs8i3/8͡.1:&·7'i@.,G勨)]ڹ$XQs XaUKu2(aEk|jyWXBF&'}kv`X"?@UN$`V ;9M]@ /̡"$VB!py 2"Br0@UB㡀IDr7 y/Vȹ IDAT%mGQZ\׏y%l"33ضW0!h)T XC[ V (Fh3U%$P\19º1I2c|&0%'j!suR3qqC}E(I`%YDdVA$,1M" ʣl YGM8!09~d`*7++װLz]? GN]Ya#C!x_oA-}q:,꽼Ǧ@!s<,}GhS,Ƭj^.qQ "OnU>5gVB eǘ=DqN nj /ʐCtMzko"dȏd{(*1byD^-4Cl{G_ΫP#ª=PXA?]jIB ʸu5,Fѳsx1-h3i-@DE=;n@$ٌ[fxw3fDl)D"?cCA,h$p)ϴOEyah& |#Иkcy>~ŀA"WSju7KF[Eadh/s " `5TGi|䈝 D*m89VXpFR^&O,7c"$`i7wg,0۔(H /^}X/O釫 cb0Wg]qΦ!}Edɜݱ2\r,{ &I%hc43]걋unʑ3,*`@Dtmx=;FV2{G0~S D{vgJB)j}Rlı=QlE\ֺm+K1"! On;0ma}p^H,dWelpj#DNq-r D:Ml57npDjs;F4zM$a*;@$ν:s3ȉA>z7XC {G:[\RDo$v "0R"GTu*e1; dHeZw&+wX늻e2Ī&`"fP""w*BSлpPy(3Bڈkc @e hN$ q[L3aD0bﰿI,hB$fRxPnme#y&2'K:hz2aCha݂ .=15ch;钭.0D8 =ΒVo!zea0P*H j2%YVDo*@1C WʧȴB{'| &PKC`O)Xv yuQnv3}0zpx/0"%QX,ekb ]Ȟ l/qwbA]bі/ܥ-"!sIU`jB;֛lDÈW}ݝSau<L02 9+9EvA6@3< )O"˭Ԋ lT+a/x`x{!tE7lxHc ZhYXt}P}eK̮nY l[5ԢSXTl~*]8) G%,k9%i'eE@)NdS?y cZ2 q#욲g>9/ˈ`PSV^f1sp>IE%W|NT!Hen  Ȫ;B7 ԡ>%#b4F?#pGF@ "X͘y(=E0)^vy[V]+1B,GD,=@ZF/ pNL ch+J¸f9k\@D829q;R|1oFguޜ[n|h@̿D>QaMJzaB E}:9s#"#~ÝE2ALoi{ YIN~e#"B #oWT !2[pK3DAȌcGN{WcZ "KA)D"29oi]/C[%mh:Q1`[HB`W[,GIA 23H B ȉ&a/1@t>w(HDrwZm&.+G3p*M!xP9wK:3&0j|{qMQhu8z1`>ur?Hj?wՉlMLy>6 H̭~vf`yT90DTVM ob Jhr!0ndx 9qwRQ HZ/%f)_*ho`hPnY к#ޑ{ѐE[+FwE#!*~d8, ZFn*h*i5m@~puj\3+Cs"u C*A[" BHl w9lޥՀWsrk.JF6T\4B/(D+ 1NooXk\)G%#YiTǬ~x5.p~A'!E9{ f(U Uw# O⽪1WiUȵ皂 пQ nFN^uxokЭ[!@7\ht`[kOV"cD>_znBQ4+N>/w$!`P xBQKx` _qXG_9 NVq|G4M^GǙSf￈sڏ /WªnԣvAtVD:ob1׹ʈYۅOmv3RD:^Hw(SnE2-kaWI߉3K ߈ܺWw%c(5 i1qwqF#r6˅`f.gWz) "<^JPܬdhHӻ 1d žDˁ9$,O8؄"ДyD?}9!ͼ$ Qo1(UV 1"1sM8P%pU OB-x%9cT덜X!9ƙա$,3wי$Bz!(-a9Y#::7:1֙DЭ#O-'1(k.Mt'F \bO>z7TzEţQ$.-L^y>1YVf+ʊ?}g.74躙ukV0(J[K'1 .g 9I5 !dH{T~@%[!moz?ݩaHh?(bD޶v5@`i!=bZ+TH~c/,U*qzXC>z#o<ɽ8礑=i`Jb'Bltb\mA줣"$uϵ2-!w/'_ҧf'qpr !,fdya([ɡվ*" ]'c+TC7OmuYĤ:! Y8F&[9cEICL&~ B/+ b UHΗ8zfo<3/LNl/ts=#ߎEe~YPレm6W^:M nl[,l?B`Ǫ/H`5!(2*e -"Y!Wɝ.J'4wuǨ䲷ȸ}T! L’UN":$ 1#̒@*潼/u08E;VCO:T `U7RɖWTN<=:Bpxr+E T$O ї_nޛ8&4W;^a5ýQI7g90K 6ӧy1қh ?~CCk@mwy>z l{;zbcN*0 = Va,:spnUX!9DeXCq)v;Ys2xSt+̍gY  %ck5_ZS?g~mmM\亮ivXEAUQ4U u9D 1E;EoГĊY\,)Vnb 5H($xb$;ָ#ES 88S0$/neD0݀i#G4J4UmmTCgM X(9эkFd=/z`D".#RiܸqG45574wwtvvرsXLT}^r2b2iLf``wÆ ۷obE#uFO+6jִ/z|YNYBBBg 7;9%$$< Y\rp"✗J)[2q&p#6bXg7kVKZ,B2kv66֛&7M>0wqN6홅 %=ib\]{#F 眛&/X|ԨD,VՎZH;YM5^U12%Jf "B/'@%VW3MNQK&*d6Ot?um>)|M+ēJi_^RdBYBBB}ɘHpCgx7|XdltY215+(D`#)@[ԑק7v2ҒGeYRU7ѣ[D.R-W670Q׍d22-L9rd& <uV[[JKc5^zAy(|.$KM=O76J_xJ5ɓO7,?o?3_8v> x^3s[RPh]_~zjyФ*uՌ=&(s?lʏWP7 T^*@,8 ;z&jnЊ|nQ[NUdWU29vZYEq_'.C q3n3W/r,O:MtT*BAJ&"Ns nR䞒/hJC~3K?~|Ï_F?Y=g.˽Aʉ@^x1| '+-!'BD]!Ŋ ‡ig}s */^N)fYv˪pXgb [ u)QfdakaBC)p^̥g~N$Žcrx_! p 1@ι̿YO>[$DC1@\.z{1_ Ÿɉol7z;tK^7nZ]!l _@Dd\DvG:ݞ |G|`CBhEgf؁!sb8] Hֶ}ա4} )b,"~`9LAqǿpҤɻ|?#Ef ,k$ݝGr &?RFU΍= ]Ϙp#'P)J) 3M?Y/M9Лy ejP0Pdeβ4gr9'I"Dc}55i;$eR /BaZBc/ Skv¬}!j]|c[sz>PPSߏX1=Z>$,LaMqsN*TBBB=@tY'jR2i \,J֢iX~[esU"ـ"11M{3;ca@*qSΉ5_>x&2rJ#0UU;:i1(LdVkιmmmb `d:dvoBE|/Y\{5^ኟoW)+dv42?#p;׷Á9_߿fJYBBBBbd%X=[ZFgΜU__0 0zzz.һ];eAs-ew:^0IiO) F%iBnltL4Mq3{e(njn492TL@D&7c;2L3gEaYV7qN\>\gGg2&$eȱOl4Z7J˴#(Pkk/ʧN;`|)P۹iĔ~qOy8LBBBB/VdÚm?9?c̑#GrέX,9r̙3=?ek|p<o96% q.$ ΉL9bZ(M^_7"[Kc.j}}mwM!4 8'T298q-WԘ&mذ^Q՘g 8ф q.!nܸC"F.5XMJq-qu"X,ijE0J$- J"Y,9(ڴ;Q]R޹/ 0E'F!o8 DBBB=RI_RȬ| Ch _g'"$(_ː4D>е+MM6rDs(J<?oβ+ocEpίGuT:hf.O~R$76|{M[mߋWTSEȕ{,ŮyHƔ HiY`O*#E@8XK>LYRY[%c " civw1U8 +0d[nUǑY,:R9Ѝ-[Z$4-Б)J,Zmm5Mz+4O^n%cի_~EɘzKoɟŏT*ʏz^x?g ma>I%qMw"1x<;2L&H$/ lv Kh1 *iumt&ǁJRv b%!!!!!!nc.SqMknnھc [fϞnH ; {ﶄsd2ӧ+|Vխ̢9g&wX,ɬduMΉ1dL4&_-)ZI[;LnZϩ0Et*̎ڸY/s"c)nr0JHHHHHTRL7/s1Gd2#(Q,7n];:;jk7o٪*J"/[7؆F/I/7߂bnFelFWw#Pbhj9g.ZlICdso/8!Nd⟈CNrVK(j2tvUM8sع'K:s%SmO$nrKsfDL@E‰ogÆӴbJ4LEQ`6&aLaDLj'cG^_1+Z躞I+or-aͩT{D<˧Rν# b8P%]GdArV>X<| DWVєV@$7*`rSQ54-Re=fIUtYN݀T̿5M+J(zT4Mf2pέᖐLd"_W@Dž6ƒ|JHHHHH@{O>rNN\k.W:-fgWON:e7u\0L:iLab^Opf(q zRM:=00PSS+c`` Jif{1I'ڵ;͎dxOOgx(yoϙXqd@H@ܧE,;tYXl9" r`W~m6mχ7a8dScٜ,޿ߎ 0 ㊓Zv\I?=[+o9bĈӧ1};s^Kr|/VBN*VěO'>H~Leʔm g.H V ?7oT*yB4rԈݻw۾m@vv^zK߻-1k#]G`ѳϿ2>K<vt28ĉ&Ϙ/*x[pa2Qe+BH2 el 48L6`bř,pΛMxK[R鳟b&9ꨣ8Z=cl…cl{%a?_}⍜ & /I ˚}q3<(a#n!MBBBBB;8{gvWZUq6~hc:8!$$y !B Z17pw"7ɶdɶcvGvfwV[tHٻ3wn3瞓 xp4svB1Ϊ<&$7kY``0>tuF޹sIܳ-^8hy^E! ׯYuk[_$g01&͒jщ  Hnh6qc0KG ЉOBP~<'$Izϟ/ID4E)e -jjRbˆl$Q)JGM 5F)'OdXg~qfI5Qb3Ϥ|!F R[Fd.f1 RAĭ$Ң" Oϳ.䣏>#G?^/2ha3ǤsP9yu=ٸ|ނ{^ A" J+rB›(LdNX,m[ęfG"\ H UTí|;d,N Ŭh k5k`#'y_7b}=6Ӊۑzicz(.K VuzAA_R r )h<Wu5IU@A+\HYVLySo^?9% )>D{_m:xNyD!x . dʔ 색(o%`b3HQ84Ax\7po"VGFC{sC4|H1Br_  H1nmS\{6V8Reu AA D61+abɴ')"ێ5c掜C:_ýh`XEjv,q܊lX{9_ IhdæA΢XLXۦ}wg6KQXEPiOZnKҒ#&NPJѷ?@\fP2q`>[{n'}!'R.ؾ;h ]<szZ8msΟ僿É'x-୆hNVt?oV.ArEn Jb6B ɊO {i IVH.PY{MoÖv%Gu?;39:R0N^BV{vKsXo.n&)y]NKU-DJ)}O`#yڞ'pbsH&t~8/oUGO芟} ^A$=lǖ\g YK09NNLVۘguuzj?O:$]7;l*+F.KD)M5V+όltXC4cWwTz^Т3NU@ 'V}ǎ6eƃzonP=G]].u^xq6o=f=ZXq_ms}.7x;Oٓ?Y&:"6,~aޢmmE+~y±TH7_u~ęW_1mb?XYRumAwėGpcF:mH0*HđH'ͷh_NKȕh˪ ͛WE(L(%ʦ$4 9O#yc0}dW D)r f||1K>;ԓ(mQSc0xF뾘xs/ڻݵz4yܨjЊczvhZ7ŀ݌>p1êj>xW|9]=৯pYߛ˾˜z6kSg]}-{k~ bc^rOnk_{o;_~.鿮FIlѻ2'3E'?wi8B;񤋲j%Yz%mDn"CvwϔS"@=rZmo/ D %_,-o_4s}$ާ.yre!xF}ӫ#߼7$mvG5SO/le )V|r 4{yOjC nز;;z4ႃzSF-F 9fћT+sυ}5m/kmc`ָŏmek^z^偉7>w4/wbvd0{`js('0zEeփYG׭ N!.+;:m& .D,ڳ~۩4Q*;"yr2|iw\:6!<~S1H7; 'rX37ꩧMKƎvǡ2hy(5/}5"(v(ֲÝ|i>[w[[Lí ୬qZ }O/5mv!=8n;_zl܆يYOwF9p9B8*N"m@{fBί4wUoiexiK|DʓS 9v@vM2Ig㨟n]_{N?Qٮµ+ٲRfI=+kXTcFs7cL ?I<~Դڅ<)P2dMWiڼ0$&/>X[1zM̈́1 _nhRR>QE$ԼyO==yr dNs|1 H % Hf?qL+`<ŕIQH`Ï>蓏@ros+*$xh}O}:gX֍ ~)8jmc(hߤeѺ1^'U#ŞFKu;@*&^yI xhA+9PU" IDATwbv6x޼ H]vIt`@`ʉN Qfr[tPN<X`.Ev7Zzh%ge4K?9i X+fMo10Bp[~ ,iV]5A c"gB~ʖ{; #q80)eTTyD1 !Gy  åR>:쬯E`BE签QuZ~WzV!! x"3649i32LO, p$Xbe'q& e#G[9Bp=&IX(2h2*_-I^י2ů-*S~ܗ`WGQ%#R芙'4m|1!0vjFxD%5++Z[X k8M mƘAAr՚Bv沇ePB+(zQ(αDc1)*y}p8U,(W#<3cX,1ާXxMB-٦EWc.ַ襀 RH $40PJ8t<{yKK}C4 C19['LnՕW岂\( G4B=1(^/ N#?QK_դӅ,/S: AI|0sT~m tQJKKx[[0 Ī*ِ2@_^oi bv-sIĢ#pڛF0;r9p@A\*D9GAE[":SVí/c>Gey)yDaShb@ ^1cug#ďbFA|P6f|W P.{}>o{{ 8祥%pZrΕI< d]Dc*EzZg'@D]t9ZWpoO r|1z9)qUUU~Նֶ6)e#zb4vb ( !Z2L h9fi<3Q-Y͸y2[)kVhvpzDr4ןv@Mخ+З9ey!^7 KUz=rY+cy3Θz/VZUkKkzuD^o8t I('QaC^$Yurk{) 9$< '@(eexcR!s.Pj'5dL)%R :xoٺU6ۙ>$ 5%CeENv鯼Κ݄ʊϝ1fͮ=MT c,0A(ofLB;w*l˨n` HvY9flTE*lGr9ldG1}H81NOmBӸsBQB]83X,f3ӡ`TWW_y *O[uUW^2a7PF*<±ejr\SR* tܘѣFرsemܴYɶMc@ U8IB z;nްq_7oظW^?rHīEvbu2Rc-VvwB\Y)/eF mG$s.7.sRQ~쳦^4ҒRIM8K;Pa^Ȳ^VeJ(+Ǐ7b͛,Yl۶ֺYRJ$B)L>Q#GR8jY⃏9(%S2cR4s_VrC EBe0AќAApm&pFlAl( AI`D+o'ٺL!8%_ s9ѣ\sy~4' >Ts(PėbcN*͔Rs'?-^fl+<uR犊ʊJ \uizt3@w{Ŭ՞,b(dp/jtDLQA mgPQ/n]MlD#+<ɓ'}˖o>MfZӿ[ߍ-b+i(Og2բ;.Iwu5cfwwj7;lgEz^i<_wNޗG HGocVviY\Q8ι޺??n9Q7` qN~N&c8ulLM;8Ur%U[ ;ؐ/ϟnzX_Mi;)J1۹9[L )6?iRv?NN_akӾ)L_ H(\rjrѧ9+,5JA8HkplcNc$D31lnj:{-_PcissKUUew46l) tbRk2u2d;dk}A5?˪aKJF?gSR'Ah 7o+n1cFS*cŊ7FMz@i;&)rqYI_oX·f$ Ps1e8'nٲ'ڱc;DHOܱR"7<7Β͍:*fA)ŜId Mw3 dљ$s H$(iPJ&b"y~*rjjj~zϾu2DY"et $f-[zu_""J !ÏB[~+>X9AY)ҨhTx IC.vT}(:}?4챚OnVlGu:17;{͚k]?fF$CŜUii2;bכ P /ϿH(BN'4|!g%8)JB b]Yxժ5P*we;zATy 8^2fs&I%]W[Wt{\*ZE$lEWh_v˖+DMx;50Ƣј-A VGB${(PMY!>Hp${q*GemJ 5W>#Gh;zR$W5RUS n߾$H$z iuFt˦6QAJcgA[^QEwfSY^}UaRINA;1I|b%Ͳ3!ծ҅>L `ě; 1&ŘB)|Jc\mTvZhI\GFcp5#lW[VfBǑwE9 5囝׊+`חiWJɷӨ8:r3rcL 73 RG"QAb Vx$E!scd*zpHI^Gjk]!A$1⅘*oӗC)PBGg|H) ÑHY5gC1{1 EJK}fRB""]D8P((~IzffcgكCm!z]lFQ.#tJ ].S=ry78,AH4 GE!3nP(RRD Ec~I'z9c,cRRRLw9CUdy$t0$O1#d9lWIi =z= Ì1:2`&3h4&IN9(£:YADь Hvh`|aPJ@Q|Ւ$!Yik`0IQ؊3]|p_\Wica$HYޕKN68G{-b_/P4#b ܣ-al~3!eL[Jqؘs ~i hkkK3h _*GYFW w%|ʃv\M"X@p)qz+q_VY=m $8U$rjChQ2"9jaݱ/T4`kk|iC8â(ℒb6\ u9+.$A$ Pe7[5ك!BB3 PyE< ]z(ΆÑp8 ._3YΉ0mEA$64WN؉1,x9%a(EQ4tG$)Eh4J)-)q gY*#בNX  H66d]Chcv]?JJ|r¼@ X'41.zh8똻aRē[؟[̚qj^/Alh ũm]%$5!R-SM%I'+beA|ܑH+)t|t+o0L]z#pOi;tTCvn ,\ܻڶgҵ7 ) VPG92!R A#Ŭ^JlP!ܹKtЗNS:ݺRNZ7Kdm-Գ1,--7ލAbe~˪W̚3>"1kԭrDj( H(ڜ܋[Vv9rh\j+@_钨;fP#J ͻAe;^ B8l.Ᲊt=D9tݴp\\LA˭_vl$i_+ e]n>i6, 5ć s!8çbS ͠hΞ6\#E EBr=|ĭɄ}<('TO)~ŬM%Cs\Lzdo W!?C˩g "4MVjY8@hCV17S҄ke5>uw-ś|p].7p~ҋg[L2ynGe=ؘeC4zns0`pq1RY1xBH'_YfbS8N\4: HkfN.rWO7T·m*cp*ަf63f3/NZ@A1RBgc37X iezI я1YUqn=Y!8p7Qey` f$Y,b%^ߠFF.@HgB˩Xb9r<[|y} #g`s xߑ{ּ:@>V$[YY_r=ޅIHތX  J439Cpmm2w)ݻ #ѝIwpo'Ͻk>Kn'ιncVzbGvywe#TOy&CuD/oޥ۞.ݧ{!b*P/Y~ϯ@8׿9kw<u)fY(+#fBKYB\9OoShJzFI{UQ٪c\ <2YX'23,j E*1R)HK{GU{7~A{*OӠJ*cbVƎ6OkCpcƎVS z6;D [g g3+~r?ڴ'>n7ig]\67&UVm#Z1tLخd j_AҖΘ A$ &=%ݑ-bDbc_U=v~\pQ~WЃQĀI{>"GoMS>#˺ ׯ?~|~\4)>k7m^䙧>k\E#?2XkC]m]mm]}kmۗ5l)( 4jOBTf]Ԓ>KfXF^4N7{XN*F)uRW) jeH ',քub o]P7Ƹx0¾XD]7bis=~+mk!ý1CͺX<_\>[]GE;pxOɇ6!0l1+XY*:06Y9fǕqM3,rTUR5׃/@AC9g(3'ޕAc6´~"(@xu9=eɓ'SJKKKgϞ}ǯ~3fah?Pa|m~ ο3~x<ֺqVi'ljC- e]D3jւ"JAIO4k3ќ\l8>agʨĎ>{NvY;p(*ICuEퟄcKw5E!zh{T9zLIbqʥQ Eg[0֍ ~)8jm= $)V hF=^FR x6*ɾIJWٽ?lÚ~ߴ3_}ucew]͍B y4q-R9gw{orD2>oƊƻ.qnOjɠӰMm ָ}tΨ`Vutݧ{RK?9|^æYY.}Sճ{"N[A.*%@ҤWʊJV&k [r\-ibC78{@_>ߊ}$Rh Tuwϩ@qW$fb/Ji޼l r XgRXޮ s`-_xi܏/ Z{R^~v3挨]s*fEm8rb%EADŽ@ָ4 k~Qh>|S|۾c3i[ I,9b[Ͽ #KG-|rB#[?쟪#bu? k?P.[݈.fy8 v|{nv=n]gzk/'.?~8~x 4BЄO2!Jiv& p΁0I(edM R(Ç}u“-?wB Lڏ .& IV#P!m ?,3]Q` tAyM}8NIVw\ 6Ѹ)٫1F-L$ǂ:^o<;&Wՙգgz<.r@1+*Y5Y5rx_nOGt6O)LoqO;_[ߗụ<:"9 Ts#_f'ؼ)qt v+(n=@qu-./f=eVzsڞ+Q?\QHc 8q%lAƘƬPJVtYDa"T/:Amcsf19gM.)KS (׉Bh1qFz3dMe16h\,crgE4麊YmRt3cLK(=JN!])l-呆Mm+ʷzvvLo46!>;3qHM(L) qŬOcCX{YRu?ƓfuK-y]+ 3R%W?w[ AH?F!P΀Sٝʙ^8ʠʞXHŬe3!89 apY4Me8s)j̤2[k1`9T T8xɌsF ]㺦ZֿfQ>$[Y%nȮ̪9&:kP}p., ׭2 `]>UYGo>lW>L"L㪬fc]n)¬xgҏĉd'l*GS.79Bx #EIo{C̿q%g3E0EŜ`/WNA2+I6bw3RJ=KuZWb؜r\\/i[nd'egݏP:#KH9R" 6`Nt=+HD Gb_VV.< ֢bF:C152[yAA Ҳ0Zٳg%KuYlY6n1x≜p8 C$Iy8hՄrw7K8 !R<\R7@$)nXAHDJZG Ƙ2jX a ʟf]tu(oey޺X,pEEEn;dwG$<c,6 K@E$X$d#"zV t ?5755?yaBy9fֽOչ $| (f(P d{M_ݥ*I.&,f7掟VYY=L +-+}R0Ӳ^Ur崁n'\un_ ̂KFXn`!! HA!GR [Аi\c]ݵ>WozCϴK-; Ū@@8+Mޛ76K@8ٓ[ٽ moc wwwxr5>G:wO\^Q:o=f=ZXq_mvڅwN6)ܦЍ;+AWz@f5"tYl@o|޳_< 3 n|x<Y6ŪJ/R2oߢy|t;P1cGt[N⎟:֫T]=?n z޵zsܨj @+۵~fA)f/YW05Se5z>]O!6rWoog /-*k~MG[ZZ:A1[ ,^T󬙣x}֏_[S8Hz))>k7m^䙧>k\E⾃۰yǗwEcʒTyJEݫ}z Z忰m_ְP2p=AmįjPE)zݬ`N/W~OBc<*J*K 6zc;{Uyvw #PRmGX˦w!;~ભ-( ,"Un- Lȁh2򓳘-~2_ᚿS FX+c>!|BJB;#Βeb$J;^Zb,e})/8x'iIe DQ΃[hK.k ~tWIIE qͅ3]1RɅn\ULS&\`PP@X_XijuY9fޑ,rTC7ۣyL!",u>oj=`ch}ݝ`{^Çhm:#aÆ]~Ǐ^U 2ӷ'گe9H0 r=N=Gp,7\Ѩ6z^.ՔhR }ƒ5O=VG\hd]0-H 䢠INP׌6]A4  GGyرCݺumkk ò H1{>Xo|4lG%,fK!жw]|ԒAS/էaӛʿzbGʬ4Eo_^pt-Qh󆟕BvMXs+xTHӶ潰`n\GZ/Wy"@a@h%gQde= S̖::_9[ y5&*=1n?ӷ Н;w޽Dll<@|(ؘFÛ~a~d'-4>e[@9V/n|Cl^,6Ze%(faw5HƉEAr2I,„b5)oXm̖3|dG3KOhh߿?!(/!ޞ9S+XsdbT€d'Pw帝\[י^qI㪬O9rN:!ҕTv%Ȟ f+9p.qN A/*{S6fb9E2Aֶkr#s0;YcKxq_*)U:X8{}X)rZ~cԼ%_pB))Ҧzwz@r=yW]s舊s{$DjоzT1z5.djhأ [q ?ouT~:̾z^8h s11,foɁaR2+'}wYt pξo'Ҟ-<ϟ=N\?\zB\G>'.;N Xr֠|/;~:~\ڼ@ˆ}߿PS}Ala EAB ds0d_U2F#w8ݢ/>b'{QHSŚZ?xb5/xhy7hgyte gRLb¦RSoʫZx9M,A_63pƖު|j=Ϝ&0Šdf MGc•QW-sVF^=i@틏/j>pU?9߽cy(<&u /^٭?짏?{ÆRSQ noჲQRjAA:)&OagN d^tUߛvJH鹽hݺx=|-7@qǘ\3z#)R2U%bs{ʿmS|e#zHm (액~Eb?$&fzh\][i`]IҾ_hNNy j!28O>S)`J_ƹ"Uv@ҤÁ~Aosֿwçy}w?:,O!T xp/X'ۣG}RJ^3X$BT34%|Of{VU?d`'d IDAT灉߽j^Uݻ]ysNnƁHDqk{Q=^9K[J2 Hq0ιNcO:&zyR@E|6 ξ꼹 JsDBx|]*19<.rzA O PBJ'dl=VySѴ]nƏ?tX>=JZ],0xDPSEyǓaVz̛=潇쁊fEHΚx-|&CAGc*Ie+u/ݶ\"WTnFoRA)3;!1f!dR-Hd_8!w)J11nFA͊9B`ތ[$)>"cLs0d BYLJ =72Vv¬{vV̚> SԳŲR/~:.58-1zsdp-:jEnչkyW& HwLbfjK͕]U#rZg5nS{P|C/@5{i w fgZ,y =~Ko7G+կs+d'tڏ HGVv̴ P.;C{7g~LY︬[*'ݣptzcYtX;W+ E3 `;Ɉ?5 ycD(Ne@mԻ{rU+@LO+{,%*oCexdT;oXNX\Ɩ WXԏ HQ˲z;#ې6Q-p[-!Ḱ֖(OzyO[f*'7мyO=N (:'zT)[yaG4vֲ=kýScv~' fu#Z'1|]s͞[;ZhGh~A$ 0M޲<"~vo̹R2\6hwo͠+~8c@t6r},RVǟJaC)=ƙv߾~o\ *f(jw~CtaXD4cBb GB+I\]kĶ|!2:oN֕sی+hӮXI\6z˿73LYXePNN#3>qCI[pCd'_uU=X.\Քٗ?ơ)s.V*ɖbr+)L.hR\]]̟ͮحvSSi2XԿo3 Ca2C K:ClQeAF$aj6i ޴(9:$ԎӃ7~?m;,5|WO?"͑3X/JGl[:9oy$gͻ9X]fBRu*E$oyoRN@8"/ի/5We,!M10`؈}zF"QWn0X`b2GYT`aO:|9 }4A$ ك3{ މ- H"2cLq773c?SUM H+ qz 4+[ R̊uIzoWx)!Z}9!9TNjHP@sg0AAӬ $ONv7qAPyRf9̑  &IG 9NV`9@z! ]W1;OM"EsF9Ye ΰ~r\lTVZ@hV[Ui|M:.`qi<f#39 NU3Q"B G'&t1b M) @4RsMNL@jPm\bA E*ӔzgX~rpkeb|ڰ}ZjWVҘ5a,G ɛ菜&k_o] TFìk~HqcDJ)T.i)~>[LM|e<pl '\~y5ϾSS='Y-)n>L3Shl4~[iq;-_oi\C4^W8N{fX'MM鬉 岦zRdS3/o!s[AP1MǨENS!e&ܪ\v>?< 5]@W|i_rY1GV!g;:ߵUo@0O2 ⽨pA0̨OТp۶UnnVAyQB+^0 I!Hyk<{3g?T[ެR99{x>=k[ dt%^۟+cغcr箲8],b(wy|a K~aD^3EKyWX̪UI1W]R2^{^TzÔ],bwW?NEn > s.E^Qz(U0Hڔ~kIgafv?CwϾ&Yú@+OؙYe$K-{z⟦T۷P>+s?]&iHإVwHV{wʍ_m=EOٙ/oۓs\_ixv οcVD]1$H{Uj/fSQEQe$2ʼn"?햏ű" 'B93sweEQETTֻ̈90aCID$ 3U10c\zQQEQv@):/e.l-aŘ{jJTr*Jvt.b H+(ʞZ&O-/</[^c*_(w%]Sh{]10i)}((~! {hF-KU̙V֜&+(>ͳS$*;mp245((x[~( 8ZEQȅ(,.5Zg{f.x=oqV`O+;8xC V&.#fiiiey#"rju}mm]Oǎ)$@QD#(( f.=kuhȤl hy9p`CrWdM&:!8rCWn?#ŰѧKP}Ҳ}eEQEYln"`_J.C'NMNv9Lih> QQEQ^4tZȨhr9|JgYjcGonMn/o;qrskr᥶3dYQEQ_9;f鵴67~s̃lXaQmڶ!"d85t[&n}Gjݖ",<(,`6_yd(WoR/B뛘P " PۜCt^E@E%!+"w˝_#'O7o9_y6kܼV6K["$0oOlj埢(,&3G]Yo}\kfeeiuu}23///HIG˓tǐcOz|zuْl4 Iu9!#FMFEQEYT݌sz4, ?y3K=j.w>L5]ϹmwBVD^u`ePÐ=[(,,㿩Qe;_muڔuS:CIL"mKPpJD$jTu7ɤC`pdTw`7s6?} r VEQ= АZ&c$`-aDs#D`^d*.. Ez9h܈ipcczNzꓟ<~~BG?m?!Tf&Ү,M&]]n a_IgUD&mۍ^~ӟ'1^N_|6}pun10Mlt{^d.6DcW{cxgs%"p O|\~ŕr )O|k_>/BM|Ƙ !#P?İ^UHZ(({ :(E`̩ Rf-29q}zqe2d2MDiZo&xYgy?}w: _pPh&=&ԊHT1O}6+Xۆ! L]:^_~1]?K??wr-:bLke:F1 3+(^ `_$8N2Ah'Ęi ?gaKg܋!D@dQ(( 'm.k!#y Qtq,,2f4Ҭo#w)ȻHxY7=ӑvۉǏ+J>E1*(pycSgb&j۶m__kwL!E>[=|i߼LJy+ >1ʦ!"DBDfEQEZ0JA&~R?X%xΉ>"`mljiv_ҫ~ַmM:\%p/1f+ 1ҳ[Jk bskrׯ[__1 g=} Pf+|M_劢(!QU ˩h%AY/I1`q;/~'OEKxm-N XfVsō"z>OçK~|@yjּiEi_SG"**EQ.ƦK6$iu`auH3|c_M QeVW7}K^3?Qhok_;|i19k[Zj&zLFt33J-SEQ`ݐ}%b"""Ai]_|2jHdyyj.Odm.IK>@2x[ IDATf<ⴷE`bFn'(_ g㑒jk3-zoMoCl;e|翮Ҟ>JԾPLyMPEQő̦?FdHHQQ:VR)9}6nm0dMi[jiBu4Ki( 9tkWn"%H_'Oщ'.]#l_u&)Ih\'Cd/aO[[m٥~̟V0 0(r@4;~%[ \/I(FDzD8Dĥvcch5PQl}/&du])lF{w5^+;gK{u]6 @uߘpx؀/0i:ẘ(( J".#Pcw,DqE(lM,-kk(J1"DNJn?"\ ^a&[38G5Y[[ovjN V&GYʊ( ZZ!{(o#[4 ~@@+b}8tw-őU_D brc%gCeskҶ ޖ",-^'bf$;7abIY -+(fafDD}Fw=:4TuFP&)Hki%µͭӜeKD,_dOK^Y&t1y}}s2ѡCct*0m?cӹnVEQ&HSzdD17j \~M9rhsskmm L){;˙0a}}:k7t[[f&VNL r ~X3ZGtPr?(,2%Jj/u Es=j@!8[~%6tFͭa6@V4oI񭷞< b۶533Ղɞ!9g6REQe} "b,/$s,TfHton CڶM6+]׭; ^[[L}+>TDD4YGQEQ̶{,A?!V@$8a&i ]4͡C'*ƌod:tPsdEc&R((gҖH__ 3CPwB4ӑ#7'oG#G5NfEK`F!ln  sHQSEQ!ߵj!# 2@" !8eߌY=m3@fn)3Se+++++#g#"%w#;$`((jЊ9Ho$f0Y%O(,`Uaiff AƜt3 jkEn]]ׅirsAl~eY3fEQEY\ZeqӤ^fR,^ċQ؏0"MU#+(Jf˧/Gq8VMetOx];{`kw/{7_z^kֺ^]5kx_(f~f?XEQe3!BO|Y~׋H]m\BS|þw|}'Oܕݘ\_B^Lg,HQEQL1+S/RWx a7lhL|X#|c~u>7Gwݯ<tvi߀}?yO~"WǞ{!ؼ?onð~CW T(( @~w{ܨ^dqܝf!"B0wItɾKaɭW =._̍&ʟ{ _O=\mEu{~/r罼|c??5g+n| ~eoOß#/|ŋ7#o|<ٸiͼ|Nmu~UO[{]_=wy̏cxVS_[? 'T.bg\%#G]4ICQEQRPC@)R="\z" [<曗~o#u#{ rʽ~3'+\KΏ~yKxk7 |}܍{;>y|o=96n7jٕdI;b߾g=.7~7olug@sNԥ_eW |]?1(~6Dg`ҥ(XeU}i,cm.+ӬL@JY"nt`ڿy/x\p?Ͼoxyܣ9/<1/Y9KSg֫oCgjιxS_c7ιݛK~^ҿvr6G$ط޳=^9|ƕ~W_wK_;1]V!1 LDjYQEQ#[eSJzׄ!EGlWBݼ7e??~OwUv2wl ϼ?_|ƭ'}dl7?>{/_׼v ?t*8v,GF ))*(}=h(\33 0L8u~ͥ_}Y֍_;٦6iaRT$ ÏjoLEQEsed"B1 b#6<AaƝ{?_~&Z5A%$(,pD1UR` !"2ISCf#|GvZFOGv| o/@w_ׯyW>}oo>?VoKD_Y/~u''xz 0?uͪڍ/~./O]ɷc_;'>ַ=ܗ>[pxɘ=>?E/l|Ս{=Oz;y?}-^'?#ds O꘻IױL&݄yoy8*(ʞ?WmCMC-Qc.< ro\uL ]pfaw=gssKΎko\qzXjoShfe((ʂ! aWH_"l#fVB a\y`w?! ߼c~iw~˧}2\F"SH((KD>,">ǡ#3 POV-]/}O]ŏ:.{g*n5=N IGPQEQ#) "2p`Uu$ BCgϾEbg.hڿDQEQ0L#jv*3VA:+B_'֥sIPѬ(( xC BlM l"ZFIҰYbz6uV3 "T.+g4$"damfz EQEY<,"&#s i )uh6r*fuW<0EQEYT% _J \yq]e{žprŘEQEY8&Fg_a7o:D;BDuzF3P1ץ0)(@DD3h)l_#KzF3cs.- ?֐(,`sV*9gc.WO8E9cXYYnZМ~LzМfEQEY@X˅9ZU/ dj2x#  W]&a+uq]-Z¯3 +cw٣Iҫ4[11 -9xi~2.3 >nMӿߊ]" bp볆35ǩr(*H7°[Og.)(;Q8Ͳ>7MQͦ#71 P3^DLvӈFMOs fgIR'Is[soH'q;l=V"zPO@D46`Ҙ75)=@DNN"a~BbaD(x~טּ Ѽ!m1H&r-$P.VfEQEY0nGD/#VNcFce% &Wu 8qI0q:}2ڄE.Ij{; | 1]؏rpDh̃Xl .(Q6MӈH]RbdoV^p*#E6ډ Ko~`VEQEP61aX*4ٮ/e-E E̠1uV Bi}p&j.EFa` [)ؓ>ۊu 9B9~[ζ;"v]ba#A3<Bh t{Ӂ@x1Yq!\BXN" ;i_B XQEQw׻V#i7D$RLN}@)/~*XP9 >&ZеiO0?,*mjw9? Mcc6~BS(*?w񦉳QAKG1*j>X3{BQ0̮ a(SCU+a9f2U{~=f#mؽRmWfmܩ)((M%-:_I{ٸ0Ɯ`3icf2L8Wb%), ŚY11c1fD$jw0Mj  $@ŕVلP"B>0FЧ JLX:&7΀^ #,F,xB3K30GY5KsԸQQEQ6LI׻Ddj8ee_4fgN6lX.^FTc t Rɍ:O; 4zN8'[UX]MYa0%.`%_K·"#ؠH~ P)p ]3Vv0`bA%p0#ыb&A\߯ҽ+(/twl@'q$5J;F~+qo~2+st}Ë1Q9"DAU /,)C,]ʟL9*- EEf a|z D? jEQEQtVK#YEb+ejɚLSb0 ){lǼRdNfכ٬qo8s6H,7 $Gsf@̅_qt ؃4m%M4Ƭ((P4O2/#1#iT_Y!9>/IOrb6p6 dd vu71UJl8+y~m\]&ۄ.,j3dWg34zwp:kݟhgcvbS̑sd4:@(M ͟m h1Sݙ)(27x Ehv髿dd1H'Frρ(U6QocRSٚ fGbf, ,܃R()jp79RS<4 rB,:ህ-K~a.@ocWoozΎ tV*(rf!ij7e٦ľd5c=7Md8;5IҙxnR I>jl!|)+dQFh)laYcJSJR 9Dv&Lq6A_ ֮hn3}_ϕepͽBDc cRmy1<(,X- $ nEpy^*Ri VJ`C}S!TF}LUzp&Si4䋳뺺X\q?v(L#R Yړ`Ð>7'@Uqiiq& U FbF `ۯ0hsvÅ(JDL_ T̗*HԅO04} ]DL߇He%iH" ,ˏ WH0m"v.%EG`"DSb>`VEQEC K1"Ӊ1m+w-S ".#R [PX'qK£V+)6#6[ yAYoiDH C#d"SR1BDd;I"6:Hk(#'`i$.a3qhCFjyȡ.ڏZKAh((H%c W4LY3jޚ#mhWNVvUGZ5x SGMTԧo0=I&{@20vU4LB{@$4;2= aiČb}EQefD @Bdg:b$".ma׹͖f%YEV+u"tH\xec}-J?],|՜E5uڳnpYf WF]N= AŴ(,he s菴/ng'|(iP6 z.4pt, GZ٬٘A.fhqđ#D4{42Z݅pጷێ\r-qyAV4#)@R3YF+(%B j`s(JA@AY7Uq%4EXTI^ ڃR }^P9k&0ifQvkɥ.SMrtR5zTCX2ܬJ- R>뼮WgNGgs+3 33U>$۲\cck>%1͗z)SAFftG=*ǘg`&m,*^EQEY>DL?;~q*9 AXa1^0!p6#R,$ۚ;lrc0qw#m3гו],(/1氏,`5W|:Rљޡ،yq$EQEYҢ#/FF2+6p\l[.fmF a?|_$LN5bKAS 5\v.1fwVn2aOc&s\i])ms1{tMX|^ii] 1!(( c#wdJ|ŴpېAf4ߊAX;MY 0Y:c<|FQ$hm6C c8ZP0? tH]a9:k\cW0-_g}g Fmt{lzmm 5Ƭ(( `1ʀa=&'FB}'+ƨ pYD)$v, HH$d4 o((!0hAFHZ/H FؔL ŔMDDDlOYLUkaS|ffDX*sWj Z. GLŻ/؏XFv>,>Hbí @Ip`45he9 4[˛ k#͈}r/yM ȣ4 uK 0o܅U=+(^M¬^%\&2O5QSJQngF~Yvf{m5lzèpoةd1n)]`CdII.ob#aYze@;oMsK2:As#)\CZ8,#!0d,0EQEQ(UL@$ oao5F8}SD#M2L+"YvIKM(9+߸J)Fd63zZqҒ@%vhkze@`8H (Jߣ?˹r=A+sSy-#iDfݚr\ @fPFQREQ=G6j.TIЩFcJ@`w4)4r7!G |\m"P/2~- `Wځ8 Rm6 WF6:m]p~c821\1ɦ6 " [6jW#o8?iϮ[d,݉(({[l _TU4gM_.gC{:b4f>ߍۊv LJ хH[2T=p&bަÛuZb>̮v f>s'J4OOa!.Sf(@9n+1 T(䓵2=ZgGEQEQJ:']@r9lJ݂-Nx)7Dէaw b>_)rJc@Dpk.S2M2 ʩ϶<WDNΚfv0QEQPeb"QLl}3C6dS*|NHg?9 HɸW4ׅCn{dÌ0>c\5H89 ݙpU4mv6frCa;Cq+ӯ)ڥ\ .A_5)(kfWV64l n*azvxmHe6$սyA3 wS_)ˎj`GL.:"\viw)& 1V/b4 Nǰ|vp`mӲEQEu?CdsC YQ2c3&xmccfj@xnPml. F~S{TW71Fbf!^KeNFb4+p+256nH&öwyHɹh?7m3KдLWK& r ƍZs 1fYޮoHo1gճ(({ jEVTm*=Ѵdd}eY>F./ c^%U9 5b {o9<dskilR(!x!  sܡT܎ы򴽜 F0L@-2oG,L3mU2sա?iWBHٳKa| Dv+]HD^Ӳ" ! }w+7MX+yC0bZt1( q~c>ьIhfiOS(1 sl[=SFrhX-f|<'|ߦt3BB c dI@"cga-)NBSs 4Dy(&@[͖gLRuvU*,ul;![(n1;9f~ۋ߬ͫ̔[[!Zb͌%LQEQ4HfhRԄy #1!/D5HgwصTY)fzvV7c} y M7 M.+m뭝B$Zy*,m*)0eJi5SpzyxJ̒;xO j 1F0m;MGe9KZTvaiPWq6^oٳ;@5BL ځ|wwi)׃.#rE:E0.Ƭ(({Nh–] Z9)um!" z!agnu%",['פ8̱;^N]esd+m\30<K{V=F/p>i0C&idčbv~HREQŻ0L_0*a<;[ }\ڮK-'b?CJZ*aFsd;%#ވWS^Iz} #B³$ϛY/PQ'ue1LH ? sBHu8 4E1guwjbۨk2(,H& dP{97EEOGUGy˽&otJ9QL"|"feB!(R:uްۋe7cVwO2 s fm&Ja>F5+CQEQ "AH- "9lD|gf/vV5Iô?"a_p܀D ud}4g,pBI&a6BKRӏT Fy#* ȹsCW̃͹TB7qYVFՉZXEQeay}? ǫT0RW805^ze md;HXYT_g2%t@7Rٍtƒ!-KqVtCJ ZHPEQ6/`0&S j$dFZZ0-4>`qa,dEM|٢yE]$(le}9LՍZyQD0R/Nme 9Bmzm^;=iVEQ!!((@B3:>_} "u]MMӈB{PR٦ U0N);mLY"Qu3F1nӏ%|6z|Y%ͥuj)-zʇLCohlEQEYŌɆQ\uLhD`쏩ʚO7r9R@&FFdVLHfi6mjy370 cm"Fobw}3|w(ip]2fZtSɘqQr\uL"P&BAWc_8g170b>yCQEQӏI_6 !!kp՗ Kz>DD!7`,Ea:n\5^#㋈,gEG5 "&̌7!%Ļ!$"d4kVD KE6QX)$Պas;x`;1R\Ak1-†8fQEQe"B$&3|7(P:+%5U[4:vF+(Է(l}nb "`‚G匧FIiviBJ&@͝{rW.feXLw&h1E  3J &+`5L(({+hPL47p܀/79 `[C,+Ea6CE] @oGgf]ħ"0sC" bCq)fj( dS; 3"h BTPDĦq ZF, H B!F|sq/\bXRIsDYJOFw6` =Zg/#,lEy=V)("(fۘĚb?cKBAfePڥQLزB* VԭF^z+H @6*~sF"8 -6if6H~#u 7@:u(f!<9GuMfSՕRȣ( -7p`|(T/+(Bhfɂ[j"#z]kҢO \&AJShOțA"Q0K&zfB2lKԆBE@ `6Iㇳa̹Bݺ,j8pl즲C*Di#|iLL0ts>s`EQEER 4 IDATZE5XdlLz uIz#! 0fEZKt%*AV+A뚦nj:6f ' ٸT|K-z%+OLSmr:hn~DfEHM(点sP$TL {՛FzW.Ю-'daXتe`@eft:o<\XFp@՛u;ryxzӘ m.M*;}ќ}Axah2$3m.6ƏGFN_ɵ&p(bSfzm.ӽ&Vdt.F[/K^Nc fFh*:,۩/Ooly 0~s5[خ̥Dj2PI4Du9R ߥa)Dk%5VDycfk90:EQeq`fAs7CU; WGl6%Rj!J`Y$"ѧdT,K hX b"v#>9U]z03%8EBGTVkfG0fp =HpMsA.;I`ntZKf6(({骮 4({P1G%ܢ0٫YRߓYfd\g J(CdaTiE\,/J .G-Iyq8S$[8yI'}fb.PoS(NJ;`8 U4_(m%sfم+#6{϶D%`J" Eg/;$Ķy7"2 @Sz1yĉtS옓^/fPaRr4<_/"[!K R7- Gǘ[V*EQeafw dx\R¨W"jM6}.uzTVZF{B(f"v&;?S\, ?(Ml{tLKfө๗Er9t;xxW)V$F@2ۿ:2!oeEQe333c!g,sFfSsrj 2鷂 #/LgA$V*/.)(-{۞ /&̗|Q]ZZU9+35"buSe޽ s{5~aRұA·[:DQOhmL 2+(^cDM mn9K貮)!+X%_rQsV]xc4#<"÷XgW}&d\D:Db 6"f>3 z;r# 8${S PQm"ȼ$q! @3REQe1 0!$@dPQ1 ^ú+] A BѾVyD\N%l6dt@)cK"Й5@ڽ3dTCmP[PX34(Œ$L ԖWe?L%C[X[/+wCgDm0WEQ4c.#?^݌?8U-ͥ֫ݮP ~gre|HY, lWS(g6 TqjhPg?!zjYVJF΂sP\plW ^rq_8a?ToKEQEQvUB$pv#eu IÎ|9^f$&wdxgD/ ,v7Nt휦tUVꡱNmƘܶlzze((c2er=KU) u?D[{L6m\ǒJ3|ϊԌ~9"˨뻇"3ܓњsߞ0#X)TOצC4O9^1gg$I?}REQe25@DdFf5b^"tZCfrjr #KׅQ{`+dг{۾`)2$NjY(Jg8܋ s6c$o"ʮ51#ʬJB1XfYk#1^KGic"@Ԭ EQEki!$lh߂eԎ$Y+~]D[*Fnîɒdי`hZ6Z`wa #nNo~ީ7++2H`v:Ues< 'RK#C?#=WPh;>gTL}e~X_ W5Iug @8@i#3!Zs躛V?Ϡ}n]!`>v:}=53TN3Ce!J# 1*ك/h]y-V,B~C(5ɞ柝[d~qxt*4oqY[ŎGE~~be .ޯNbdl~:lvGhmzLX3n־DW#*@D<¶Zgπ+c,~ C+=2:_4F9SUe|=CE!4DC--2H>hhH:siqk'PUBE[Y0s׌H|[!>x$8kJ-lz[P_$ZbVРs]Y'xܡy}qqļy+Clh\F}p.#H,~35J{ y3ThyGo/z9^by56eOm/k{c9ĞCG7(NJ/xR|=?u 2S&VBΥ-H;vug7k#HC6%yo>z\t~16=vC=a`T7<|sX"Zb.G[bzSAezkϞ\f?m>ovT5ňUO~izeӠRDa1W={WNhyGd0%D4NwEi4C̖50#s}7XBBe6ֵ#Q+\)U3 i$lgtm~"&݁zEDt!r|}HcZw~tk{x,-E {ņN"_(I !*>oa.b.2t~v=^FHfŻc 8Q'Dv_ۮЌ!š&\bY= tֲN>tqkt'[009@PWb@$B"ok6u㏡ES,;zƣ {8]Ww_"wWy:Mǯ0~=_OlbnH'h}/h{|=ȱIrNs-O9D S|kF~Ke2m}CմUSVLݣiwhMYẁ$=nU3\hN# [ts}>nbLˉY<\94#"bd@^E29Hu8wئ qErfu_%pNqonہxt?*Tp{unC\WW@_rQU{|=effT3RwÒ}ww ޗoߙsJሺAm8=nh^I7xKB>PxBhnD& ?c5]B`#,%F $u5$G[듔5I'(GFnS/7]ho.*uV@>\WJ۬孴]cZʿH;\瞆Dc׌{|=/q@d$a3}& rpaKg#fh́o5ff^e{nq@]#\16.tI(ZփY 6X" B3ژdIw NCK͛vJ\ <E7-úLJ;U},0Ҹ"iNARk!J?xa&OYdE:HP|?.UB{kG{|=Y\j(!s.x&BB},NrW c{ȠUSm`S+h8%9Fso&{rU&dz_ه%Oߙ`2y&WPOM\JnkTɻ"RKu0kYF){[#i _ Uߵg;pZVvI{|=#&:9lB=H ı#s~ƭ<7"bTl{FSf[HfdqIȎr(>==}VS$n%URb%PVQUĠ_%V) O]xdcyHi<ϕREmbw;P>I1Z{|DvP`'wTu-~.d ߸%O48<tUxOf}:Ql:;Hj6?GFOlJFn ME G8(\y_̑;w9)˰wm2<$0嘿{|E(2%"d|q~4#}iPheGCJ̲mfyh1AW.ȼ↢<"jV/gF_hv:iԦʰ'c=s;yHQ B)xʟ[Ĭ%zX?p{Ʊ$ L唻hƕxeW/u`C#5mds|S#[X{|=wCc ~h'.0np-!v أ¸LiXNߞ:M8Ep"CU4gy:m0|GrP 22C?#1EVpmbo+ۜ]C!B/()PaΉnF4u$:Ϳ\TaE~r @x~j={'1;و~.BɇZ+,RffTHjR_:x "g]rhROLF3c@G^,`ؚlk ^t=Ea;d*sjagΘ sG*TlaΥr&&P~@CVIRe%6KHbW1## H fQu9)?<Fb`fu8MzML{9'_6mz}蓫uH3Q\V&O5 >HKt~SV羒}ʯ XZ OBd}F f4JC6w5Q%03enJH_mR(&AqXzR[t2FD"W|#5/!zJtVN kwC?9k:ez#.G.=ٲblW2&yTWS]Y &DA)Nizֺ݄Z'i񸥎u2OmnV>2~{| K5-җ :kڟj  쯜)cǛޞqA8BڱSr':b&0~FDuЄx^r[(ӌBLlœC}fa5@l6AW82c pDWA^׸݉^ / z귵3`?1:"'7JweH9X<~I'Ehֲ--:C#4֯#:ίHvqruxztrE|Zvr˽lE?zǯݾX> %m %P@h92 xc/p}.wTbS<2 q)fZճzݩGvچ Be%Pqsm&GC7Xu眝43v -[T{ͯq; IDAT}6" }HёybY؎31#$Ẁs2V)&rU"e2 ~hRꮵI + 5fm>vBb_{PhThωv^]A)z豯! eE4OF{$Gl:| 4 }t2r#xChnB0h0'g7> >i0lD 'a_~߆L.CRA5pf8RziXW b2p>颪]DzCL Ml8"Á>VJUMDCwq=7>yM p.,#uu.QK-Mw|zT}l %;~lh=4sߞHwP+OOJX4B]]A.<?]h5dFW(~B}xۙ6_W6pzY ͫ6Duz4KT(4B/n )){Dcܽ?9&dBkUó*Rv-L{ 18$ vCI1bFM/]tvXzW*Mj*>v]|K)L}`t {MR&6&ϔt{N٩a`ww|3[ zT*$;ԐHJcB+rgFyst u6gQ-bϘ< Z\j~'r5p\~2}lpIB/p`Dw N2?¹UV`{A6dgq2?ZnQ]%bf1[ڬ}42TٵC4 >vGum5JKT2W8q44F6 ty{"\zT?J^5ՓoIRGצAF n\UZ-Tw)T^m6kѪ~)? Yu)+vFBF%܀߈~VcW-t/l#>CdY/4x+u>P"]5 ckֹ%c:G:C>?U⢕6[j4Ec84jܘֳG.g$f.ڮw6q7 n9wo}> j/=n}A!{ T  |4xgq`G:Ɩr6*{S:82Ǔ[7ᶱ%P\l1!7MmپH3#=O\5E]cDU*Fh76tgf|bDnbyJN:XeUhgo5$ p/=a2%ltՂ}KF85\)""aOiMnlp` 1.ʞt rz5'QuAn;qQ12M/x N.޴]PMM%qΩ>6@됼{U??,q/V뮾hv}Ku*3eFLMs9Pz6DU(XE7i).ch9ƥMr&[XU+I:z7?U)<ncL7j _惠]ַ<T_d3[;8wpW3x|)CD:!n$Zݘ"J.ץUxhԴKֻXQֈŦ!"}Us|v}Fzkc{]MvH ^@trV:q^e | z͐GNb8(tg-[ePnr|n$5^6:F]L4fQ|0HN "Һ$Hr.UJ4p@MIq zqH-BAcN . Mm=օ s>`ֺ*ip86sAdtr٧(E8nMhͭv4^?:[se[ٚK[}'b׾Z¸a܍$dQqrtmqGg7ي}Q H]vT9xI }V&Ռ _戹HjRoH+b[?ˆmFa`ݾRK9x C ܏>ɡi~ĝ?uΩJ7w5Hw=d5{˭T{"{ B.Sre_ |>um▲0-9в cN TRy@}'=zU~{)J4b lVD^P|5RpSzϡڶys Gc q1p}kj;Ԍ!mthYmS 7y/\͢jT0Jm,~JH 毠*<ʡCcoUQ6J)FͱΞ8<.}i\9aeO_Qn:|vH3ˑKk5RI%DՄP^SJ@\$ΞVhn}{ ׃9Nj8*ެsBQeԨ ?#ŶTB!2yv5碋9y4{xUqe^. ㊊LWcB͡z$"u' Cz[Zx5oO*h[[3ɅYh^U"`oOTOlG֪s-VlLIrzٔ=\y>L jo 5ctJ"kXd(6ưUfC=3x# Mc&*&;Qj7'#\dH{ tߥ(>ݿ p.$H\_gc{5J0œ']*o5Ci7P meՔ?n=C5$:DL郍q 5EgIaZ|PS NЌ4Ik?S`>}\0?)Eάw.;4JeJFFP΁Ch T[1v!ԎV<^o5tNHPY.Ul}b]n5a (G\c&L3-<> T7@Yiiݓn=U+IzL$/cf9s5YVI\u{q]???ug'q y^SL^4DQG1\4kH۹O:VEG5l!eZ1އq.%ؼ0|)bVw/l ]71jf&/wNq}0`:]*@aXO(C {8>V|fUVΗѿ )3RF[}+ ?l[h[q ͌"iTt0VQ2CI3.ԅB2d -NԋD܈Ř#洟¦Wj.jMf+C':uu2wQ?EGa1#gLڻ~A9UufȀ\?!?jb1*(qmL5uA~D@1W46 G?w9wʝ\BK/9T?! ,Vx`K o|*C>Or.!W UP+~&i64hY'8lL~opy#Ƅܞ Y0A4~$à4]_pNK? 8a~CΟffza6Z ddo.iށ73iHxR.ɇ "-E#LJN1 6P.}r֥ĸM{H6ǫW;{]'?K 7OCsFq7zAU][Kc;֕yDN8:\s& l0Е*Κbc㊺6W2+4?"bd(ZG%.`9 nK)E;0V8i6)X]4Wuy)&E!9?~ #umُ;æ#0+m :'HxJ<h-ۓU_脚BM c:Rœt4a)$?s}9A'Mq**kbmCUVئ ngw2[翨 61Ě3-AB&I=Q:577@N-5#A1`NLZrz0.XFZ`hW;YP UbR\>&+5wM?a'K])s{JƨAd_!7~9 ]Kq|~'< 1vA $ͼvS5fրܬΜ'a-rFz}<@Hz.8`wYʞp΍Bd~T ^رy%mElnhfbN#̺},C Se?l9tF24%IewXgeWvB%gH\n-V?݌߯} S}.tt|GM8~oxoݙ챧D]LxxLE8šrYt7\*!=A+AvLLw\W(I1j OWsShz?h8-ؼ-f%#CC#N=:fc;h=҈<.K;$fd)[NQkO-<rawق?qvyc\_L!Da{ZV@wkq0*B!@`jv9UզHj c\_&pCphӽ֏H=iw*`IffX]a#q-4wRuy7^ Gg,F\#a\鷟Qy>ܮ{v EWRIDAT.jiN@E4'qiy(Su{iԉ4sFo:1}fsx}V>mkR=FW8,A[V*4RTǤymPCszxL5nU1]&0[w8+ ǸDKG8Z$)"q86BL}$d@vem/Szaa[g>/QfWLzZ̊!ݲYϱ>IтpQ|zJoݹ3}e[~?s,֜:(9ٸm\'݅Oį*R/ cȜk ZpTRF+ECnɸZޯE< k7?0\O@%f7+b.wI";Կ1OxMmpM*zwvb;"Rɧ*nv$H+r/vnW }Q>exgVCŨm#0ЈJCBӺZo`GEiH`oޣm_ML%Kf 2XI뷈DVpP⟡?\ץ?#hgGS T`y)4r%g}`vg-۞if&M~bA>Dz*E/UY9Xŵ:\>*r9L|LFa \/2{leFG!Ƈg=9"g2l9 pBJ xeE˜ bR_i!,Ì(@e'|ecԥa!@D, :)Ցf/G^S @lݱ2v[MAלq亥71JQs)M*̈́js2[[6Wױכl:~yC}I3]o[npn 2n]H=HFtEu><"a3+R9~"<]ں4;OH}"I'7IR0~7D> rNR<#zR[6#UQ?~ϸgC \Q& 8V;[C8g;9'4Gy|1j \ Uُ\3*fcXR>5h_@ӝ+dPg&vjU 5(Qo~z@2"(wiyYmJ&N0bOȝ]_Q]]N"~'H[kIH}vPI62@π/_ O ea[u#2 plYDbJYv]J_ Z=f DwJnA8ЧCy |[V]׿z:]woV^/y*qxZ5a=0ַ٦sĭ#; ?E&xAsba,?c6SLaZ-7a.1>T/NK}T>o2SSwf&aczt)*ޢtNz~t~,Hni+ζ6C-\U<'[5KOɚ̟Fo+[̥³U5]F%Zaޚ&Y/R%ٿH唫wNwt%cr`&m2p؉_Z񖖺kTj&DZU3gSތpNb1d6i([HzLO PS*{ rEP]/(vφ٫f VVH%SaET&] ,z4f \R4^3]Н7.mM4W JN62pV.ܩ*@v]i`\/%|aFO/!#'L2EW(…Kv/u|mΨ)gnBJnYWRVMu۴`sY`x1r|cpY{f,%%zKu> G̰|ZB=I[2E=" !8F-?EކUoy@9;5 υB'ITmM6e`򦧶EK}?^Y|.\+12ץuuk#c\?zƆvqxtZ5|̤wks}܈9zf[PfYQ\-}fD"oaiи"%xzс."2k~zPuee;PLJo z*W'V]v"tqR,| ^z0Ua RmQqSj.8aΒlJ\=1QZbi,FL%U=5?DA[n)]ftY-O+Isӻ-dqGn(ߨbemU qG<>d?I>HSF@l?xj+GgI_X0EzBW9N9EsT1^ VOxGV@Uq7 ~7Taň{+]7;rtvOd>YxC=0q<&K]<LJ=R*Ĩ߲?C~&~!$oq^Lѣ2Q/i\묍 dO˻x!LB`d *j@q[CM! W,1-&8Oɠ3A537~@zkFr22EI2JR;̂Ջ̗dG.os9y] l뾕 JިcںϡB/3!BLvܳ^]I;tE7Y5LZ 9ȿu%hZRpM.D9\c -lu﹔0Vi@}5 jT.iWѢ+.Z;T)EqR( ;q?fwל/C WcdR. JU;]"|JV1󮟣S<'IyM@2 ! D?!yٓ<+dn7TIg)T2v2=pLЗX+6ػzz1N)to9-O6]}A)@Ώ' "{=> 3C|qġI)'%BTHQeςNa3Tyv;wG`@BkJ[n4mp,>)]%\v3M,fEzS vHC~_ۇקGq3z(:M$>1cŁK+' Fٲ,u=;,HT_R2xtTr0U[]$QvOq9n*CĜ=ָO.]: ߞGHɭjtg8I kQj1I(9U1?^l:%|אHc(+ "߭sR68,Sc .®F#$I* O.KmV SWNj<${2gv hl^& *7VʳqV ޳6Xm zk!> Gؽ<™xk|"z X*GmVn/ojw͍h!ϤwpCpĬ-DAe.R\x%r0RDoBvãDT(q[9 KG#&tTG$U!MZ|ñmmݼ]ʂ 0vdBfaA-6=ingeoJY2mִS_&⻯Tc/Iur'3.^"+uE*iymB2M'^Cp8Dk}+7b6e=L +9\$=tm)@p ҟ!^ oԀBx^N1XoMB ȁHgtr懷dxqRٔw-ހ$&i_*#@P̗1_&C礩(e DP#0E13C5a`P-%#jNC'J0`baM ,LǀӧT<·5'tAsW/#O +%D1B`B\^}ΆX7%'RYjCWߦ69PKGtwC 1a% h1\Sm/'lƲi32i"/#g"+rCG{-ah,nrX )nìUJ$3.qadSvE(wڍI^OpP5߽t*ü`hMٵd}l)~e85zxNz,9 'tmg+ T.wښ SnPue4^)G(hAefpgr4[|v).bDD/3%*1 cMLN(=aD }YJgqp ehX\E\̂\C"#%ފ_z*e'?b/-7RM;&TT'\ лk^0#c6OLp͆ʹ'` 6!٠j{t}&|zHj,sάf5=#y9dC[ bm+Jd6գϓu7IyfK&oD25;6L8KeD7epO OEd *v]oδ6֬$M1T:OLr"hx0r虎+n)Y{[6gёqW$q;%HKvO[(#+&nU&J眝>Z$1u.=%[a8^g=jX<ҧry>+B? aC-# Շַm|1zyR[y"Q۝r>EPk6\=M.7m^-v"683j ߘÇ荦=૸YEX&)NX5-"тRjі Kg r˅?JU*tkJiƛKkYG^69N&3;V|=C^Y6cG#0뺆XOy<̹2/tELeлxNwci͞pPݗs-5 M~gM1CA GS#L啑]ZtvϮs+Lfw,9P7pklZV\V g-5ȋ(S3k+*#^Uc4syUsE'ET,L[^uKEP~nB;oU!Q4[3]cO+zӵO ethL)4"?*8&Rh%I5l7 Ysanak EA3_2ܝB1yyB6D\ΥĉrO|b;@jhc~]UC*SsonA1+65xQd2;h̜y%iY+@LA#Puduibh]n)G#Bh7uGMs/JXp;׃ȪQ:3܈ha[@z$Hོ+ whf[pWٝ:X#vtdnjkqȡ tJgҭ c|zUr7MDj3>׽Q=L`9d‚Ԛ{ 5 LE""EL{cw 5=8)OshEUedWo Z0L͸S|=}皂eɇѴu+;\{D0O*ry&8૮#B%囗hϣelUh' 'W#8qN`VIENDB`Mopidy-2.0.0/docs/clients/mpd-client-mpdroid.jpg0000644000175000017500000010410012441116635021740 0ustar jodaljodal00000000000000JFIF`ExifII*1&i.Picasa0220                [  !1"AQR#2a 3BcqTU$&SbfrC%5DdtB!1AQa"q2RS#Bb3r$4 ?U@(P @(P @(P @(2W >V\UnxD1F5"J(y{<*'Ѹ"Inܓ^THg( 9jƬۛ$8nR4u],I$9>gYbln"hgR]KFVP >\ޫI%#, s FVp3QT%Qf8poy)zgV"z/ǦH#"6du>*JQR?2Ҕ^SYO˙2f;k4q"[Gw3M4Im [jΉ'1,@YR%o<|5P.'-1rꗳ5/5 wՑHTY"9x䍕Ք f9Ss_fѭ 5MtiEY/ @(P @(P @(P @(  &$Eou:[dpF[  l*h.m3ZqKdg74U\H>{u$ӦI23m)!KA sDΡILi0A NF-F8kz04 @`EP\% y2b:$*D\" -aml*3~Pz?@=~3?{g*CTއ?c~Pz?@=~3?{g*CTއ?c~Pz?@=~3?{g*CTއ?c~Pz?@{II %C)OqW7(3?g 8SUL {gzCz\O3?PPNRK vN-Z[k5k;>E20;F#iҝGEV%Q^dXK7́S)6kGZi4ŀׇeqr7R?ǏxL{;E4Cʖ9h!#&dt)b82,zP @(P @(P @(P @(Uf' 1>'oA%#r_2wdq1bEYe4F mE;T`\ZJ5sL(Y1l +]cNeVy -XI$JżP!G$s!ܪƵ5Vr}6NtխJ]K=RuV+{ 2al40{GwK$Si5l I@֫ekSx/W6rχ|5䵞;)Ut %XIgVkPndV)!a Vr-/5vEq$j3]/nL(BD6r :;xQΑ>$ur`YE)m Nf{=H2 P @(P @(P @(N^OcZ x&S#UHTRKT5"6W ~:Lٯ܉%].h$[kWPK[)mcz.zfx9GhpY|"N. FK abFλ0H:>"Ӕj{_Teâӯ/`qZnM|3+H7c^}VϧZ5vi$z^e\Y>(l%v6H.v;[OkB}z8Liu=$>\@isyw6RIv} eq gW Y1K䧻Iu!,!;&emP.{aYܱ.<6kdH#DzK N] nSOԭ9Cw ܺucueKU460%J˨./+N^&J$=dM]L]?ZBX: -# $\À/c܇¯iˠ={۫i-6կDrGyIcI'~zRMmu܋'I~\70~w$b027 v>xq#fWY.'"wBA9DemA;b^ZcT ' ,j^iOĉ'-,HTX+02P @(P @(*,c*Ybݍܩdv3wc'2q-֧J$|_Y)Ryس3;13fcf%OҀP @(Pt"'*2<M`9(N:پA606FR7FY=AGA5j%ڀP @(P @(P @((dC€Ad}F}eҵS*3-0[8;+nc_O YᵡO9'yJKKo*A|M ɵ`ϾR)J"dşA{慍WJjr^$q~dYngYBaI$]Dv`mfd`] Mwl^ 4<;,7o׺C 'H9`p=AeTK/e{RwJP$NzS"Ovt TiO/|q4߀>~M[tdq.L>_ӿkrTyV0s/INϫF*lDqXK70 %bn\s[^mSkvj7'#|Wv\*)\jQg]g{-k$2IIdnxzm}t+&E)RxnﴖU.)֌ZΧMg ۂϞ"%6 oښ&pp Aq|jk:Ԧ(J:<>k1AKq, +ӞiQP>*ޤ^֜aM_PZq}7~šZq}7~š8׌?K<_`qxͨEV+fZo?GmB|l2ֲcgR| ZԡKܲdGT9N$Ps o=?%g3CW.?mƧy דўA㧏Z?BK/P}\ůKV?{ Y`%< ^EAxV-Ky2S-)jBP @(fCl+&/ӢM8bk)V)g#'tcbs-\$fQEia {g/V";X9g`n%J+)_ݵJT[_We£b"h8Oש%V^y]M덵Ul_[O(p vnD`. $]T]UqMrk/Gǽ»)e. iɺtRxfwl vg{$6ʍ2*>bVT_뱩txs>9Vkj:pbl*|K9f}G=ͪ׶mTHbq2eI\nf *׫ڧV5"u+m0lvxVQijf+}?3hc w!uJ+R9rr9{7|2ݺOJPҥ]0QhKMjh5ky E'v,TEq[Ma4kj\ŝ7^xkjVӢ{yWz(xM͇*%hlڭpؠIZB:c QǿJzk⌔UD4;y.Y|LH&@>9mx]OLY&.U(_A|u4ݡrN 8Xǎ魽]h-w!iT>v x.Ii0#JZqs%5j[ٴU5yxmC.x|AVW26l~6< >h8FOzRoϔo-ГmEm.i6H8?dOq n UG`AՋN7Eg]ֳӌzy6(+m9aqmpYUrw`gcm+kqN= 7 z@zz xtE'C,2AF }.)V/*G=Fe#e,eBlP\I>oŔtQU g@(P 6N4#ki{#qo4{ݙ#1'IG (:ms{^s1ir¨3Ӯq kyb+~Iu>c8N7r̗QGmcgT>c I q ?-l}$|rZ䱄6{-V/UTZr Mp 걆*]=OS֤.TėR7Uh4Rӫ 5-WfKshù7 w\ydh,z(Nu-c-nsnv7kFR㧖Ǘf'wLN-Sj,{RLqfvlu qXNԓ_JIm-H4<6ۄĴ;>om#FO=WڣYkoI<vfp_&%q)W5UE5K?q.ᝤ4-䰚׾Kr7S3JԢ8Q2:˜ `;$Iтa\O\Z'ZKTskqzFNrNq.L3:};TF,05ǨP>1T+syط}smaVߒu5O)&Ro}ܟ$ 3ƥub8gm[1YZ??gD;UE(>gfRM:Y6Љ4+\"c~M*2#.^c/Bέ|~4ey7Ir۝O\gJxսUtq!g[Ve)ȥ[IbRi%~xtU,Ɠ3۸#QJ-ph.Id*Vvkz:z*_ɕIGjBP @(|"I <,wI{ۂDpDnI$۴WiR~ӳ q,FR:Ҍwj v#zfjFRxȿ-aRk2~-o5k7C.-n }4PΝB K["ayum¨*\c<#%m<2@?PVg tR忤էSױ5jYݵ$}j[rE}HF$'bJ,ψ΍̵<>]w`w\RqR2yi}7ú[1HdG\nGF 2ʐPGOOZԸg7:=W!5R/~k4 JR&Dkq"G.+ت0g ^];;w'AiҞ^[[=;TwF9i8%k=<GlYrŲP%Q$?\@~7R*?kjr.?ht,kSEIa!Mu7"E@? Sn7HޜWSptOsNjS_{>SD,Q  e)#slAE)V7QkGbnUjXJFgyi9%I2HU vOOSNw-qNv^ Tިx?;&鮴˹m浹 2v<y0ȍ! Kd)KmhS)Imk^^^f6{ ¦{L_d+ޟ6bpQEc%ц -p_ߴQ9E乬K+ڒؒ{,šr"+NQ)!F_!)q~h5\3y׽XYGXSs4ib2wUxyLh(yh75T35q<cƓO>-r.x5dNm+K kP @(T?"&Жt{%!#&U=W ;\tovE{Eƭdî''=tAFNN2}>玆mxOr,[h*#HQ")n޵:B+8O8'QRn> )%mpT]5#|Vnm;<+##[0 N\m˖$Y[(r5"w,4Xv>K|<|1̕Zp ecidیoGOuHo aIj_K ';kr0ʙQFs慊,tP|Ydgp$S 7A;cosxr݌vߒuM*W iv8I}ʵ'ż4S{+mF8 YchG,|b1@' 2;=N4ۡR,yrp.ʵeN /NWO]m 粉,;4G'5 9e4G^eSIMGy硫Q䏖=qTwx Cex"ީ, RW`TNXϷV ԩ)wkΙ(?},{kE I#0p4 ] upլ;0esQ$r"K%q"i6fR9i W*znf#;sUx3ڹfM9c ./#!lʫdO9| Ӝew:yQsCWxDl܍|oGkTKmϪ+BhV n;@0:8m-t3Mx6KFb"Uw]|X̬¸iT7'tiɴ1;o/KM֒\xocviv*݌gj=G%nϮ/S֡iM뾲 *Mj5bYk^O&legFv_QԖVlq׸ 9эxֶ^X:Nso2-^LiYyݯO^T)na^Lsm`8ǧ,'Ok5܆u)G~Crlyd͸9:Bv 5׳Ϙ/Ki7^ UJ-ɤOQyLo]Eu@- lYTrw x`,Ҟj\ W-Jsu1yiqӴIR"km]i`^sihQѧQUG(G| #=\2&0脳 rD8mX*Q۾J2i o-KӘ4Ko֎׻o%xe|WT)&)KĎb9B3sҨʏ.F֖/(',%n_&̑>^^=A/{g`bIfcm|F8ZÍHSol>B}R&JsԒ𞕎MŴ\3KeH9ؖ?*<ՕI|X_>9癍NQY խȤ3s&Re%a{^(ȧ'c~3lv\X+e6ePd X-[ڪw7nc,]zRZ;%\x3@NW+V!طg;PYiZڿqB$*Lb0r7àHmNI&O Vvyݿf/%ktJ9dcV?ܶ eD_zƮw| 9.)Ό,N-hYRJQkuhҭK,g:$|N ASZڞet?#Zջ1繲7{]B,Ӣ [i*F | j.LҒk\?;tKW,9J?{}Zoյ xM$1"Ft/f#cnR5)rlo%$jy,$湯y2I-3Ƞ6ٔܶ25Z!c+4Dψ\ zUۯV6m'{nc)-L\R]ө'7-SotF u ٻK7kkI^]V̵}V[=-$dEw^OY-#5fUVzs xV<-C˨:dCB+2JBΐ.HĒ N+g'y=1.lq3O$'"Kz\Gq gw/+獡X4ɪMiJ*+8Mϛ_&\`|*{~&z ]vVqvsFآ$eVI[jHnoӚ_\8ӏҜW ~5\bZδwq%=ʵ<Y ;y* +Kq|k!*m!r0Yb2b9P>DSgM.I6Y/dߌR5W(P @(Pv&&m|oPDi '>f< bE)Hp+es(o V[r2tY`y& smGqɦ\ g8R%&$^.-4G#GpGFn' `8*UrK8b&MF<0V:XGf< Пxkӎ-jVbȁo)Ak +z5,p=,q V-ZR4->;vy1WsEf7MIPnG}$j\.!.ketY|_AkDWږq$}=X%I"T;`֎\Fv?J|}7 q|ְ^Z%&L܎22+2*@ zUUTMuy;С}^g%.m_ྒྷxϑ nR'B:褾5i$:vlȦuJRJMdq.#yNi>Z„6I]w]~DNj5VW6҅?$5Cae3̫KraUDH;p<.I eʘڼX D"FdS.XֽwQ@T- |ϧ*jE_#)y(X$3Ṙލw7݄+IgZ~ rLA!(RxP(P @H;fmXa,Ay9 hٕ@*4 FYѕvcd[NuW?.3P,~U׉+xY)1\Wp*!/n.Z|s0k'̵=RdžRXœ7mkx- Zwշ`ơdaӨJ}g!vc zXRKI>3;*#:$1bUT.ˆWk?W?yqR/Ut{9:=ul̜E]#|n9 p۫f9h;d mݗ*Ems-8ϊQ¼[omݤh&\ee 2:0(2 QLƌ8f,(,suTWXzwUJ,:=d^^/NN2TLs>k8#ijDTqIA5[b33֕Ӫ?SDϩXYS6127y7e _:$yF6rH ܪZ]iExy"T:,%ձ[yHec"KJ^hU;_:Qcփqq[ώ;OES<-ۯ/Ĉ۟}7B,I3w !m ĂjG8M;)Nqӕj+lsg'{_o{OH4->۳ce#]]Ճ+0`NpZqIk!,#k䏞t$~5#ڥO(2I[L9 dTTD} -U^=Eg}a&K[hW"̎IS 9#qZig JҜ#mE}G)r(] űଊ竬p='vҕ49ge}Vp,kekxbyeXHEV ;D9Gh;Q.Tm㙮m3gNDHk-IJ >/h8xvNx5h/]U5/U.Qfޭ+-8O5{̥y4I$e] ]3|u^]Sdb2=ϑ|?-g?oM&~A6 I91BC3a@wBӠ/z[}6ݯ(/T X Qg{׉=7Ex'R4N2oO=R鼃ޥQu Fj;tR$b=3=Ռ@(P BϹs%N|c9u,L~yK}*2xSۏ/fX=W^׻1i{Y_j7"L\ ԁm\muNES=/?4cZ'Y7lC-#{GUY1$ă43 1,>m<'!:7һpxOMrKh屺CJqr;Kf=@xCFM]U5.yFLh4zBab;=L6*\Ä o#T\( wk>Og~*Ծff}(Ē>TU_խwBqh,oV\^v[xobQW;ǖ"V#>r0u?5.&r.V ҩm7(N>Q[O /X ,o-9TK=z~hB /aK>IO Oiۘ n#!P3deC:~ xTnt Ls9䩪3kU6NX rV2Q r/"!ɚ̉ɐ) ^\+kU9J:RXifr<}PmU\G(PFhq yrz|??31"+qYYO)t8# ~PkpVW/MM PX(dWG$Ec35v@DT45U-+~W|%>L~C;g/#v q]k &ehfCp Ȭ u;+&%Kr&ݛ[*rPysX{3.[ݼHWR!u`G >FH0N:*VQyOݏq)UZr^y. $d8&+˜džUjxx$FS[nR*IFUωdOˏQIv6gx]=vib+Y-mA yc`w枀CVvSۜw"rA{aiŒ,=[ܓv2\k%*>I1\MOgS9ZPI[x%s)K&@8u=#5pNig,A w>=|?7n6;9z5N'Ji4꼍&Vj/6O ]Nj /;[K:5C86R([KW~gJ 9ӊP @(TlyA6Vmq=RcQS)l jGŒHۮm w\J\Vq43h-u{sFyP, RPOG3-S;Z<[|izVEߧSF}y[6^Z;4ټue=C#\T*YH'^VΤi5:|<>xW5]I Ή$b=~q`~\C]żFW7 45e!Nll?=mVm$]+N+N2o#^z4#ïK=ZZmxtKGq>|6ֱM7:32qˌ$g,\VUn%[>7;?c+[H'Mx7{0[(Ϛ>9 8sD5j0XG^zUMiOv6]KnYVI6(Jc 3r 3BҔb֯IsMNU)tyx5[k^ڨ#vdY^gd'P( Cп%nU!Xpl%7BJIO=OC@*P @(qN#rfqnh22@UOR~Ld:cFKN_V"ȱTe5C=J#St̼iڡ$n9~OV4y-VCÒ]M%-<Ʊȱ/6Fb;h Uiӄ*uI\ӡ^NNX&/e?U[^[Y6>T#8B̊qac>#8 +o,5'"IUE~}=O'/#G-$3Ҧ3ioͿ$U\Ƀev븻e&@8b)cS?jiiWNj*jz}T-UvxX;ģt`#BT.&nm[պ+Js-./B;~9FpFy HN0pq0#i{Vt¤6qZՊkK/ncԬJCKsYwqXe/f?kmxC(ҫ*msT=^8ohnJ겝)K\^ZrnPHrkyRAa}66w =0&L[Q`ޭ\DGo,;B>7̲e! .+ܤ6ѩ)I=qaT 3IBRەb]j]1 'UJCncazg Ѣa<{:'lm*sW+"z^|0c:n;R?!mJ5aKq, ̱$"!jKj۩Ӗ|0>8RJy5ci#+ʇ8%ëRe%^򓦭֧ݼnhew͑ATP @([F ]PT)SyɊk(ƌm)4^3:'Xs6F궈<F;9Җ{9A̚)V`wm #lx'Jڴ#t\ѯ6W2*'g/ʍ}#PVv!5xkzӔ֛[핻{ntʞQ9gn_tH] M%BQ:\U?-bXp{j`<-^&Ƶ9]њ3)ᵕY{"Ga, V@Xۙ@:ckn!'k)R,<)tBk~֓q/tWĖ=ݽГQy\Kvaۖ.Sz窏0V3+)(gPzRw9Bhԥ$9N^)%Z+d.]*4"Ye ;̛dc.3N!v<^/[1b9=v~-*V4%k$Y)}a9\3Zio\,HVDKRv+n7zpWՄ>}/Bt.h֣?{ZG!4V5*82XF6wvU*$džȔJsw4S@~ g~blpфT,9=<~8x7^-,KXxǎe%"1jW=sշ=yMHm9'5-pON!ɾ~ ɇ0sn$|*UCWҔW,,>>9,=Jrr 6~j>s$|մS))fjC*8j3(PɷgCl^ы*3᠕PFHUI:o+שZqץIz/o̟ m[-6#8:}=otwo5b)lryaen`/bqIť ')z(cLV1ә3J (IiO)x//8cVSAslyk#B͛_e`AICrv8*Вp)ǝ6{ⱶvQ5|篤K|RP @([*5C8Yޘ<''dj.su3V)N<&n0CP3в~qkQYn{JHӄ|>o r"T2M(ژ>qL|ONwqQpѤ&o mWSʪv=|q0RUjQK͍:St%oJ :8M zFI$V{f/,ћ3x\7_zgX; t&:|~䒌^_PX\0uv"#y`D&yxնUY18x; *-O|[O:潳ۻ9 YĶ7ߗL[:Yd IGHHwHq\#0B Jc$o??n|5}V(ʴ-mɭiĠ`$w4Q;I3sZFïG9$ug]mE.n)c?2M 7-g+SF.U\O&qsd,r1MwT|]Җ`3DoRPΘ D;'K-Ɲt}\̍*CQjhЧMRM0ĿiԫԔӒ[#y䈶Mnrp@8'>vW>s),󶭾d;SP sD<@8ުR TkZ 5&.Zf%{7y͕uGPF@_RgWa'*MJ.g-(3v.ޝuU}-$~iUȍ@(P -|@*@v3B9!>L4bO&Nӌ1WoBOwExni:uax{uhWj~';YQLS|Nϝ2W$Q``vV2ucׂ#1K 䓏XZ-7Icn{v&rLTWPӵN09?7uWwi^xӜ'𑡻g,W8^?,Zė20}Fd.YA)*ď7A׬)[G =]Y<5jTjrTzvg 9g2B1۹#bxp>5;ZJZna7>n]TMZZU#j.I$X,2I'g8=ުknۦ䔺$GRecdH;1 ~L9*tܗOO? 8j Ma7[;52i7Oϧ EdF"P:CD] ˷\N⥝. RTǦ[Xſb]Te?'>zq&,ks Pw̍&`I[.i^ͿEF撎5bS[jKld("BP @(q T`UQhTXxg_N"x\2S5e.`ᢔC:ψb+YRk'h捙ˊQÛ $dι鐙b9Ts%[Ea톹i)Jުkz-I'owZLN->)7Qbc 2pzwtRTE$nhԞM<#l||M{iYRK7d'!2GB#%Ԋ1\gwtM'%Rٿ~2H8(RTѱzmvm \p[U\R[Fj٬vkT;@2HC_ˊX:5&J;c=UV#8>$ZRP3 "-KjT5f>;q[;rDJ&\)@w@OWR`r<"7tN6ѫqw5̈́0:\FPl-D2ЇboL?v;敾qXk,`K-nVOg 60v&eSMo:cXR_3S;ە-r'.YXxXxi6u RTEBV*kRnjY!:ok-TXU|@zpo<͍nSmv%b.UFҬ %PG椗o(Ͷjb(nAGQQFЭgmٿP=Γm$K&PHcM,ЂwJ$W.k\Һn蟽[kx(]YX1o=hWmH HykN >Gf8ޛɊHK |yXlF+%5W{&w 00zx՞>^VWiWsW#ŨVqڽP @(1_@Z9P " 52@J; ?9GZRzO9L#jNSP`뺪SIG>?}G'Fy`UU@Ӈ>T MT @(P -|@(P  }dmmfy[[v[p. 9net!@>W{.ab?{{Ilbp*R]-̈IY@"qnfY̢9 36Ϯ<4z>%(׷J$ڣ.HTdvDV #G Ü;r冶 nI!]BHEъ mū%qjyh R4J[c70%qOZ١c` ` ` ` ` .9%4P @(q? @(P @q9@(PN[P@(P @E_@ZP @(P @(P @]8so@JhP @(?E}ktϾEu)P @(۩U?@q_>_>_>_>_>_>_>_>_>_>_>_>_>_>_>_>_>_>_>_>_>_>_>_>Q*=(P @(P @(~ JU-bϦ$v,Ou/7dUW$EJ݁N\[Ŭ[QYy,HgTHϘƳK-zry4h`lm5R{PZKeexYLsc[x[/PX[Um6K&#@:[Ųp:^´,nnaEqs$糸5#'sy0d-`{f Fs}k}5;-=MsXg2+M0~ӵ֑cS}Gd&yhn@aKl-im⸻}JIfekYa(ô#)jzƎJYWI+\ Է 5\ni4nл$k9 }W!3GFN\P @(P @(P @(P a==XF67yI&iz*('/. 򜸃HKf.K-a8-ѻ0,+qh$pZ򿸸2JKk6$[e={GCt6]wHwI̊k}1fx(("}YFDin' \VUm ImG*F襉[kzr<1O(MխTR001<<N=Y:y_4tӯu ?zk;WՖGl~G=W_RϿdcuM4>O~TwKWՖi}0sprX*SMSG?Ueh}jr?5?ܩ?ʯ-TCTaOtUYk:Z܏ w?g*;_4>k8Sm{d55.pA]Rr̊Y v|:/ZY٠uj+|6'>~b5ѠEyD#3]ɘS9Z04hb drS^CHg$xw86✹|#k6G_#kd~~ڀlѯP o5j?mFm@6G_#kd~~ڀlѯP o5j?mFm@6G_#kd~~ڀlѯPP @(xd:pGFkd%%8Fqq|'T#0c3(h>}[ӎ5Tx=)w"UF助A@(P @(P @(P @(P @(P @(P @(P @(P @( @(Qw7&ɟ)P@3@s@(P @($\t{6y]ϓv[3۷o\>m&?29c-#=b pܵ(3sTZLdv@O.;!GS;KGy|h I@(yyQAϩ]I {a 9(̱6m#o5T;Ah uLڒkͬJZM{;"\[<2D*),7i>8MP @( dmr\Dg2FF dxfBJL\u =kaw>l.s\'s#[~w>@@.hy W;#DPCyڧrrn|Gxfθh 4vx[orvy\wng'r;:073܋x[nqq{@ju_^ČLp`+4ANG G_ BQO֎{_&Rk/hlK1=ܥd?yGߟ=ܥd?yGߟ=ܥd?yGߟ=ܥd?yGߟ=ܥd?yGߟ=ܥd?yGߟ=ܥd?yGߟ=ܥd?yGߟ=ܥd?yGߟ=ܥd?yGߟ=ܥd?yGߟ=ܥd?yGߟ=ܥd?yGߟ=ܥd?yGߟ=ܥd?yGߟ=ܥd?yGߟ=ܥd?yGߟ=ܥx82ÀA8'7==PEI7I}?eotJrMuG@ʠ"324圕A cүFTߪn:R.HNصh$}R4F 2.r:U PN%R=Ӭp*u)ڥSm{2B{Mo]&IP @]u.) [ify  H[FҤg٢FUw6@..mt6}v5'eФާ €P @(n~_g@7Z7?N/} ־Ӌ_ftus:ٹq}k8΀nn~_g@7Z7?N/} ־Ӌ_ftus:ٹq}k8΀nn~_g@7Z7?N/} ־Ӌ_ftus:ٹq}k8΀nn~_g@7Z7?N/} ־Ӌ_ftus:ٹq}k8΀nn~_g@7Z7?N/} ־Ӌ e*"[6iHb>4HH,@!G @uM5He]DCa8e%]r2V`A SP @(=mBL2Gy lP @(%5=d[DHN8DD nn@=ݗ?0gUv_lퟘ}T}{/~aPwe>@=ݗ?0gUv_lퟘ}T}{/~aPwe>@=ݗ?0gUv_lퟘ}T}{/~aPwe>@=ݗ?0gUv_lퟘ}T}{/~aPwe>@PP @(**Yb9&Ԏ$i$v¢q7V]ee8ee8*A#P @(( )jcuD3l8bmxP>WT<9cl[fJD^N|(AP @($weڜȲç_$vwu( /nʀ{.፥N$I-.# $ 1@(P @(6{wmP/}%ϻe;/+o'PC pI -vod1j]YɳȒ;[_5Le6F31P @l&Yhv:LiVW+xw&m XXe`OL1wvɪjXN+ m"8Tr,fsVHwyH>h jZ厭5Ɨik}ab׉w`^%n|Q 4OP@(ZGj#``mg $?Ov‹: wi/Qd ^6zh V6qZiX;ٱb eʢ=' j-P @(5I`eGT;Hݣ֮2@VPt~ ˾dk IvF(ұ9̄MA@[x6K.Mʲ]\rHb8,{'q 4P @( {^ }&X_)afsLu $j4?O]4?O]uYi #h 0ePǐx=<P @(3qCiz_ߣ _nH "'.'.vڧ5F ⡣g0cb|{utP @(P @(P @(P @(P @(P Mopidy-2.0.0/docs/service.rst0000664000175000017500000000754512660436420016320 0ustar jodaljodal00000000000000.. _service: ******************** Running as a service ******************** If you want to run Mopidy as a service using either an init script or a systemd service, there's a few differences from running Mopidy as your own user you'll want to know about. The following applies to Debian, Ubuntu, Raspbian, and Arch. Hopefully, other distributions packaging Mopidy will make sure this works the same way on their distribution. Configuration ============= All configuration is in :file:`/etc/mopidy/mopidy.conf`, not in your user's home directory. mopidy user =========== The Mopidy service runs as the ``mopidy`` user, which is automatically created when you install the Mopidy package. The ``mopidy`` user will need read access to any local music you want Mopidy to play. Subcommands =========== To run Mopidy subcommands with the same user and config files as the service uses, you can use ``sudo mopidyctl ``. In other words, where you'll usually run:: mopidy config You should instead run the following to inspect the service's configuration:: sudo mopidyctl config The same applies to scanning your local music collection. Where you'll normally run:: mopidy local scan You should instead run:: sudo mopidyctl local scan Service management with systemd =============================== On modern systems using systemd you can enable the Mopidy service by running:: sudo systemctl enable mopidy This will make Mopidy start when the system boots. Mopidy is started, stopped, and restarted just like any other systemd service:: sudo systemctl start mopidy sudo systemctl stop mopidy sudo systemctl restart mopidy You can check if Mopidy is currently running as a service by running:: sudo systemctl status mopidy Service management on Debian ============================ On Debian systems (both those using systemd and not) you can enable the Mopidy service by running:: sudo dpkg-reconfigure mopidy Mopidy can be started, stopped, and restarted using the ``service`` command:: sudo service mopidy start sudo service mopidy stop sudo service mopidy restart You can check if Mopidy is currently running as a service by running:: sudo service mopidy status Service on OS X =============== If you're installing Mopidy on OS X, see :ref:`osx-service`. Configure PulseAudio ==================== When using PulseAudio, you will typically have a PulseAudio server run by your main user. Since Mopidy is running as its own user, it can't access this server directly. Running PulseAudio as a system-wide daemon is discouraged by upstream (see `here `_ for details). Rather you can configure PulseAudio and Mopidy so Mopidy sends the sound to the PulseAudio server already running as your main user. First, configure PulseAudio to accept sound over TCP from localhost by uncommenting or adding the TCP module to :file:`/etc/pulse/default.pa` or :file:`$XDG_CONFIG_HOME/pulse/default.pa` (typically :file:`~/.config/pulse/default.pa`):: ### Network access (may be configured with paprefs, so leave this commented ### here if you plan to use paprefs) #load-module module-esound-protocol-tcp load-module module-native-protocol-tcp auth-ip-acl=127.0.0.1 #load-module module-zeroconf-publish Next, configure Mopidy to use this PulseAudio server:: [audio] output = pulsesink server=127.0.0.1 After this, restart both PulseAudio and Mopidy:: pulseaudio --kill start-pulseaudio-x11 sudo systemctl restart mopidy If you are not running any X server, run ``pulseaudio --start`` instead of ``start-pulseaudio-x11``. If you don't want to hard code the output in your Mopidy config, you can instead of adding any config to Mopidy add this to :file:`~mopidy/.pulse/client.conf`:: default-server=127.0.0.1 Mopidy-2.0.0/docs/troubleshooting.rst0000664000175000017500000000753612505224626020110 0ustar jodaljodal00000000000000.. _troubleshooting: *************** Troubleshooting *************** If you run into problems with Mopidy, we usually hang around at ``#mopidy`` on `irc.freenode.net `_ and also have a `discussion forum `_. If you stumble into a bug or have a feature request, please create an issue in the `issue tracker `_. When you're debugging yourself or asking for help, there are some tools built into Mopidy that you should know about. Sharing config and log output ============================= If you're getting help at IRC, we recommend that you use a pastebin, like `pastebin.com `_ or `GitHub Gist `_, to share your configuration and log output. Pasting more than a couple of lines on IRC is generally frowned upon. On the mailing list or when reporting an issue, somewhat longer text dumps are accepted, but large logs should still be shared through a pastebin. Show effective configuration ============================ The command ``mopidy config`` will print your full effective configuration the way Mopidy sees it after all defaults and all config files have been merged into a single config document. Any secret values like passwords are masked out, so the output of the command should be safe to share with others for debugging. Show installed dependencies =========================== The command ``mopidy deps`` will list the paths to and versions of any dependency Mopidy or the extensions might need to work. This is very useful data for checking that you're using the right versions, and that you're using the right installation if you have multiple installations of a dependency on your system. Debug logging ============= If you run :option:`mopidy -v` or ``mopidy -vv`` or ``mopidy -vvv`` Mopidy will print more and more debug log to stdout. All three options will give you debug level output from Mopidy and extensions, while ``-vv`` and ``-vvv`` will give you more log output from their dependencies as well. If you run :option:`mopidy --save-debug-log`, it will save the log equivalent with ``-vvv`` to the file ``mopidy.log`` in the directory you ran the command from. If you want to reduce the logging for some component, see the docs for the :confval:`loglevels/*` config section. Debugging deadlocks =================== If Mopidy hangs without an obvious explanation, you can send the ``SIGUSR1`` signal to the Mopidy process. If Mopidy's main thread is still responsive, it will log a traceback for each running thread, showing what the threads are currently doing. This is a very useful tool for understanding exactly how the system is deadlocking. If you have the ``pkill`` command installed, you can use this by simply running:: pkill -SIGUSR1 mopidy Debugging GStreamer =================== If you really want to dig in and debug GStreamer behaviour, then check out the `Debugging section `_ of GStreamer's documentation for your options. Note that Mopidy does not support the GStreamer command line options, like ``--gst-debug-level=3``, but setting GStreamer environment variables, like :envvar:`GST_DEBUG`, works with Mopidy. For example, to run Mopidy with debug logging and GStreamer logging at level 3, you can run:: GST_DEBUG=3 mopidy -v This will produce a lot of output, but given some GStreamer knowledge this is very useful for debugging GStreamer pipeline issues. Additionally :envvar:`GST_DEBUG_FILE=gstreamer.log` can be used to redirect the debug logging to a file instead of standard out. Lastly :envvar:`GST_DEBUG_DUMP_DOT_DIR` can be used to get descriptions of the current pipeline in dot format. Currently we trigger a dump of the pipeline on every completed state change:: GST_DEBUG_DUMP_DOT_DIR=. mopidy Mopidy-2.0.0/docs/releasing.rst0000664000175000017500000001042712653464377016640 0ustar jodaljodal00000000000000****************** Release procedures ****************** Here we try to keep an up to date record of how Mopidy releases are made. This documentation serves both as a checklist, to reduce the project's dependency on key individuals, and as a stepping stone to more automation. .. _creating-releases: Creating releases ================= #. Update changelog and commit it. #. Bump the version number in ``mopidy/__init__.py``. Remember to update the test case in ``tests/test_version.py``. #. Merge the release branch (``develop`` in the example) into master:: git checkout master git merge --no-ff -m "Release v0.16.0" develop #. Install/upgrade tools used for packaging:: pip install -U twine wheel #. Build package and test it manually in a new virtualenv. The following assumes the use of virtualenvwrapper:: python setup.py sdist bdist_wheel mktmpenv pip install path/to/dist/Mopidy-0.16.0.tar.gz toggleglobalsitepackages # do manual test deactivate mktmpenv pip install path/to/dist/Mopidy-0.16.0-py27-none-any.whl toggleglobalsitepackages # do manual test deactivate #. Tag the release:: git tag -a -m "Release v0.16.0" v0.16.0 #. Push to GitHub:: git push --follow-tags #. Upload the previously built and tested sdist and bdist_wheel packages to PyPI:: twine upload dist/Mopidy-0.16.0* #. Merge ``master`` back into ``develop`` and push the branch to GitHub. #. Make sure the new tag is built by Read the Docs, and that the ``latest`` version shows the newly released version. #. Spread the word through the topic on #mopidy on IRC, @mopidy on Twitter, and on the mailing list. #. Update the Debian package. Updating Debian packages ======================== This howto is not intended to learn you all the details, just to give someone already familiar with Debian packaging an overview of how Mopidy's Debian packages is maintained. #. Install the basic packaging tools:: sudo apt-get install build-essential git-buildpackage #. Create a Wheezy pbuilder env if running on Ubuntu and this the first time. See :issue:`561` for details about why this is needed:: DIST=wheezy sudo git-pbuilder update --mirror=http://mirror.rackspace.com/debian/ --debootstrapopts --keyring=/usr/share/keyrings/debian-archive-keyring.gpg #. Check out the ``debian`` branch of the repo:: git checkout -t origin/debian git pull #. Merge the latest release tag into the ``debian`` branch:: git merge v0.16.0 #. Update the ``debian/changelog`` with a "New upstream release" entry:: dch -v 0.16.0-0mopidy1 git add debian/changelog git commit -m "debian: New upstream release" #. Check if any dependencies in ``debian/control`` or similar needs updating. #. Install any Build-Deps listed in ``debian/control``. #. Build the package and fix any issues repeatedly until the build succeeds and the Lintian check at the end of the build is satisfactory:: git buildpackage -uc -us If you are using the pbuilder make sure this command is:: sudo git buildpackage -uc -us --git-ignore-new --git-pbuilder --git-dist=wheezy --git-no-pbuilder-autoconf #. Install and test newly built package:: sudo debi Again for pbuilder use:: sudo debi --debs-dir /var/cache/pbuilder/result/ #. If everything is OK, build the package a final time to tag the package version:: git buildpackage -uc -us --git-tag Pbuilder:: sudo git buildpackage -uc -us --git-ignore-new --git-pbuilder --git-dist=wheezy --git-no-pbuilder-autoconf --git-tag #. Push the changes you've done to the ``debian`` branch and the new tag:: git push git push --tags #. If you're building for multiple architectures, checkout the ``debian`` branch on the other builders and run:: git buildpackage -uc -us Modify as above to use the pbuilder as needed. #. Copy files to the APT server. Make sure to select the correct part of the repo, e.g. main, contrib, or non-free:: scp ../mopidy*_0.16* bonobo.mopidy.com:/srv/apt.mopidy.com/app/incoming/stable/main #. Update the APT repo:: ssh bonobo.mopidy.com /srv/apt.mopidy.com/app/update.sh #. Test installation from apt.mopidy.com:: sudo apt-get update sudo apt-get dist-upgrade Mopidy-2.0.0/docs/glossary.rst0000664000175000017500000000251312575004517016514 0ustar jodaljodal00000000000000******** Glossary ******** .. glossary:: backend A part of Mopidy providing music library, playlist storage and/or playback capability to the :term:`core`. Mopidy has a backend for each music store or music service it supports. See :ref:`backend-api` for details. core The part of Mopidy that makes multiple frontends capable of using multiple backends. The core module is also the owner of the :term:`tracklist`. To use the core module, see :ref:`core-api`. extension A Python package that can extend Mopidy with one or more :term:`backends `, :term:`frontends `, or GStreamer elements like :term:`mixers `. See :ref:`ext` for a list of existing extensions and :ref:`extensiondev` for how to make a new extension. frontend A part of Mopidy *using* the :term:`core` API. Existing frontends include the :ref:`MPD server `, the MPRIS/D-Bus integration, the Last.fm scrobbler, and the :ref:`HTTP server ` with JavaScript API. See :ref:`frontend-api` for details. mixer A GStreamer element that controls audio volume. tracklist Mopidy's name for the play queue or current playlist. The name is inspired by the MPRIS specification. Mopidy-2.0.0/docs/running.rst0000664000175000017500000000225112653464377016343 0ustar jodaljodal00000000000000************** Running Mopidy ************** To start Mopidy, simply open a terminal and run:: mopidy For a complete reference to the Mopidy commands and their command line options, see :ref:`mopidy-cmd`. When Mopidy says ``MPD server running at [127.0.0.1]:6600`` it's ready to accept connections by any MPD client. Check out our non-exhaustive :doc:`/clients/mpd` list to find recommended clients. Updating the library ==================== To update the library, e.g. after audio files have changed, run:: mopidy local scan Afterwards, to refresh the library (which is for now only available through the API) it is necessary to run:: curl -d '{"jsonrpc": "2.0", "id": 1, "method": "core.library.refresh"}' http://localhost:6680/mopidy/rpc This makes the changes in the library visible to the clients. Stopping Mopidy =============== To stop Mopidy, press ``CTRL+C`` in the terminal where you started Mopidy. Mopidy will also shut down properly if you send it the TERM signal, e.g. by using ``pkill``:: pkill mopidy Running as a service ==================== Once you're done exploring Mopidy and want to run it as a proper service, check out :ref:`service`. Mopidy-2.0.0/docs/versioning.rst0000664000175000017500000000301012505224626017023 0ustar jodaljodal00000000000000.. _versioning: ********** Versioning ********** Mopidy follows `Semantic Versioning `_. In summary this means that our version numbers have three parts, MAJOR.MINOR.PATCH, which change according to the following rules: - When we *make incompatible API changes*, we increase the MAJOR number. - When we *add features* in a backwards-compatible manner, we increase the MINOR number. - When we *fix bugs* in a backwards-compatible manner, we increase the PATCH number. The promise is that if you make a Mopidy extension for Mopidy 1.0, it should work unchanged with any Mopidy 1.x release, but probably not with 2.0. When a new major version is released, you must review the incompatible changes and update your extension accordingly. Release schedule ================ We intend to have about one feature release every month in periods of active development. The features added is a mix of what we feel is most important/requested of the missing features, and features we develop just because we find them fun to make, even though they may be useful for very few users or for a limited use case. Bugfix releases will be released whenever we discover bugs that are too serious to wait for the next feature release. We will only release bugfix releases for the last feature release. E.g. when 1.2.0 is released, we will no longer provide bugfix releases for the 1.1.x series. In other words, there will be just a single supported release at any point in time. This is to not spread our limited resources too thin. Mopidy-2.0.0/docs/contributing.rst0000664000175000017500000001221512653464377017373 0ustar jodaljodal00000000000000.. _contributing: ************ Contributing ************ If you want to contribute to Mopidy, here are some tips to get you started. .. _asking-questions: Asking questions ================ Please get in touch with us in one of these ways when requesting help with Mopidy and its extensions: - Our discussion forum: `discuss.mopidy.com `_. Just sign in and fire away. - Our IRC channel: `#mopidy `_ on `irc.freenode.net `_, with public `searchable logs `_. Be prepared to hang around for a while, as we're not always around to answer straight away. Before asking for help, it might be worth your time to read the :ref:`troubleshooting` page, both so you might find a solution to your problem but also to be able to provide useful details when asking for help. Helping users ============= If you want to contribute to Mopidy, a great place to start is by helping other users on IRC and in the discussion forum. This is a contribution we value highly. As more people help with user support, new users get faster and better help. For your own benefit, you'll quickly learn what users find confusing, difficult or lacking, giving you some ideas for where you may contribute improvements, either to code or documentation. Lastly, this may also free up time for other contributors to spend more time on fixing bugs or implementing new features. .. _issue-guidelines: Issue guidelines ================ #. If you need help, see :ref:`asking-questions` above. The GitHub issue tracker is not a support forum. #. If you are not sure if what you're experiencing is a bug or not, post in the `discussion forum `__ first to verify that it's a bug. #. If you are sure that you've found a bug or have a feature request, check if there's already an issue in the `issue tracker `_. If there is, see if there is anything you can add to help reproduce or fix the issue. #. If there is no exising issue matching your bug or feature request, create a `new issue `_. Please include as much relevant information as possible. If it's a bug, including how to reproduce the bug and any relevant logs or error messages. Pull request guidelines ======================= #. Before spending any time on making a pull request: - If it's a bug, :ref:`file an issue `. - If it's an enhancement, discuss it with other Mopidy developers first, either in a GitHub issue, on the discussion forum, or on IRC. Making sure your ideas and solutions are aligned with other contributors greatly increases the odds of your pull request being quickly accepted. #. Create a new branch, based on the ``develop`` branch, for every feature or bug fix. Keep branches small and on topic, as that makes them far easier to review. We often use the following naming convention for branches: - Features get the prefix ``feature/``, e.g. ``feature/track-last-modified-as-ms``. - Bug fixes get the prefix ``fix/``, e.g. ``fix/902-consume-track-on-next``. - Improvements to the documentation get the prefix ``docs/``, e.g. ``docs/add-ext-mopidy-spotify-tunigo``. #. Follow the :ref:`code style `, especially make sure the ``flake8`` linter does not complain about anything. Travis CI will check that your pull request is "flake8 clean". See :ref:`code-linting`. #. Include tests for any new feature or substantial bug fix. See :ref:`running-tests`. #. Include documentation for any new feature. See :ref:`writing-docs`. #. Feel free to include a changelog entry in your pull request. The changelog is in :file:`docs/changelog.rst`. #. Write good commit messages. - Follow the template "topic: description" for the first line of the commit message, e.g. "mpd: Switch list command to using list_distinct". See the commit history for inspiration. - Use the rest of the commit message to explain anything you feel isn't obvious. It's better to have the details here than in the pull request description, since the commit message will live forever. - Write in the imperative, present tense: "add" not "added". For more inspiration, feel free to read these blog posts: - `Writing Git commit messages `_ - `A Note About Git Commit Messages `_ - `On commit messages `_ #. Send a pull request to the ``develop`` branch. See the `GitHub pull request docs `_ for help. .. note:: If you are contributing a bug fix for a specific minor version of Mopidy you should create the branch based on ``release-x.y`` instead of ``develop``. When the release is done the changes will be merged back into ``develop`` automatically as part of the normal release process. See :ref:`creating-releases`. Mopidy-2.0.0/docs/installation/0000775000175000017500000000000012660436443016621 5ustar jodaljodal00000000000000Mopidy-2.0.0/docs/installation/arch.rst0000664000175000017500000000252712653464377020307 0ustar jodaljodal00000000000000.. _arch-install: ********************************** Arch Linux: Install from community ********************************** If you are running Arch Linux, you can install Mopidy using the `mopidy `_ package found in ``community``. #. To install Mopidy with all dependencies, you can use:: pacman -S mopidy To upgrade Mopidy to future releases, just upgrade your system using:: pacman -Syu #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy ` or run Mopidy as a :ref:`service `. Installing extensions ===================== If you want to use any Mopidy extensions, like Spotify support or Last.fm scrobbling, AUR has `packages for lots of Mopidy extensions `_. You can also install any Mopidy extension directly from PyPI with ``pip``. To list all the extensions available from PyPI, run:: pip search mopidy Note that extensions installed from PyPI will only automatically install Python dependencies. Please refer to the extension's documentation for information about any other requirements needed for the extension to work properly. For a full list of available Mopidy extensions, including those not installable from AUR, see :ref:`ext`. Mopidy-2.0.0/docs/installation/osx.rst0000664000175000017500000001043212653464377020175 0ustar jodaljodal00000000000000*************************** OS X: Install from Homebrew *************************** If you are running OS X, you can install everything needed with Homebrew. #. Install Xcode command line developer tools. Do this even if you already have Xcode installed:: xcode-select --install #. Install `XQuartz `_. This is needed by GStreamer which Mopidy use heavily. #. Install `Homebrew `_. #. If you are already using Homebrew, make sure your installation is up to date before you continue:: brew update brew upgrade --all Notice that this will upgrade all software on your system that have been installed with Homebrew. #. Mopidy works out of box if you have installed Python from Homebrew:: brew install python .. note:: If you want to use the Python version bundled with OS X, you'll need to include Python packages installed by Homebrew in your ``PYTHONPATH``. If you don't do this, the ``mopidy`` executable will not find its dependencies and will crash. You can either amend your ``PYTHONPATH`` permanently, by adding the following statement to your shell's init file, e.g. ``~/.bashrc``:: export PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages:$PYTHONPATH And then reload the shell's init file or restart your terminal:: source ~/.bashrc Or, you can prefix the Mopidy command every time you run it:: PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages mopidy #. Mopidy has its own `Homebrew formula repo `_, called a "tap". To enable our Homebrew tap, run:: brew tap mopidy/mopidy #. To install Mopidy, run:: brew install mopidy #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. Installing extensions ===================== If you want to use any Mopidy extensions, like Spotify support or Last.fm scrobbling, the Homebrew tap has formulas for several Mopidy extensions as well. Extensions installed from Homebrew will come complete with all dependencies, both Python and non-Python ones. To list all the extensions available from our tap, you can run:: brew search mopidy You can also install any Mopidy extension directly from PyPI with ``pip``, just like on Linux. To list all the extensions available from PyPI, run:: pip search mopidy Note that extensions installed from PyPI will only automatically install Python dependencies. Please refer to the extension's documentation for information about any other requirements needed for the extension to work properly. For a full list of available Mopidy extensions, including those not installable from Homebrew, see :ref:`ext`. .. _osx-service: Running Mopidy automatically on login ===================================== On OS X, you can use launchd to start Mopidy automatically at login. If you installed Mopidy from Homebrew, simply run ``brew info mopidy`` and follow the instructions in the "Caveats" section:: $ brew info mopidy ... ==> Caveats To have launchd start mopidy at login: ln -sfv /usr/local/opt/mopidy/*.plist ~/Library/LaunchAgents Then to load mopidy now: launchctl load ~/Library/LaunchAgents/homebrew.mopidy.mopidy.plist Or, if you don't want/need launchctl, you can just run: mopidy If you happen to be on OS X, but didn't install Mopidy with Homebrew, you can get the same effect by adding the file :file:`~/Library/LaunchAgents/mopidy.plist` with the following contents:: Label mopidy ProgramArguments /usr/local/bin/mopidy RunAtLoad KeepAlive You might need to adjust the path to the ``mopidy`` executable, ``/usr/local/bin/mopidy``, to match your system. Then, to start Mopidy with launchd right away:: launchctl load ~/Library/LaunchAgents/mopidy.plist Mopidy-2.0.0/docs/installation/raspberrypi.rst0000664000175000017500000000613112660436420021711 0ustar jodaljodal00000000000000.. _raspberrypi-installation: ************ Raspberry Pi ************ Mopidy runs on all versions of `Raspberry Pi `_. However, note that Raspberry Pi 2 B's CPU is approximately six times as powerful as Raspberry Pi 1 and Raspberry Pi Zero, so Mopidy will be more joyful to use on a Raspberry Pi 2. .. image:: raspberrypi2.jpg :width: 640 :height: 363 .. _raspi-wheezy: How to for Raspbian Jessie ========================== #. Download the latest Jessie or Jessie Lite disk image from http://www.raspberrypi.org/downloads/raspbian/. If you're only using your Pi for Mopidy, go with Jessie Lite as you won't need the full graphical desktop included in the Jessie image. #. Flash the Raspbian image you downloaded to your SD card. See the `Raspberry Pi installation docs `_ for instructions. #. If you connect a monitor and a keyboard, you'll see that the Pi boots right into the ``raspi-config`` tool. If you boot with only a network cable connected, you'll have to find the IP address of the Pi yourself, e.g. by looking in the client list on your router/DHCP server. When you have found the Pi's IP address, you can SSH to the IP address and login with the user ``pi`` and password ``raspberry``. Once logged in, run ``sudo raspi-config`` to start the config tool as the ``root`` user. #. Use the ``raspi-config`` tool to setup the basics of your Pi. You might want to do one or more of the following: - Expand the file system to fill the SD card. - Change the password of the ``pi`` user. - Change the time zone. Under "Advanced Options": - Set a hostname. - Enable SSH if not already enabled. - If your will use HDMI for display and 3.5mm jack for audio, force the audio output to the 3.5mm jack. By default it will use HDMI for audio output if an HDMI cable is connected and the 3.5mm jack if not. Once done, select "Finish" and restart your Pi. If you want to change any settings later, you can simply rerun ``sudo raspi-config``. #. Once you've rebooted and has logged in as the ``pi`` user, you can enter ``sudo -i`` to become ``root``. #. Install Mopidy and its dependencies as described in :ref:`debian-install`. #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. Alternatively you may want to have Mopidy run as a :doc:`system service `, automatically starting at boot. Testing sound output ==================== You can test sound output independent of Mopidy by running:: aplay /usr/share/sounds/alsa/Front_Center.wav If you hear a voice saying "Front Center", then your sound is working. If you want to change your audio output setting, simply rerun ``sudo raspi-config``. Alternatively, you can change the audio output setting directly by running: - Auto (HDMI if connected, else 3.5mm jack): ``sudo amixer cset numid=3 0`` - Use 3.5mm jack: ``sudo amixer cset numid=3 1`` - Use HDMI: ``sudo amixer cset numid=3 2`` Mopidy-2.0.0/docs/installation/debian.rst0000664000175000017500000000602412653464377020610 0ustar jodaljodal00000000000000.. _debian-install: ****************************************** Debian/Ubuntu: Install from apt.mopidy.com ****************************************** If you run a Debian based Linux distribution, like Ubuntu, the easiest way to install Mopidy is from the `Mopidy APT archive `_. When installing from the APT archive, you will automatically get updates to Mopidy in the same way as you get updates to the rest of your system. If you're on a Raspberry Pi running Debian or Raspbian, the following instructions should work for you as well. If you're setting up a Raspberry Pi from scratch, we have a guide for installing Debian/Raspbian and Mopidy. See :ref:`raspberrypi-installation`. The packages are built for: - Debian wheezy (oldstable), which also works for Raspbian wheezy and Ubuntu 12.04 LTS. - Debian jessie (stable), which also works for Raspbian jessie and Ubuntu 14.04 LTS and newer. The packages are available for multiple CPU architectures: i386, amd64, armel, and armhf (compatible with Raspberry Pi 1 and 2). .. note:: This is just what we currently support, not a promise to continue to support the same in the future. We *will* drop support for older distributions and architectures when supporting those stops us from moving forward with the project. #. Add the archive's GPG key:: wget -q -O - https://apt.mopidy.com/mopidy.gpg | sudo apt-key add - #. If you run Debian wheezy or Ubuntu 12.04 LTS:: sudo wget -q -O /etc/apt/sources.list.d/mopidy.list https://apt.mopidy.com/wheezy.list Or, if you run any newer Debian/Ubuntu distro:: sudo wget -q -O /etc/apt/sources.list.d/mopidy.list https://apt.mopidy.com/jessie.list #. Install Mopidy and all dependencies:: sudo apt-get update sudo apt-get install mopidy #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy ` or run Mopidy as a :ref:`service `. When a new release of Mopidy is out, and you can't wait for you system to figure it out for itself, run the following to upgrade right away:: sudo apt-get update sudo apt-get dist-upgrade Installing extensions ===================== If you want to use any Mopidy extensions, like Spotify support or Last.fm scrobbling, you need to install additional packages. To list all the extensions available from apt.mopidy.com, you can run:: apt-cache search mopidy To install one of the listed packages, e.g. ``mopidy-spotify``, simply run:: sudo apt-get install mopidy-spotify You can also install any Mopidy extension directly from PyPI with ``pip``. To list all the extensions available from PyPI, run:: pip search mopidy Note that extensions installed from PyPI will only automatically install Python dependencies. Please refer to the extension's documentation for information about any other requirements needed for the extension to work properly. For a full list of available Mopidy extensions, including those not installable from apt.mopidy.com, see :ref:`ext`. Mopidy-2.0.0/docs/installation/raspberrypi2.jpg0000664000175000017500000027610212653464377021770 0ustar jodaljodal00000000000000JFIF,,ExifII* z(1 2iCanonCanon EOS 5D Mark II,,GIMP 2.8.142016:01:15 10:00:47$"'0221,@ T\ dl  t1414140100k| 2014:12:30 10:22:502014:12:30 10:22:50p9@BO@BAU!9(HHJFIFC    $.' ",#(7),01444'9=82<.342C  2!!22222222222222222222222222222222222222222222222222o" }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbr $4%&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz ?( ( ( ( *+|YZ3sӁ}y PJlH1AR䓰nQX~"VK[R#j%.̫"_a,g5$ĴQE1Q@Q@Q@Q@Q@Q@Q@Q@^O{PeWxjYxr0R?#J{4PE| }_6R.$t >l:_+Zk֡Cu-ҵo!f88`F{`W]jͨ\109b;6wOSQ֓N^r'.Utw'K9݁s׵yf"C<3v[..YFΣLVx.cxHNx`KVW6v|rTNsXᰒͷ{ȇVؘ'i[+ܥ9⺛mz of$Suf#f-@pkK]i\6T{t>RV{F6s].Ƒlϣu 5 > hAZ0dSj#1=ξy> m.cO]2jsEz MSbĆ@ǎc{ ~o6L7Raʵt]5vZX<#aG>I:)ʐHFK+ʼ{q>w !nBO89'DF$ul357@$?Jq5͝lx٭/ fOtdn$YǨ OANSjZtO :4%ݢ y#Ӝzq:v ;8=?Z燭IoIqbFU=:}5v[$BP;`-k\ҟ$ֿ$A"Yf$꣑ϯʯ\Wys W8 ӯs0jV H77_үA=?/|J襇ӿm[` y#>t$pH33БPH|78 $4'S"'%AiB~8UKɚCװZݴ(m߹;` 㯭uWpbRl9kbĺl{$*Qk 6?T!R}8me%fܲp߉89ޟ9,I5s"B[#$=}΅ `sٝ|.㰲2j[`RcW9/:֮q4 J9z( 4ZSm,+Ccp;wֽ;AK =Y0ʜ8#4]5z4QEQ$sʰ@%QKOjZX{hI R_?{+0k4RI' IyXև$rzۛehۭs$S8Ioa|U:KKރĄFqלӜmy&quC_D<F:GLv)4ěb98s]Τoʞթ&ocV儾Ѽ3,bMXoû<=O^Z-&t%x,װ z7 m$r ޲?s5R_MKF; Z:q5 :%:7K-; b1ߜe\[y L*U[ڨF514_\xqڻhOj^PHɈql2zO=>Ռ5M,UՕ̱]C%>T@O8< d KI丒x+$l_SWN3kԠIw0o:ٲzU;oxdupFK\ol׶|=E1q\79qjV8/mڈ!v̋}~FKk'XRy ȤU51Mç%5 m MH62##,/^5-uGH.d,Į'lK^It;=Gďia,.2]UqBv\[jiSKVmS.۷k%wy]#'>n"u;)א+M*-r&Ҧ7pv={pA# *TbOʹ0Ȯ=YR&s,dFq]Dm[|B~ b,%kwλ}ryϿZwac@/is,_H|~oֱcJ.(yVG=N bwt5mr!>@n9|F' A.k4>#3 g3ۑϩk->>mXȴ$TrF^`rI<3i'pNex]:F*LGR?_ z4z}IfܾHx\urj焼#cȭkz3NV!z$Q,h@YE4t6->9jUQEQEQEQET3Arf\QSQ@lf[9\ =с' 1G+B(((&http://ns.adobe.com/xap/1.0/ 3231603639 24/1 70/1 0/0 0/0 EF24-70mm f/2.8L USM 230 0 42/100 0/1 2.1.2 3231603639 24/1 70/1 0/0 0/0 EF24-70mm f/2.8L USM 230 0 42/100 0/1 2.1.2 0 2014-12-30T11:48:55Z 2014-12-30T10:22:50 0 2014-12-30T11:48:55Z Adobe Photoshop CC 2014 (Macintosh) 2D04A65DEBE8519405F0D5388EB7060C 3 Adobe RGB (1998) 2014-12-30T10:22:50.014 2D04A65DEBE8519405F0D5388EB7060C 3 Adobe RGB (1998) ABEF1E22746C7026BF37D3D245F15456 xmp.iid:e396e690-0bd8-4391-82a3-be43ec320d02 ABEF1E22746C7026BF37D3D245F15456 xmp.iid:e396e690-0bd8-4391-82a3-be43ec320d02 Canon Canon EOS 5D Mark II Top-left 300 300 Inch GIMP 2.8.14 2016:01:15 10:00:04 JPEG compression 72 72 Inch Canon Canon EOS 5D Mark II Top-left 300 300 Inch GIMP 2.8.14 2016:01:15 10:00:20 JPEG compression 72 72 Inch 1 sec. f/22,0 Manual 250 Exif Version 2.21 2014:12:30 10:22:50 2014:12:30 10:22:50 -0,38 EV (1 sec.) 8,92 EV (f/22,0) 0,00 EV 3,00 EV (f/2,8) Spot 65,0 mm 14 14 14 FlashPix Version 1.0 Internal error (unknown value 65535) 640 363 3849,2118 3908,142 Inch Normal process Manual exposure Auto white balance Standard C     C   k W֨4U=;Js +;S탤LVh$-Wcupv7k.r3xڽa@Dq|==z I-I:>;';ce{'VRGm9=;XmVO>_}^YϖZ2 lE)FyUұ]Z)zC+k#2ܸkCkH|U}V-)4i2OH筯 }XaoMkb^z;Tګ ,Zx\pG}-]>w<9ےu)4 e.[l(e0RZX.i&qZٛV KzMIݽ#sDQ39[L'\a͛Y_.웽gd>F-\9l24(Z4k"ZٲbҒřٷiJ 'hkXzڟ}۔ZY.9yØֺR+wie]Xgk&v*϶)1D2/}?Ϗ;z9"3hN;ԧo9޽MzBu3)Mw-~-v(XeIk:rMP!m]kxu=W>'³*-`KbZ`c}#]47FUSM,ruy ~8 ղ҉ش3pro9٭moy^'jzN$NBm96 Zi{5#sktW*[atg`Ø*kjڞ՞ԷhA-)=2#Z㭏|чi~V`Huš[J{T<]86CadS\|"Q}E^著eՌ#Mkk6ճlcIR;KlɬLhzw'U.֌Mr-zfMzuVǵk2j+}m׷loo o{TvmorqM#qk}Q}g7kݜN)>ǬXsJEG0ȍUPy8v; 3ŋO'>-&s|4 O<SJ fd-` [3Ńx䷇_Yb "q\b/ڶFۮŮQy#;-F}G1#2ʬjY&!1t>>{u!\-%4V[^LM14Y1OCsg̽80gCe]|ͷ'n}vjݯ}f:2εy10:kTw*}~~dzy{mY|~5Q}|<>sDgwƴzqa\'<3s<*3Mz:sw{FM[(F15eaMkt4]!LtZͥݪ1dڷ}2}_nOsˇ?yrʱ[Puí;߬v5֛ѡwcz}MUm}3ﶿtu{^}Hize+o+&ywV\yYzH6qK:utlm}+?za|OkW";EϼR4X?An#|~|籺=>S+>wSt'BdZ\حQso.M]jۋטQFC?;i3@gk!m2tƯϽ#O>mOK-XEZ|SI+~_Zw=SIW>t=/};Rus$KgY=kKxI?7uG6цڧy7||O mjEzp^Vˆvl3%}5?!^ߛãN_J S&ӳ`S=!ĺU}KSjVRZ1h145˯_lOc^>[;]inOH ~KR$=_:&{ IPV/~Se#j"xGi)~L5S?oݾ10jmlZliǎ^mv:Cp<΀2-oL3מo]ӓo4ׯVtGZj+[oYnq֝X/7uty/׬e ]*t. xssL{o/mwٮ{9hNm^""yugNHNVjλ.ã`αVFڽQĸ\C."y'cGHZxOwri\C;5[JO` F+iOWcRb_-Ϭ<_NC ܴ}|-:NOR;DvV*Vt[;Kr TK& G^!:ˈ$<`;V&ۢ7ӝ^+۞&GmqYaY`LTaB6s͝tcTy9??ݝ´wp^'G);eJb96>vV2Zc=\rO(Y9t1ri6^WFi݅&r@DD\D֟{@qнJPlN '=۝:}x5W=5Dy}]W|2vԋ<XH{ve1ju816ܯ-zOyv_wc?sϦ>4MgO uF=KVT䙯+ ^vߪq[t5pvF]f6}Z3=VQ]ϴ| 'Z+Dt]j1^R;fkO{x/X1hW}zpOqۧv9+#&"a\ĥ-W4Һ[X6g.w23=xm2^Qsɯ[+DqvHtg:wi;Ci[SDŽ8DfV+Y+i_}h{;:*CCے>oMv+gmQ îFҹ@s1Ɂ[|ˡyzxc{ptMa_k_Bq魖^@uF,+SbjuH啻7S1ZOe>Iڷlӱ};4^z%h+@"D,Y>sl^V{>B޷="ypuǔdֱ mf HL|';`p~ު.$%.^G=Xζ^ UopI+[u;$Wo4XCfUwMR5SwֽS@vؑ/ÏC|ݨ^;vFlRy lfC5:O{w@F ԃ6KO2tֲkp=M6YdR}i$0JSp 7kcju(Vq5T{qKJ~NOo{:k#v]h]'ƒ?DVxOޣ+mp6lX+$IfX.{صgSYnRlp3-vdΨৡ0 $)T;ěx 2G۱fusO < wG5 ̪긝zdiY?r5+i xh-p>E'Ev!aVa;b=|矔 !]kŸP+5͚ߟ7ixhﻒ9)ןBc^}cj [3˥LFz@kcDQBڝ~DS5Qu&N9w h]CJ/n6m y:՝^/(_oZᇱKl zt!+Ӟ9"Z70 I%[t@9Q&w:Pl,Z"`lLV fnZڦfj͆%2)#Vnh ֔ie#ʮC~ spW­)$lLCշߣ݀g[Sqc͎#/!J&Ҫĵ{p׽薷=,#Jwc$oI:X(Uj8\gFJHӖU==[j?pCqWZoj-)ZҭƤ `B# L@^8L1P“)BA-gC#Q`~[\N=jT͙rK^TN`elXr\w}0uՎ+}|@i:c?{F_d]G@ݻ~sR*K):NoNL]nu[Ri-s[XͬaޖuRkjc|`\`:GsY$F??9< bqkij7iy9̾e1yoqK4В -&7p hRg{69;Q{B7Ѫ2»4Z*G꟱XVUZbXZ ;iһXX>/?!kߐu(3^[05RQBC9גnϥƼm[3g[&lܖ [I_Vx0%Z{v)TԓLyMyyM%n2"7]Ycͤ 7&XXjٶ%LWl_LdKt8^r5X꫿ <`βލJpߘ AX={~zpjյk}_WHNR4pkw葷(󽀑C -i \avK5'|$I޶x'4IZ([>btg7"UvTlNTTETt31=_=P7X/`;iq7xu{jB#0u=蘻ƚS['REn g|7Z|F툍h+ҫ,Q5(+JTÈt=;ЭR`d]MdYU0 ͬ `ozvVl#`)Z*g6ۈ#ԇ/ X;]=D b65]~5NO5C41G"Z礗t1&gҴSgCsnW'izEz D=_wꩤjY Tvp$<:Tꮌ8B5u:h.wkŎw./pJgǽՍX7-ܒ)v\%`ۏ5ܳ՞~~sӇTlb~ }؊<$=723]Z$˞(?~_u;|*)B_d^a]KeSoobE6iffKu9|ގ~CP j qĤҴkoYWkFSkK(﷜#^b<׌a  r8$(ΌMh );م_%i=XV鮫Im[r~+#ehͩFH yI{A]2!8}wOuK>KiXvJhbA3v=Ї2e/XXDss|!EO ~~M[?^dۡeQ'UL#'5x=:/ca.9Gz5; 6̯ @7"s72e,%iOjڻY.3KiEewkuSa=~Y)j:ZĦ{>J8 *l)aƱEsOZ9h~+P*bL-d7@9r.&!O^<ᕄځpmYdz36Uwl̃Y%yNmU;֘")t8~[jBɎ $-.c&|7krXKtWXg؞{ֵx޸A~8fnn]Wkx@DZoG?9v> {uY1mo_~o> fk\aZ5r5*+[?ްa'ctP.V(bIٙnp\v" ݴTA:бc C5ڑՒ= $&#߸:n5̺g4ΦVX;q͏Ŵ-~YvI(k4vy=nH(u}_%kݣ"'j9o$o&l=p˧(5V=K0 SkӼ!C[Ʈ:'^첮PRndxb=;|#~lˮMFɕKV>g9K[=g& cAͧhP(aB2W z bk+Q"z<;9zh/GB7ӹpm@P{k[ɭ§c4&3A~a=iz};Ϻ뢒"{e[VS Cפ7ږS&&-+-5aw-o &SI5C|@/8+_0~?ͭW.)9b}.* ̫|䁭k6%)as`hjcU[^H޸<PgomU4ֵt<^`})l9zrz:W?g9HaDz3Ӟ񛆥q<6LѶQHWmBl'%[)3SvV8xI_QYGC+C qkԱ87$0;] v1. mX:"Ҳ(Op+C]5-ycaR_7ΑTyzugx [cTі5A v݊:[Ik8˜sZ W*XΦ}}"/ C~sO2.BXTuF3pFxz8,wV"Kq--kWe{ZMEVYzΔn$w؝4=j>?|da5ScT:1M8`{{QNk~k'5"q1O᩼g~2 b[U-Zy$,|7a_PqC !1"2AQaq 0B#3P@C`$brS?x9_ٵx*SKp˓FE]tb*'/욛 &@HŨa1n"3N# dOfBs|UU89N%'Czrw —AaDɕ &jU4ҩ=ƴXd5dN&/Gֳr?jLDìӚ$E[`j©S qY-(4H4Ҷ.yJ_\D /(7! ++w Sn$ܧ4VQv$  :}w]z jSW{S*.V KnpeP(X@ 4W DKȦf$LR9On̢K2&3Y?+n/ifzbqn]n0͗PљNvJY 6ջ0i̠WyPH=Êm~aedfVM*nx@/E6ŨfL a:`ɕ 5N!GuO ^Ϗoc+J{MNbjX *ieX4)%{F<\{Pj0|c1G0 :a%E3 N5h( @mB`5 0̵7}8ЧaA>k0 (ZY$Uh! @bo۲b)Lu׫4ːz,nv&`\*Ä"g"8@N2f~bfǔkKVsq<:jlt ›s-yO)uQEQCŚk TZE1JcLu6;0 . 9M6 a-@EoHIٺk5!_ZVyj(DP8J| JTħJ+ +;H3ìL_bcHacC4|#yiMm#\ZJ ##pWWg%㊔nm(v5[;wRhmC<4z$ޱLϱSqD&Rk5]J5_MtD ~ڄwӃ ٲʜ5E&s[PrM0J9)kdU#\yv>ѣMɏWXI2N~myk3ު2Q =x,m:fi[8kH nKB~MIvQ/W iy!QHsQYJH⎺C,>M+-&Ĩ&RSb=Tk]84Rܓ4wOr--A䪜N sTA5cm? ~iCOmS2P6quj8iE)d^E(hiȄZȈT)`o"T(F}T3Iq ccsZ0kiᛨ )urW3 U&l6{fb \S&H>+1żP-[!AL,l02fdDj ,2XT5-IZ7Sب[+X$:LHhdiT҄ॴWdIROU',5l+V2O<!-Rxu/T$9V|asHp-Rqxc40^NOJQ7J#Ri1&nu&h}p a§sTk)C+7f]6IN uC4 L=0"%]@X9,$y 35f&s˶`=uA0pT7hᴁwz)S$ XZ޵V*e-*+f r/ m]o'7 fZfBH&uZD\ueFE=順Lwg0c$M5P*7')$]X+|bi]Y1Td AUyc%6Lhc-,fktoLKkStVDnw%Nd66ODQp9(N)T(%ҠYiDiQMIHˮ8-vk`D8.SZꙠ2EcpԴB(궢9!a(GX G-ȸ%fCx]&*Y\rQSN)-#C Ȭ.;Ci48Q]6dV0*fPlPp$0443MwiHU (䤢givA@ %i8])-:iZ軴4`QuZSVՍӼ5#ރ`ˌ}&S !sV9" (M.H8?M5فzVDpYjJoBL.-63扟0Ҙ va=Ms* i wiHf1_XC]?H$0繬Nњ46_i71bjm^=biQsS%%iM!4 LsBRɔL@S#4J U PjBN}c̼$ jFy;5s$ݷ=OKnC?~E47c/ps믂"<+ da2Un@n>8 R031EZ24sL3%ڎqGJFkJA,P{G5a V"T((;GiO䈅z? LZ.qjf5؞:V˚ q܇i),Dܧ.F!b9WDWj :lAc,v1guTiEVANd9M;YBcXc4L%O%8gID%aO$Tjbi ٖE 5 ?_47CTxgO!\;(0H9+8R%i§Ev:WhZGɦe4H@HIP<Gz6O-7jcNiN# Op ;[Uis&S B.쩓ġk*6:d^w d`8s[?4꘲U@>*T:$ܚA4*^!Z-]3sQTj16H%YGeT.v YVLSp3L\Bn> ]XtueQͧJ/xD8s @* nһSA)rv#g5TN=x iEL!άgAu4{Xq'04juxuש]K]Eg䀼5bu{S;-S1zvYCD|& !UxO}I*9 :#JW@vh2NF* " ^~A*a[w 'Ҵ$*pwk-[}ӟG2(Vz5)Tj~gAJr/vٹMAQ8w iLU@B_˗9!ZwHޱN+S:|Qţ4'xLz%{#O *K3cjtwN ˰ E6o{ H3bu|M?y`OHwS+\;#-dd.qFfC!1"AQa2q #30B@P`rCRb$4p?8rN*N ښnl;ʳ^//wwT`Tt\w$ڗpn%?!B!VU{ bCrOsu?]'vJx  j>{l,강c^) 4\}Ы*a9L.*sfr%bnA ˻$grp6YB}!DYa6oQʶ"S@4AkTcKnMQ3SECOYNJcp )'f%8Si׭/-k&3. p~hr?d=Xt=z e0uʠ֕[S4Ro*Q30xULM*KKS t*b U[8;/΃<5]sI ٨ZyMk&T>7O?~dlAr.Np9 aNw !l*bva87զ u[r8TUGmTPUS;c˞A@X4522@%vrXAJ$)30n6!]`w|;*o렭JN$}#SG<8+qXB"LBnX+Ri/Uq$#9T~N z=X=Ɏ&MZ8D%B"滴H/$BC5$v.ß / - "mB8ehE@k#;eg}gG@I؁wzrYgSp!Ҫ eӽqhUU=&XwiL"*64&: rl1׾ '>v9Ay ewT!)h5h-Wթ;8CB"T8`(e*0B`UcnUP Qh ,UpG igkC *x+јWD3v+v85QL[}oϴˏ>8ydGiSꋅNy;g\dĄ"B;SaG29a=2!9\Du;JnyL(E(3R8#+4bܕjpT2b "/WJh=avBBЮukw9bAvjnT`pߚAV92u9ƥ?s2oK"D93S;%TT N8 ;U9VS/^pE tw+)ܤtꫀ(&ތP}e>Ϳhc 5MUKר>ɭ$.$NH2U&@- ïmC-i'Fdm_Z0W\CNhLJ0PvݩU t̮4DMԌ!]icj@SavjQcҒ+! ڮaճ )m@yUk[A]o3Sj85U#UYv{k\+7 BNӱ>^A*kw]G3t/C:g+=T9 ;t9h0ò(Ժۃ$Y.2~@Lh'ӾI؃Z|⠿8(x, RF:P>ZŨ(`^Js P*A%9Iݕqdrt"JP;!5jʽEyV m9|ӫ<6I ۓմm;~A4]ZhM'Rvh]ދx.,40bVd^C B8!RU( $2PʌҭXf1'x ^iP=%ȕIm ^)΄{CfP19E6B$",XJ3rNi,|7 5rfr {wWIָբ%yL.%wVj*ZjiW]]\۞6?"IV+\oF;Ւh푿o> [LSJDB?Y0IQU:&{&M2kpN*k -J@pv)- ,{x{/܎yD uX7Mo^즳ry 0㠷*( q;?Xj$fiURǸ>>qThC$w( e@ToiǞ*'pXc \{o9GǽZ)TRj&W^đ#PP.yqu~7pVƶ鋳0`믽M)?8.;<}p4\ .Q9W%L ދ]@v[0&1Hъp)ʴbYޭU:lةRm6o Z3y.ِj+6jFRpJ7/]DU,TpU.LNXXlkЪ:eܝ~Щ֝ odTW\{@6Qw}c }!:֗ + !+!YhЯe5.BFw"I2SomE $'^%^S;r?|n? 1v.ݹՎgwy{omg,@0ܡB}&TWX_ mzZrhs><*\9Su܉t2UU-?0UeJr.45nG@fveuo]+ӃQ1ߠesZdlA;1!^YiPI՝U=;ϒ)wKjbw}UJlm=*TKi?!^*:N7S^ ?uø:;B[sTgxT٪ZNV:neeWqD u;LLH h8& rE70,BfPH4urwjYjP%[R&7s\IN@c.UGM,~Jj#Ց5T9=R;ySհS#Ʉ^p;B-2 whJ1ޝMą{Ci#TFꄦU5wyM!vqm>'w9z#_ ꃃϢL,P\y%uCy[,jiWW6Uyy7ug=U=wߒvRl7vL:KBTҫUz8D[,cYPHi8Sx??u2Mx2$ U}#W R0 $P^ cLA1/X܌s<3&7-&No ׳#{/5]xQҋw03XYwclN1>%VS*]+T${ [P꩘ ~g!Pe:a݋?eNE/*{MU:.LtVa*J8TS Tz5pr ٣.V:LiS5Jώ(5o:H :nfnxi}|0B{SNo^[V)J1L@0E0OS\ E[4];]sJSmj%7]v {j[.k­F7rpžc]4j uipځ5ÇTݤ<+}mQe%G QC`w09WUq;«֚gԟ"+e\L{ 1UĴV?3ڽYlmئ;Q41QM`MzFWbQ zU]\V|Yl_p fme2yçDQE<LstUb<1 eƁP[V;§]i;~NO okNPe%B!pڬtfюħЀS4-xWSv)L?U~c0],0BkRrsϹQʃO`Dz[S@;gW hO* j7eAu[մ5d?S>G腲d7k)-sD*"vHwv~IB9"=cլC+EkKlyThRe3nx4Aͺ2U*Zl7 -uSp*Ý;p<#8kLbN%6@n*DKsWCuLod1JZg8绀M! NPB}uVHMۺ8uu>Gh622d|6ljzs2U[MnrmMx!H0 !NQq2+E7b+5G`Vb]H0f4lM:pN!1"AQa#2BRqbr $03C@PSc`s%DTdp?9t6( גDzzͤ&J?*(YwHݵin* S9%o⬥lYrySw+IT) xbQ"XYXuA-~:+. ={b2y,Zǔ]˖>~哘Yý^O؉V*{ D+hAk#䎹eO[f xSVt{4AGV)Y#b*f)_LrQ:sjWm^uon$ZG?]UY!(:F͊jRfʮK.Nx}#.>8gLR ccBLq$oZxF؂詍Iڭ.Ae*^Ԛ='[[V!=$Ybc|`&zQULivF>&%=6rJ\c՛ͷ߷Y'XeK~Zx5h=_;CuWJ d⭄tD{.UNqB+c8Z;&>|X5}‘[ra0ډWK6sH3c1lU=q|WI;yS*$ʉzEמ=W+ճH}c]l cg$~'aF;od~$x^r0|EzZ֬[a#v]Vۿ+SI?AJ_ X\Ivn'n4Џ Q]>?[+K1"T{Xk=8vhg\g&<oH=\:"x-܄6=zƏG.'Qs# Zu疎gF?kY+#.Pb:ss޳31#!|E|>I>Ьݷ\6uh q,W v{7y¹ SjډZYB `WIZmiqκY{ڭ51::Qg] cv#V|Yu㷬?:'<UZg'`5IIݷLѩ 76ZGR/gAYtfMΑB).h4^ #gб}ൣ. !% 6<Yx =BKI"M!,.#oEІ(y&9q:{1҃} N?3ռF'N0zA GԓO?OOȲ+n")? C 8Ċ~#iXϚ9? oJAǺ&>ߝh_Y7`[/VD#4uG9խhN拙 \$?ޚM.1t~Xp~q] -] sWN?Z=w*o>cP`6͏WCWNG7cғk\778㪤֑*5ĴgۇʓlZ#R `$g NM CKv%`;Mlgc5շIu=ܪX>&"5Gʺ9 Q(޵]FScS[m{ i JЊ6AQۍy*M'RY15:Mׅ$KB[pP[|U0ŗ QViLnC^V!(j;S;izRKʮMźc}/jC2"ҔCڴ.A{H-FW:ԩ5#T|Nyko<\DjekqY%NPx"Y|qkq48Ƕ!~i]x[і3Ƴΐ)_RP\ɫQ{Q٢;YܷX:-PcmGx6Kf0q=BK%\(91#HdO!ʨ4^iT42BuiH8xm\ՙm@0 ؙ4?jx_ڛ&9jZaxB ڋKr'`+cl1H_XԭouU]&ƣc7F̝k!f^dqFpG:GY\}(V3D͵QFe#N7Ǘ(0V5WޕI;q6!e1ćP : HV.ZXڄ:q9I}<ږc jҝbfsK}Xꏒht;ORInV8۔ErH?d ?8 +T$CC`?P5|j8dO4$t XefkQnmEv3Fމέ:I!|g 6y{K5N}-:gY:8.͡lFƮbyq8!^5o)dGY?O<)4bw"n#k>ϳYB‡Wp?ֺBpn›+yu ~V$(ƒcw/TwWgf1ͬ3`hS(d'neG?ोywSq#l\kIژZ]4>tA.>~_O3 V@߼=htLIƈu[dfzט+l7LF7GRg_ Ӽ3Sr-RG1X9:f%\f%l๎uLD8V w鈚ĢkwRٜj-[[:Iц1o*\l*^ 1r(- FN*?'Jv>?k9ods8v$ zdwO(;wכ]vSic 䪩Q#y<*މ<5緶W!U-"{@}1ݾ!l{K]pV qz#i=xEo"&f./4 Ae|ɮ@\FeI„atƾ1T|+!+.իq/36#v֨N7,MJ.~L|>NJ%[5cͫ[hK DIZ<ם>+^n\7AukRʎ`5z$\+`Xs~\iSHhu]³GpqBD)$Z '[ߜx-pC00sCVYp9E<,2 Iw.z"8-Ťn1+L|;蛦;#Q^(s.HXm&C*~xMP][~S?V'pq1eRZ>hNO¼iVǛ\Iˌ~Zc2__C rUgZLˏys<3mw#7^Q(k[^LR*a#bF䚔=#9dz#iv-.ҽ=cw}HnEu5䵙FkWӞ5O(<هY5KFF~PH&z`6[GvKf>q4akt8rchT)-x%ឱSad#l!_ݤ2o;g`wlj&7LT.dfcOInbusH;}y\.Gx/1^q>+C7[Y;Hʺ9odQCloDxx hբR0ӷ<+/<ոY[Hq&; fcQ!AOd/3粒[1a 1Ȭ9 ቓ +yӤv>Rf *P4Y=I̒63Ż!ʌi5=+j.ɒSnY$Wv+,Ӥ࿹^6<9՝OөZHWR ;5Ru`p'\myR[*-=:jǿj'^v"?Iӆ}( +rb`}sʟɑmg\ȣ?3Fxm9ܓ1[L)Tfg/mn<˸BrTqҜVR#`ղqOes'Z8{y%lLCk$fTK釄r1Q$#k3iJ8.Ǣ#-xPkYNqƺ5=C8b@3z~}bt#(|SV!xk85qĄ1a8mso®!&돍J5N@][g_}W8d a JuȾ̀L;26K ulC9-r[1S<-zyԝMܖ)hVSƂ&Qq^zԲq)Jk ve5bsƿV9%juꣻFyKzG vi V#6_DȽkː8 '֩|@!j|N9U_ D78y]0t|{}ᄐۑ< ErNaEoN+&O78-6̧̼4#q#Cj&NY)r[18SVʠ.:ՃA N蘆L[Y˺v-یF=[^_:Zhq#1/>lw-ZXj]*5ÖeTK)6Juy`T^S~Dž2R?o*@'ՁNUÌ[VOr.{vz0JOZq󤗤mծ7^Uo{k qU60-uHC!?{vegxǭ(_PQvh^lj߅jQ$|¯oQ~tMdWx;bވi!^L$]X*ζ"ES)tk8'W,,=YAg^u,HMyAhK7^ RtI\[OmzJ_V;5%?*hU>үPx?u\_tߪʑraGFeF<[]٤:w%]&C7u۟#ciP4.-7jUCwfK${0a(ۍ%z.$b翲h"FdbyAP_̚W+8ԝZed+-pʸ4.t{{)2O OYImY"̇xI,(q, C?u$x.脨_6B"vW{RYIMMC¾5FS@Hx`gƴBP!Ʈ͏§.hN@h3?}B!>e#]s#Rf贀eGp'%BF[Vm]/8os*#Xgu\Egr夊 YO±yѱbCg#u:(4*nu=Hl3]Ap=i6 Nt u.FOk$(宅`΄ 'yiYCSX&,䁆-cNsJn-UU8< EVcWJ}1ѹ*Y7}8ūSo] :`Vyz@)gh+a]!I8ϰ:OdԸ֑zxЊ6cdR񘐣M &ÐScDuD@G[~N +c3e-VV\"liz84qv*vI/g81PHQa?Xl*Qc=pi% >UËcܤȮ=ӎMm,b^G\;I|skYFGO)WWItE#HxP; xNԐB ^?D ^J؂jF,iV?ix֭aאANɾ=[ךN\jEnjTs`:3xnQ3VȀپ&2wc+np](nNk nZh,k)`ؼ̗2sjFZQӰ`;ޱ֋%=jJZ}u- RwD4B{$Ɵ84b给cEw} 1̭ՋO?jw'Qwɥ6ց'6wvXڥ`Sƍ쓻`=Q΍OH/<:;Xٍ^e+֐'mkk~`0'(S`ֽ#WlYd: sHUhRHǬ[GeF\{-T%SBl ڮd_ ˟%80'A9vv,9%[s#B{13/q f-#R\g%qZcşxW*Xo8Z.boUUo)$;Gv cb[Plյ͓;N0DV?:1ueu㺱w]'Ж2ߧ.R.5& hi[ {+ XAdҹP L:+h[މ](:zj,m1ӓ;inU{w[Q14E_R?4e9iGV.{Q[Cv䅛=]=K1P#?^ޞQzU k jA@w}Y?΢-F0}ǣN[;= /&~1*yPG'cZa(D ќnʎf"3&Ma ~ڑ8kI7eC2t} OgDsSk =棞X@Ʊm+L{*dQSW1k+# %CV0g1P3,ѓM$҈M'_4m󮐸$3Xn vyWrbh?pB1X\\NvTa5ą`3R]tPsKYԉ-D,q"UE*9B(Ӄn V/P 6dE4ꏟHN:KcF=\9Q[BtL}<+؇eyэ15Gnd2K%jfTl 6qlב<-Ipm `7#97+TyV k;V-F49hwQw^hQʰeڇ,w g>4w-͵\N$_CQ*&f* ڝ%- {kmI=zڕIR=ƾ{v( n<+t5y[G&Ӄ"XcN1qBGLߊ>"{8o/R ?KZtBzM8+1!HFO]:Y_L3so@QY#Tʻ=GۮO(GJŝKU 5y^qTE&wkU#\[IF>4òDC$f?9V]QZvw νoZVSVJ'>uXJ=9/X{ƐǩQ]㈃Xg;й']zGfi;bUiδW_ ViNc_8Qko8.ufLs{Ja7~ףܓOcя).oҜ{A1!QAaS1α#3: /y7A T (!F9(M rwU?]+NQCp]*,wΌG nIAO|L̃\qƄw*}qBFE5΄y]c})MkpxvGp`$IfL`_Lf1Wn~t#,q'bdOZo$ik%YW WPEeHU%Hն癠{6Y2aZ ƾ*w=bGIImx5&߻5-Q5$2g\{yPj48uqw*H_XqfqVS<=匛HC\LVINp>oAep{a'Gm:8 Td1$0Ǜ];V}Mgz5q3!զ5Ouŷ/b )#;{4A 8ŒfαpygKwj{k]g,c8Q`n&m9&#A>,<>&sV~2B@P*3Y6?@`k ^ Gwq_;ec]VԺnu_/n+qi|ȋ~r(:H/#h*mdƏ~F@SZɞZ [#j+řJVCq]FAM迪yd~t;GҙD]"sBqinR7_jմmQ^nD{'y>n+ްڻż,/;/V1 I-c1XQ'{A.A7 ǪKrAȥ$hG84HP64sB8pmW/TkѮT C+L_]tTs|}V#`."hjǻ^ޟ\91gj\oʇq +o1ڹ8@<Dc;u$1(ҿֻ**FAM̔~ʎ7֎e"9X\oƤXq=!Lz4$CYG FZ% $}}¥:/uÕu 92 YFRq]/ ?:pٿs k*{Jkgm.3ub$Q􇘷r{bp|Md^r7Zw5J]=/ mEe#, oTM ȺdHmgb ň`=L汈Al{?(({R] C>GHn#Cen߅ =9\bf :cQSv)Z;_cg>5KG2M'y!~|ΖK>- ʻdRau79CzkSr'*ڷZ8U݇"Ec5]QY?W*ka[\_G*-!1AQaq 0@P`p?!:&rѫ ^h~ p\M}ߩ,QVP=`N:('xV*Qmc ן,K~)~/iuE4F˻5}7Y EGev_n/{$4L`ߝM!15Xn!WNqLkʈ(oef__0ξHZf8LOz tbȧBY6CdŚg}Ĵp}jm{K >Ҳ_~qT*zïj:% h}Q||-,Աi H4uwC*Yy.>y&5DzN[-.C j_*fV&C (_Ӫ%el~jڰ᜝.S? ~# ]Zr~#)/W xt&o,;9>dL]j@g9:j3su0Aֆx3T08t~s#uo$ru h.h#E[Ѐb >XЁo>7*99[1MJʇ3Unk‚V@l9Tt@ z mtg(~̸PRX$mdT1˅?f7unLZC{eH>,T0~oL`v`U}=)5] q_!g2[W>/oUd?+ЎߙoβU;U @x0Q@ : ڢeZFDM9#! jJ*K vDJ{͎c@O ތDkTʯ<%~ZXi2*v)?֕}!_exSJZ,zTLh2j%rqP .8c`M.CBn6p4kfez\,[=NbG^l~Zόx]qtt*4bW_3Ql>!UD 4HIq1vk Y]"nxnWĶf4v"Y90ئӎD'`GP{D 1l8:\8,{,#LW֙K4y-x4tҍi^2=J~B[/η8Nwk,kWX24.Zyb8[Ϋ媡x}s%̶߱:geL59 J259 0imX~5cetva`u3į-&VU;3::C^yNC5dQq\ JݙJrgggeHӞVu; ae~oQД͐^dRi ϵei:MkEQ^aP7 ()~~zE\0f׆~ ? Rsj?ELt+ kjֻ]JDm(;&3 =ϱ+*OUfr<.UGb^JAie]z{IɱE ًKe$UG9Jpx暑ounPpeT\HzW+K^HeRgܓ-5U "UF8~PxP>[Lk,YpaX@]B5+,GnQV,>60bj"کP¼Qk$x\eRplWfPT+@#UYz7/-Hޮ@VW̷kQۍ=Dlfܢ@:8=;-gk30sݝhaoNOZY *xfU-anv{Ňbr4r:J/:9C Y/:/H"et{/5ku2⭱|[ *١3_PF#mM֧\ߨ]Q/AgzQQ{OԼ wYu|%ԾȐPiwR26p1V*T̼0rLZ雃OfI6t`ʨ&Z1CE:񹝒9}vu$X+E--S:XϸҜcemՌRlhoFۗ5b//*~ f m#*3O0L,[|&ܕ+'.>݃΋]5Xr.4)FzS1]?Ü켞T>R[@i<|lf+LY/W\|oĢ P(˃(qPuG?[蓎 kW',}b̳盫t9٘BAz|::!]jf'' Z] u3,5oWuKU2}2l]28Av?=]'-ub%[a3*<;(8^i̡HV[ /lhG"~qu/1){q[A|(X-Vn8[+K"l+8 p_f0әF@'J޾A 5<š(tdָ۵z ["־vipkWUZDٗp24˱ -/0jt`_QĹ@>KbcNT5)IF!ARL*Tq,)e@bt#V mо4J FT+nl{K8FjLcA܂M:zo0QB-݄2:LS _byJCŇH.mW27xR:/uV̻-~~VX]eFlgKLwU ku.}à UtzkbVz胫+,YO#Ab%Lp wb1m {v'Ln{U]Q3yhykriˍET% ^giukWaɰ'FEWBfLKyK2m{~%&r1{.}pK pI^2>*nsxBKe.k LLLJ-]z(uPUA71?R9Y-hg2}ae-e?9Ous_h0znP{ %!䄪kPl *v-h:Q]pL7] Юt?X+Ṵ+0 f;=p0ű≯TA*.[JL]>)gkɍز0gqnma[4iNFrSYnGVBj< c+nϧx4Aw7\R:(^bd)]sjwЪOQ #]c.,U\]Jáur\i195W(ㅩt+Ay.@Y|A;t2^:G CBmL«_瘿n7"ae8[ s4=nCJ`+`h 9k8v nun4kwҥTQZd# %%WZzo\,`ŪˆÅ+??39*6OF3%ԋ;5Q0k@Py呫ȻvQڕy@륌,ꅋ,zrc9̢r3^r+ gr_޾ M?M> #YΗWvn.m@NUI=ٮ)iY_ ˛dq9]6&J31!zM d*>-}]\V @Ct0&EV*nI3ӾbR6z7%tF/B4Ln/^7Bo#b t [QOs\s hG/et5S;[2UT7POtf`q4..LRY[<>|LGx^@٬(/H^Ej mr@aU%t{NJ:\d+M=[of71|Lkb5@y17xsr"}rD"_DL@_@4m'E(^-euw51Fetıs%; $S0BP'w(RҟeıfR˝o4p[ [41O ;kEykG\em\puROC!544w~B5<(BSooff2ڙlr&+M0`kݑ3Wy^ҥ7`>wYSGP}ԤcC_@?ۂ=dg'ҡӃ=p]؂s0 WSS쏍yp]'_]$#)`6:w/UB*It?11b9@AO<Ѩߐ4e wg^gR5|' (\^"W)vL50s~ J ^sSTFw.Kͭ5ZV–TC**G?cEd{R:*/Bb%1#o ֫ :BF}WIѮlP#"23:L ]ª-$a`@] ooҶYbZ5T3$XK\X[k9 X#c.)9-\Ĺ%wEOAJz>^tb seoSהvb]mc 3ȮrbEWzrP-ekYy5!A#EZ [ZE&SWX*О6-;gUdQEG9i*d'e8)aMzZ7Øvhxq"+av [/Pec유(/K!)Vk̠z/]\gyBmo\`Ư؜ 2=]H׷j=BC^Zb5 4S%r2@Q՟7UQ}W"%IUw؛nUS(Z+/x2 KgJcA1j%IݚM"g[D%)RES>x[N(缞M[ߴ[M[U\:_lGWE$%ol%2QhC13HYJGIHr,gHud.ˡlLTjjs{*L@KJ Fcӡ?ERtE$G=}n)ea *n`8I'dvh+6Xʮ8Q꼳;}\gxWI b\kA|]yy}q+?Ypuf Cs.ϷGhr49C 𗙓]WxYWg43mi,5l^Srh,f-{ Nh5$X芋baB. جo ::v!b3g<qd ~yۖ^xas/CGRy'|ENA$`V~젟"ɇZ&'#:(ShPFxlS?z1G Rv뽍{5 x >Bq`LzCD=T.EP4 MøT;Intt!RjT42?e1$hЭ␰P .dߣm {>*Y?P#Z9C8tv%:&_W:PS`k?)QN۾^Y@@nKpݕMgoT$[Hעܗ 4i*AH[V0 s "= r/bi|+tmU(~!c(@0a C]OLn8d^P.p2`-Ir)z1=3c/\mxNļB.fZU |ZCS;\7F V7fg-lln/o3dDA VW l 7IsUn/rzO;W2| } jn:~f}ǖNZX`U8>-q^uQ7[xe>tسnW0  :σDG!0۾yb =9kf܊ӭj; {33g/霪XG,s/AQ-X9?#"}N;v=ҡ/ŗRwՎk XWaQ+&òP`^KLn}aiz?ahK W֜c-- 5p:%:VU -`+Z xW;^ˍM1KO տaj]Q;AQK[2F cҗpC2ȫV"ͻW_)ܗ.&A̩C*Jt?5}hG)PUlw ~49MݬAf+:WHo umN: HebQa:/6L;Xx=݋(nj*s7  $$az * r0o&:A :sEUO1s/?{r[Ol_OˣoO>u_h_~oyVCbL1[~QO>% J4:Yl'N=nC8*jR:8 bΝWT/Ùdq[߾QRN2q8%11hx!MJq^|:y"4; f~b_1י?/8)ҒejQl\%W P0BNl&!ßP.%875b{!\rIW ̜n_UY=?" l>mXЀ1Yjc*d.~#k3{BB;16n_g74l{xԊ m ˮCcCATQ-qj`3'_pa)LXFn Fi9֪7JV. ?UB[njUVQkҗ5 gP\eDL&R27*WF@OJyԧLǝ~#VXcYy1A_K l2{kCnV2lPT>Ҹꕕ)@`#69tfKWH!3PzUu[hHpXw!=ݧ.D͜`_GQ(0rLP C)*:Zor*>\bjtu%Db!{6$0NɋV:~%v {Qlak̳ _SLs#a 0Np.#Amt ~~mz"*zN]Mh˗qfdSh?aEYcal] HfGPoFoAz^ Wi!1yQ7%TFЁ*N^ߙ/(?@tfs^CF:*Ѻsq(2퇊ԡ&|L5R/Q"4i.i̱sLazFP[JҾzD `2nITm"fQ?0RnOhMq W I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I I$I$I$I$I$I$I$I$I$LI$I$I$I$I$I$I$I$I$D?AI$I$I$I$I$I$I$I$I,pKI$I$I$I$I$I$I$I$PޫA#'$I$I$I$I$I$I$@x=$ I$I$I$I$I$I$=o)W(I$I$$I$I$I$X?ZՅ$I%I$I$I$?Ưֻ!{RI I$I$H<˧;K~@I]$I$@*fGQ 6B^wI$*Sj_ssV2jJ8GVI^?<ݒ}&qI$ j{?˼ޤI$ЬsU?$I Cy{a_h$I~)ٷ-P$MSOCgxe b2$8g?q]''ܢ-"\={\r$ǭiҦƿq{k;$c ߼gF'Clk?s9$7зcA4yWϿi1$eUe~n+AaM‰$HwҞ>lyyBI$I~c>޿g=ue`aҠI$I$%}d}!ViRs"I$I$c v__X6eSDzI$I$Hkj[J9pHI$I$I WˌC_UOEI$I$I$tt;/o"gދ$I$I$I$@*ndЁn ]X$I$I$I$I$[T`3O$I$I$I$I$8^GI$I$I$I$I$ *Y+泥I$I$I$I$I$I9}3_;(I$I$I$I$I$I$w OI$I$I$I$I$I$INI$I$I$I$I$I$I Y`$I$I$I$I$I$I$I$Jo$I$I$I$I$I$I$I$H: I$I$I$I$I$I$I$I$I$I$I I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$-!1AQaq𑡱 0P@`p??|au . Kđ}8h(EJY^=cܧ 7U""ފ~)(t2֢A LHK9ܛ׃zITfYhh)@̼ހSRL|\qkR鵢BaiCN}տE,>y^jFAAPXY\Ϸ -UB9K_P!I ]I8 #0DwgrA.yX 1E4y _-3aĶMhy aXLEYE[Zu@%)g*ATqzA)"3+\mo=w)5,kVϵLZvApǵ6`i&mL> YD"?Cn[ǖU/F)@7)P ܳY܅/DHQq6>'Yhlbl;CK@۬KbHp6LBkfnůRܫ 983=5=hsҴ l*Z1|yTJ=O eHh(w.fB ս-0A хLtap-Mulh[SvM8fHzyx}=7ڵ bR"K|P"Q`ig'Hk7G⟖)R_gLawL{ 榌V.k y.Q8ܹ@`yzF' K۵^V&nW%ˎع9ҁ)!w*B$Ew C/jU"m fށ 5#D)r0-sro5 ܳJ`*v2)!u-H1c}-Ln1,f] /"kA&6fZ@ (M%#Xާ3ˬkTyҖQ:{Re֚""eKR@Eo?to?;oBx?ZTKEmc3T +4jЦ*w((5JrqjFdx]T-kgj 2lzjwrgFw?I,E#m:uNؘ'kL-At}^7G:!7ܤ 0mEDiMmyg 0ĒDMƎHnq1$30 hJ¾^S$86d9" c.^4nqɲ &m:k"gnd+1Emkw Iv&DӪ_MP^,w{^b]VwK7661cjh±UtKl!I`*L}* zs =l>4RP$ڥEzS]sV/?40 )l!K@ׯjSH(>+/qC9HbѭtJ46 O/nT=iv)EF=%L͇HE,e_Up.Ta)>r$FyHg;/n"808`&AJ\ b<F܉XYr0xh' &hG#eE7gg=𾂇1zT M Z6&F4JE%",E" ! <*a17|i.-(sK/u߳oc&-$Kp*8Q;6VF[)&شzz!=j*"akU@XuiA$;e3$IX )9hJJ͏aV||Sծa4: ϺH*$]_]mm8WޮfWF0ށV!4=O%ar?>mֈzo0MoޗiKX&YI ͶfA@*`-7s, ro0wߣ( E D:zA%MOX^t; i=oRAp7 qȽMMɍK}; f"u#N)PwxN5.ok5y5"Qt/Ҙg|^-aTzzLxΆj2\5i:^ilzH qHchͳ@e~I4<ךOze 􌉇3#˪($γ!=($'AŨ4Վ~7ogML:MҡE گIo֤2ޮMKR݇4,K]-pėAJӯZ*Ted@H#K`GraORV黯4`g3Z׋mhdo=9MSjSihJjA,>BUnx3|&+/jE)Iqlobξb,bj^@"u amRhѡM1i]ś@+:BtЈ32Wii~]) oX]!g*G^ Q c3}|5Ϗ_ϣM[MnUCYp]v+[~3F*!>xJscw^k(IDEJ;uSpO#ڌ%" ah8kߓQ=w],߰0d,?o>i Lj3읩jx8ӛN,-Ҡ" T6"vChX@:RN)bU4(ȷGMi̜CH>q0$^COE",5rJܛ/f$R^2Kr.'PQ%#ӧXI7\:5iGb\.M bTfI (@ !okINR7fƳ jQ ͮ^@n:Ahb$\-~KzQJgwH_4P5.;~x%Fi@.?nDV,RV (l&N'Yc<2xqNA |ޗl bDĤa, gM(B0y_Ҥtاn+-2 ً]4K,J11Xb(a SCbnD3 SǭOX}h @mTJb ye/7R+ ʿ|nZxd`ʬi `$uM M?4nǞm! &wϽ#Q8 Ē@Tp,,G7D Kٵ)7˩l),7Bq@ ۗ9c>V@kSjNG4r!}Ux[oQ뗭^"*MIlkJA4΀;щa!.&@lBdJXU42 3-K_\Ĺ*k5 A{qB7!Z/T=)1ǽk@$_~_aRDJ)$hbnDGS< ҡ4]r6\}@Xq-}.>}ZJ'~|R> /6="B 䀘oft0G>{iՂ$sp%(B7X!6F(,&Em qdHXt!uп]CJdEdWX$7j7Xm1 5g HlM 0*Q4]plR i⯈!;@ŽޜxܿhJמkH7o8Hj#V.NdftB Kޏ(11`$.o ߿.'K\Ң=11z:k]Y3.x($d[g?ds'((k O;u eV1"{]hst>}FsY$/f݌e'}>CrM4^jX&3"|% oG8'Rŷ  ZNa4 16/B"32t离RP6ݽi` # "ZRBI;[++}bk^XpT,s@SIV#*I(OZϞb`I~ޤ_7AXݶuW5)u\DFR>4#ݙ3IINת.|TAwq{V_V]@iDQiyu])=~lKNAffsB !dބ XGBw^)[xٍ8E׺z+&u\T5b7 @s94唳dO7bA3~>'^9!LOohU%MBŔ4/kͯP"998m{Th:&KRZ<ޘ҇؉0>םhjsޏE`iP%Jj8(feu V23;}Ioh ,3--c$Ἧ)gkvd%w4N OD2ɡ@`TW3AGOK0~iuh%EHئX@S n RxU?wANC^;LΖ" }Ɍ_GځG+ӹ_3Y}0 "Ńm%[Ѩd@g.fܦaQd,fw(ݳF&ڥucO9 $"m4J0PH,GHBX f׎ }ud4lK1v,RRBNn[5"l"/iB7ov0gyHec`s@ hv'\\MM6z:Uׇ\+UB$"< [Z@Ţs:RPK"mJp#i1 ֏BENKv-Emߢa,0f'n1wf`S9r۠DQ{;P7xH=SC2mkn R6`{VϊIS #!3W-B9"fIL{gt8qsG3/W^Zz[Uu) )U.j3 @^iJ@Lou˭CK+cˮAj6z^30)ڔB>ĥhowL)x~Pd`qBdT-RMțO&XhcGگk۸Q k`7u 'G4ʇ-,~QbЖwW[ZV*.^5]V4K5hRzebh7Qؤvױ7M⒦;^;\[P:R\3{MG`w~X19wִS4_W?QG ׶+81 }-_R+-D\Dez7qmZ0h6O.ɐ7W`6+0YZ%e/RFY`0!uUW*z\[J@ s4b+Oen2B\2@LMcS (!pJUab3@ ;V(޲4UiMg5b~Z${QnT/ .Kj2}?mV _ ;]1SFF$m˜q&ލY%?AIRd3o~_@0sBDZFĩGʈ݂ 98G\iPR)1jk"m?r+J6\R'\MBnTE ;zb)kA y4BJe ;tzj3>BY~o6hRCɹ$)%eX Ώe=" ][NUb;55x9{6%3.q;21.ˌFrPoQQTʲ&Gބ'(KĤoqHS" :O ^4L gH厈b=&-JK ތ1 3Э&M-w澡H"/MAFH`l~qQ!!s˹Af3mla9P_-U?3I@1pm%T:r0G0M3 zR5`K8J}NH/+C#rŧjf@jPA-q`DQ@3MLL՞u˿1QQЍү.wWSZ;!*VY9ܡ:=zZxG^s+:-h~1GIfvK8L>Ґ4H}֢բ(8a k#JILk=C Z3T!\T"/B0D,['rlm2ri($hvT|~>I?t5:ѵ*Y&HxYcTPZ`!n'h]MB7ֆL瘨QY)bj9unuifr[&aHIRA[|YJKKͦ$2\bxnQʿ64aE"{}^XoQҹoҜLuaLjLނP(IXuq鈾@ 9`9B4۽qz!UqPjEtG)tLmivOzjoviXXd&8~jq8& Avg-CfhNֵv/o@A'D]sV(:7>h M#_2ۏJUҒB$N)@o.W4ѓD7I$Bgbi'ѾHnTuEX4oQ<;4i(ruJkgVJGg.bXmuqB#%ށvXK~wJLwy*+1M;eM<16ԔRW7[TOhPbبrFX ZTEuE[,hW,tcLPݺ>!zA%ط&^5F5pƗ(E)&K&NqMB^bը6>nd]{AR&p_]/҇}}-(R\4 C8[yڲk{L=W 4zR  pڭ(ÌHi"t?V2FJ"kb)BouN?g@5ZVcB 3EPXhZކ>v``iC ZߑneMzE2lXFuC֎+(L Tt1Pƛ4Δqg7hQjM))m>0Y6J.O&%%>F/:leb~,P .6gug_"ߚh!|PϑrSo ?";R ORj6II>KAh¶Y^tB՟ ;7 %B:_"GBg?U#֔g/\C!iPyLb[Ğ*b-&we >Y擪@LIýXNuHG78[/1?Td^أ:O':#;JƔOiBs xUAlfW-'j'S 3L(GY jd֬C.{lg-MK[dm]R։dEMդP k !%~*[r!]m,9FfzZLo{kdz[ X["DY/{]* 7$]z#qHzXӑ3XjBo6^'^_ڽ eOArB}X=(Hˇ_тwUeBtihW8*lJ1#Ss-!{Q7%0zL|?Ԕ;F30~%m1n2B$47O#9A:mP,ǜh)1҆I&wбsmj|4BZiwzbQm,^ڂGL |XKdC/S zGtQҳL4q鯛J39*68>ZPZyЗt. s[(HKF%)ԴZ 1["u yy hk;H3%1LPD?*-uމ'0D[1K[IPafgku]u+8.ߡ F`(f9a[яY5Xnh 6,%פcl(YbeQ5N:2-D !D2$K*k @Ạ̑˿1ABv3 *_&Cg-Jfaf27i0ݖ526BA67͋vU9WJtu0IJ gⅷ,NF6r.z}9,АFUo)[j!(P1:LI%gK4/Ū'ҍPA! t~VVb=:u (YO[ϷKrbh.KIzst,ōL>j)d c&RoI!@l ks{c6vDEnHJI0x'w86lO8w.R.hberx , B2D$ycX'uҍ5cF5nޕcèYKJ^u1PewU-R #&`}fT\D&/AًŢ"-4 ٖ B;k Aƕf  `*diJ&IxCwxf!vjSw=$W6 m'ZXƖ?gidԚJ!~jhJÚ·%qW)OO-4 ?vHD'f`箶a.gX͊z]m/Ku~RF =e){P9홢쮆^~4G4AHd;MϧhYֱRAٞM!e,D̮Mm֡54F/ey%{$гiW `:bl$vI:0( uօɿY2k;/ڏ-rYi˰vBrI=-IiϪMɫЀ;]]n &zhLY:XpͽgHm765B]\S[3wFHzXmPniΗ~:LrE\ƚXwu}QA6˭>EG][i  y#'VBX^qL, Q"@ūRڛsQ 0blEW}&h ν*GXۖ"" ziTKb[yXFKxi@_sœ0v'\LMK`o@l9B`ڡ9yv;^F G4;?ބrx˷*#ڬ, fٌ93#2DkD۔9`ߍ(%\Ax,qҔ8/@eXC${8X r4RI:0UU`lZhbP2q"sbX7PM}\%` T1yβm}q2V{JΙ╼la$&H_ZLg R07HF, mm-`TKš:k1H veIX 8ho.d?u&Ƽ$N6XKQ64t=Y7gX&!Le =|sf.o`^[I02-L: f6z4q촨y6i4) +EIk@Puo&7ǵ&bbi&&'obyf|&[ >+ _ue7bf]1"-mڐ` y4PY'L$rxP6˦ϝ9uٶ4"atꒈ$d(V 4Ԕ&1z o|!onoyCHĩIJVlLIJ@  ibo6oО0X(D2C4|JHrL:cr4]:g@L 7jZ6F +ޗM\A@tnU.bBgo)5z6I#(C"T,"qΤ uB+`Vb$UeɀX VNiY/0K2 kw];-W@/ x!!.OkJDfK5ن:ԉ]kԊ0tjJ.tHT:M),N4OMF0! zC^Sa6{M*$P$'[0GƔx Q,;~pvkl^{J"#ts`Ŭ3%'cP`Csygѻօ (bw(/:sҚ ujV_孼Q(Hnh4Nu>Lܩ9( >g'\.AIYK2TQJMNZ"HaeЌ>}W5y%!bCfƴE١6vUM-d& ;G5 w͋eaL XҜ!NU. 7q/&;F)]vzvjcy/A\hI25 @A t nڞp2yZ ąY :_HJj©\Pg$,n,j\ZEYˈ'"S(^7ӡcՉK)YY =lξ1M?{դohբ ,&;^]쇒pK@esch6jEӼb"y3'Xul2ԗ饸fM)(D뭳"oB?[u:PbT0$sv,ԡDzٳm 96@!wڐI=d$s4I4 M(0Pbi3Q)fQJa@䒑Sz 'r4. JD 7w]z`^Ja:A8+leMm =Kh\ʪI_M ]j7Y7i3X7KPi J r6rY #1,KlBjJ%2xD,z=qhw3SP@!T 5r5Ϧj䔜w2>H'mzF\(G\ޠb~P$o|x GHF A yjí۔L_(01m}mյE+.133nj C%І8Ц*zx&3]=b`c<ڀ,TeojQ|ޑȏzTN7mۯ+@]*S8(ESZJ-)ǽmZMFWc)BbnQ("K8Z3G[ !Ygt1B!_M02f wU}e7SgcNr R 0YR P4s=:\nTi 'cKr$QLLNI1,$rDm|K[5@ի'(6`ӊrvA믠=4}hL=ZA ߳YFR"ZTU/V A{<*zQ "D1kJ!m `4R8/ ECfF Oq[k\8dspZ~JavLp^[Sxӡ ak90pqk p I"'FnR҆qB ̇{P@6/4VVFf%Z lxsŬ]XF^:|* hK;AGP/ Hp/QA}Pj6]UoS(Uf˴D_mBUv?g8z{QzTs?-V~-}q' eKjK_̕#H`QL͉]K-f XQ @`Ђ `u$YBA$f8lY][hVYEll; "b1H詛P2V-8O})QIuR1o4zFT]H,3yM[S@5hm,:\`i!b oe@#~ZIYCf]i:/08\9^`륳Z 2\ȚxŪe]Em& BѦ*(6sK͆1b_kyUͬG*V棥,V{_ډ{4-s^YBیij%F`˴9=AK htINh@\`5 tz,$LDFTDHl8z&".[Q6W"Wta*I͔jt3I [>jI7#-}i(澕[v o%'*$fIKoRA!Y 2^ޑ/7\Luv%bzىa#,h9oB]{̢iBeHV"BPĭb!&s%؞ b+)17iY}6fk,SH26ړ@=^†f uzMXb_ߦ6BFο8gS^3 E_oƿ摖)dj54a`bpM 0?UVfjLu%ACUJns`+[QBTe$C\3Fqmޯ5:1CK]fE\"HѓG$[Z\PtK˿dSBbNJ#:&˩/-Fx{n榆`Id7Z:v&D7)sC;KsaNG$9OXI72mu&mHu)0|֚`-"0syLΩ9eў?@JX}:fh QI$Δx_3QXi=Q\h%6rS)uxl+StaA ڕ R].Y~sOn%"l0 !L.gY4`6 -/<*oH($̒>z=B\zQcEAK[FzZa:‡S8Lov)0Iq{\],CCҒ`2P(=<+6 gԑxilN &̡7ޜ`AN0z^(HQ{[h3;~bYj481ܺ*UcMA铕^V`miMyYQ:'CDQgh(ǁ":Q3Enzy*2yj@ӧLU7fs9h. {%&*&1$ᓌQDt.N.[eRጶ`SD ,C ߙU> ~֡P;y0g{Cڗ #k+A-iPrz2ҋ!p屯nT EZ/P ܴ͡d=g=}HcsQC厑Y'ॊYU*O1њFx *qɤ1AE'|VD} e@Mq^Ϟ)p[1! Y5hEcKrc., Q8Ѣ)26O+ZJ ƦO[ΔNr #i=,bFݓHKu(Tbo,jY `8Wj W|fXh-gjK  l}~=4\@99@'mGI'_0]+̨@vt;5eǹ.9R.I h |L[KI-Ld|4? ?%kvJAe! y, A@D䁚c)i(ѫNzzڽY/) ƞur+2VD}_SDWdryo ʁ.:;ugu4r԰:\yt1Vlj_WzY0H^˙ٝ@tj1.&#SnTx#D̜ U[.ZD}q@\LgPRNg4(ϥZ"of2?M8j/Nfiӵ%:|| "ƥ3A 'Z+>Rl'mtg#\ `UY)3%pD$),Y=LOf"%:Rk :4,o{@yz4EL ӀrF>{6椞 ۚw#[{ը;C#̴hf UԝHfY SMJ&ωj pD^$E m=R$ٺV40^ZY!CG]5=}Tآ7 h 0Dk1bf`W';D5."cY:7#4o8"8WqJU G/Đֈ9]fMkXx[~@10ԏ H^ſ 쨡kcRjTm'fTHTʄQf8sueVezb<_Z騘3M`j" Pd9Df9 ڗ;Qk:k:=DHZ{nN] s9Sᦋ$D7<Զ^U qMiK4(d@@k[,5gwLBhH0WU $H@aw^LAdRzE4%i^j$W"+Fȵ $vAGZ4[8& fnd˰刖Du8Mf*HjuڗFjbO&Jہm֏6\KG8,#vK. @PPj)LSR&wBIɻm"nc KX+3z|QN,8\BDI$C LXX@RIBQFX NQY^(hffe*2 4VM Akl{.4Qvi2ke_4jihzEKqJLa"&X*B`f'*1 AR"!e!ʩ G\5:"Sm"4 q<)AD6F (` cCLQ 5Tћ֘j5NXNAJN@3Ţ=NRE.bj.)AbZZ3K% ԍT?-!1AQaq0@P `p?%Gg8 U𛋮CャSHa-jr~ CDOFqCO8juIyvZ; iU&J,u0l / JXPICge)]}N3~mySp. 'bH@~Eb] $MfG/ +I*`QHNV2a3LqZ ,ЈM"mjfIG@UYr[ϞY X@IJSbA4bcbpTVqV `FGڿ *5ޓ""I*oJ B1:ERfQ1ڲWB#!6D/xn( o\`\r2b@uC,D(Y?Y**pj1x(OuOh8KS󞪈1um~(!U@E[Y]G @LvdrX!C_i2 4&4HIhQQ6&8̹PfM9 _}z3IJ9 wiͣ7KF Z`1A NoÛ# mK jpLCe h2v~|b XТuq j(G.\y ó,'@ YQbQf.,lih @G! 7DR9t)V H@v޶B& :)&HN}cR3;L!S]CZ^fvXUxBabA(N>uSvCVW8NG!{5| Z8 9ɵO#]#N$CaA*Acxttr;0sX% .Gz{6 響P"J 0hQ:p񀩠tz!A"[!WhPѶ\B >I-Kd%B'ʨq,7E~Sߚ߼AR<.!|pHtB6WFG &RoQO {9sLf5]W~{+2JǘTGޕ<BJ l-@yb$ѬQ% Mh[-Sɚn^ v5a}t-p#(Dc'():7OIb> 8(o&Uk([qu Lh[O}S뜄n& dOj}pJY>1{Q~̫MnGG1_ʞQтk5: L <0jz\"(& *U1MrqaUfe*R~㭅ֆtHEϟ@1 h⮼H,)KhddGl "UhZoSq*wcwհr$@N4V M&eڬ`l9A`;NnjQEӡ@Jœqyġ`@&!HzfTRKye5Mg(!$4~5ODN8ѹnƵӗDhOD{ς/xF/=^>z^Xjߠfǂgmv)V iKʟZR%pzs}|v!Ë́<XG eќ$цϡBUp/,Gc m8袒ȡ B݉7.HDZ \hAPCM Ȥ<`V||/ͅb"BOdH,:]TKH_h-48 .Q8#Z\09& j㩵u6| 8^a'tf~d|{蕭1iď/:т{EU%kAr`s dWQ#i(>y1Ìy<9T~hmDUqwh@ 5e,mtb#0OQgVµǂ4v;oc+[C`F- \5*r!Gb.z1zƗ/KԢx-`T*WS;92+}L@5C QMT К`vs 65(Vݐ|jр"8Z@ A!L c4֕SIl;8/&H_hᙢ@%ӴV&2rĤ[. hhs'(Ѧ *P~?2vr=b]SLlQ|^녝{IR6%^Ukf;<8..-Q&wJE%4-v$QJ/q!2@my7-CP*e*7 (bـw]ZCC]kzZ !@ -c!6s瀉m: [ Nj ϳ% $] J߱^l'lJ#JA $!K [B4Ga D)4!>V*Ҿ?1j852RUS@M$g-׻ƒ'*srD؎ݒc<i l[1lMMх>DN 7˲ VSqw9TC1lFjb&H@U\A"ㅛA+!wIj'W&$^ѥe%/f1(&JKuĀ,eTfhT㊸y@Hr0 <'#,f pE C*0cw}]^x½0Q _{>qG6 ;*`WQ̃t!yȹ-VB…f::6c4^~C"%Q4x Ht,)ƁlhI71Vk!Xg)zAx.AB1kyl/;fU;di)B) F\CgHidt³>m+.ٮ[?6(Ȣv~Ţ@Cbߨ\Ⱦ&. n-1 Oo6$Jjq`B,0w1"D–/$ږٱKJ4I0IAUUIJ v[ `F:>tҔ8Ā 4u^ "쨏X n?'ŰG2pv\2Y%'_:&Q%9VF2BK{"E7Be8i;J^_5sF8k:) >wX#S`?raPrWBlNg1jDeѾPpI4Ș½Nyk`@}s H+º$ccRet2MW`ixXj1])"c\PEQFJ0-q;<@kTب`k.l+/[6a@b ZjlS )ɍIh6 #c [d 4( ѭFT}`(h\a8ȣ ((ME'VoҌ`+R~UC1PWYi,%oK]E2. ! G D9yj6AdUÐF&N =9}]C yNԲs@$g0x 2J@H&0Z?0DDŽE~AA[H0C8 (Sd\@{^S8fUXـ4)< ne )Fw-(qEW?\`aj.7.X։zT!9I9YƉۚGNHJeNZ@Lu^QepqQVϖXD h *LJnh" HMPx|*{F@P CRK W `,ǭBCyYA#*r{|+ ;eB`&Vk_ɪ] TaNtFΔy{:1(@Ir~+"Pӡ' ʡꍓiIu4VFo#zmyc%E:ƒ_k $=r`t  f!)Q&h AJARHEư2ZP D \(-}55ҲV\ SDP4Ӊ_4m|+DtDx8'mjzf9A ٍ߉dZ520 F<ǝq6׏u!ɚwQ-TSְ_q"$a| 7ܽL?ݺ1xaf JlBgANzn mځs JQsK&EI_vx|Fبxa!v sg?H .l"Qkf-VUu^(`nMp~b׬X.^((u[!z{Ū@pQ, (LYHs}i_D*J(B[SPH nWxrHX1xb`5|ւq.z׹g m3F;^>2- S xcN1/ 3 1uU@Gx naօ"PcX*=1XPNzsoURJz$v_xd+ yCnP 6k"l:ǖjk%RQ?|íq Ǝz eW>cY gq(QQ D@MXLbHe={xooI:6Q.j9ӎiV7jkbwRݼP]_tqt0QS&b#NJb s>, wnI%*nG:1 |0+Si^t/'$Z8,-PRwl޴q n*&^4B Sxh[ָP~5%:.>S%i{x*4- 14shٳhG{ URXTBW\W 9=4|yF9~k]Q! 'S, L"o@%M//Aeu( !8!_nN5h `ѯ!$4mHl.#[хsHteU`z;:*\* ˆEᩒ(Z%+7ZjNLlGL x~pU hP84:5M9E@Ae(㼥XbT$WgNd)t^$ڨ&! s`Z|OX4~rןf>d7Zpk8W{>jM*#S`qb>@ ׋И4Myi_b0(-*HޅlxSÍm q/x457ကx3XĢsò)J^94TKq8F51Dxb"ĥICsPA}3] Dq8@ 9?&6c' GC:*6®i'kM#TjF |pYR v:xp]c[)243it@+w'8¦vc0__#H`t!z' <~L8**9 !W=zhU7 - vx>iyph@+ϛҏ!Y@Z+k 9 9&,cbpZ Ms~4 |nxorU}f>ǐ]b*j$# ]=:1/5:cDAi,y=V< B5[5-ְ`4zqgaNtzp*ƫqEKA`{E|s?@ @BKMax|aIHinۇj^/#I{x("V:GA%>M:0ZEIaH`l\iޱVWc~Z.xӊ`O2'VVq NS~3y/CQHpWm5!h!A{qtx5Q@t: ;YKU;Ra=> ˡ"O\y2w{SmU^-6 dH;:-TJƉ!EOWcAUh{(p]i 2U`ʡ!`<Q"(E~+7έ*n4`Vcf4q<r`>w׼^[Lm[7]Fya oQ{ѷe!Z0y}gacϬtCwq1XuFbExKT}C=Z@Qoőߖ0Z dU9Wߐaf\c"Tͷ%A}-Zh*ѴWqyL y[Y@qiDp|S߉jt`?9i :D9a60kzdATۼ?oby'#51O_e? o2e9] O9P),} J +Cg5jot'2߆>j_8az UϮ1ABTs:JL+*:$Ӊ]DäNr=gKJ0Gը!<'Z9T v![h(X92> V$6h1o;O h A3n-HXx!{ܤ:I]ڗSoki5j:[YnzRNxť8^2&3؍W *ߏ^qMr2ra5#5P.}Mk;_ˬB+kR< {9E]=7Y1}j @T ç!R\%b!pk1^)j(~M+00Jٹy5n"WpN m;>XAӅ{Ƥ.;9y+RPacTXu` ^sG^1OP@g[Q*ɸiJ{" 3g jݓf$kpK>Ꮯ@f)XꍁtǍ>(? i!QZW`k bmu1_h?+|qp"qOA(Em*RᭀRu-Yy)`-g\(X; `iC_ oXij6*vt?fp9i<0q-W/&ǏY].OϏϨלּQX-QmƟ<\Y.tvnE?KaN0P *%ExSP%CT ۵k :j2׫rm8 #ӛ=v!EAJatOXr FsOvQQ,[QcX"!ޛ;;.q8k* Z7ж6vU-pQw<`:77OI)A]B\@ vl`;U;Þ>ܭ PEN'+qW ? =,6#1@*'9TҞ? NoY&a ٮ0IpL;zVS2W]iI@㊙';ӝ2N 15G ODҗ\S%7[ 4*^}h,C2$5eF6-qR152R}>qgErEe-97 (baWm, 8mg&\@To?bC\MEz PQ|?0!z Tg :>n#~rF 8FA一w !΀x'q:d }n"9ĻP=.e-XIi>.W&.ʖmw"B77\f bWpԏJ e0T kóh _]梈C=gɕ$ݡ+.R6 Rx,_8BdC *jb'y6FWݞz߯[zﯜ""td\`5qVe}&MFtB)Ywh7poY&ּ⦻[=x x`_༚Z7/& ~ 79y1]&/_j9Gḡ!pډE[W_ UXbÈU8.B ITBFǯ`QuWl0tGKd( P je/HEGB1NزD+o5YvZ@^zr:a&]6c@5XTmwb&"€j=yoog_,|{Ǽzr,&5',"œ&lA* aH@\ceVƐS Pew+5б]V 45Ɯ~/*IRMA92v>[yc=\fb>f?ַ{SAs]Ұ jmxu x'y\YKKB)R`%u HqwnnQؕЯE!*Tvx LhAEWa8Gܧ 80,3,9Rdb%8KiUi $nV-מ(MOQbJ dęZ0Kl kL_499$\t/\t7٣Xx@[yO d{1 A w,,1QM`/ p-"OyCa~%Tg>~?l!W+'?% 8 X񁥟9#} È4%yTQAW-Q*T$Ws:٠y@MG[c8d~q("wg,aN?&EĐ%̡[.;W4KoхKj21: VgXL<`=B7?&&' Aޜ<|=fC_X;imNLtz We 9-UD8b~+2_#ߥBau=mv$TrX 3LtS$mI%^6Ga6o>>}6[5%d 7A]k& &Uu~-;T=kD^n m* LЉ9 tK(NZEHX@ m1*07G{qC4 P ijG ]bmE; x>t]::?8> o8h h5=r9!7φa @h(g&|lqDz6wuP̳C74^y哜3*CE.7Ap2IjPdq &>?N1N-4pA>Kqw1(̞ZgTnˋQ.N\n (ͳn|~pMNt79Ӕ02m5ô,*#k @B/[= JRqÊ+S_$]8w cRt|~0A-~8)!L@ YFBƞ!\^!P: cXp&1K1oxQ؍^bU\=1x@IfuNWmG~W kN#;/`O$Ɯv{b7 PI"]ipMۧ|mb.x;v;f9o[׼q )2?8N, ]0GJ7im` =p& . %^SxK;َuoi&dthu4Qe*Z_j-((YɇQ!\{10 ]I5C]T {c/m ^T~e4[Y ug3`k] ~[.POXYXGic`dzEz%SиN&i dߟ$iDCyʢ %8u0XP\GDqk7 Rc\LH˺FxO'&Ӂ :]'Y7L*6JN3f'U'/o"D"8b2O4K W Lwcz.iEBck-5(پi@Bkĥ;St3<;=D :?'1w>5 {,=L(P l(/R`64\ C(Dq)ۚ$ u?_Mopidy-2.0.0/docs/installation/source.rst0000664000175000017500000000635112660436420020653 0ustar jodaljodal00000000000000.. _source-install: ******************* Install from source ******************* If you are on Linux, but can't install :ref:`from the APT archive ` or :ref:`from the Arch Linux repository `, you can install Mopidy from PyPI using the ``pip`` installer. If you are looking to contribute or wish to install from source using ``git`` please follow the directions :ref:`here `. #. First of all, you need Python 2.7. Check if you have Python and what version by running:: python --version #. You need to make sure you have ``pip``, the Python package installer. You'll also need a C compiler and the Python development headers to build pyspotify later. This is how you install it on Debian/Ubuntu:: sudo apt-get install build-essential python-dev python-pip And on Arch Linux from the official repository:: sudo pacman -S base-devel python2-pip And on Fedora Linux from the official repositories:: sudo yum install -y gcc python-devel python-pip .. note:: On Fedora Linux, you must replace ``pip`` with ``pip-python`` in the following steps. #. Then you'll need to install GStreamer >= 1.2.3, with Python bindings. GStreamer is packaged for most popular Linux distributions. Search for GStreamer in your package manager, and make sure to install the Python bindings, and the "good" and "ugly" plugin sets. If you use Debian/Ubuntu you can install GStreamer like this:: sudo apt-get install python-gst-1.0 \ gir1.2-gstreamer-1.0 gir1.2-gst-plugins-base-1.0 \ gstreamer1.0-plugins-good gstreamer1.0-plugins-ugly \ gstreamer1.0-tools If you use Arch Linux, install the following packages from the official repository:: sudo pacman -S gst-python2 gst-plugins-good gst-plugins-ugly If you use Fedora you can install GStreamer like this:: sudo yum install -y python-gstreamer1 gstreamer1-plugins-good \ gstreamer1-plugins-ugly If you use Gentoo you can install GStreamer like this:: emerge -av gst-python gst-plugins-meta ``gst-plugins-meta`` is the one that actually pulls in the plugins you want, so pay attention to the USE flags, e.g. ``alsa``, ``mp3``, etc. #. Install the latest release of Mopidy:: sudo pip install -U mopidy This will use ``pip`` to install the latest release of `Mopidy from PyPI `_. To upgrade Mopidy to future releases, just rerun this command. #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. Installing extensions ===================== If you want to use any Mopidy extensions, like Spotify support or Last.fm scrobbling, you need to install additional Mopidy extensions. You can install any Mopidy extension directly from PyPI with ``pip``. To list all the extensions available from PyPI, run:: pip search mopidy Note that extensions installed from PyPI will only automatically install Python dependencies. Please refer to the extension's documentation for information about any other requirements needed for the extension to work properly. For a full list of available Mopidy extensions see :ref:`ext`. Mopidy-2.0.0/docs/installation/index.rst0000664000175000017500000000066712505224626020467 0ustar jodaljodal00000000000000.. _installation: ************ Installation ************ There are several ways to install Mopidy. What way is best depends upon your OS and/or distribution. If you want to contribute to the development of Mopidy, you should first follow the instructions here to install a regular install of Mopidy, then continue with reading :ref:`contributing` and :ref:`devenv`. .. toctree:: debian arch osx source raspberrypi Mopidy-2.0.0/docs/requirements.txt0000644000175000017500000000003112441116635017367 0ustar jodaljodal00000000000000Sphinx >= 1.0 pygraphviz Mopidy-2.0.0/docs/authors.rst0000664000175000017500000000123412653464377016350 0ustar jodaljodal00000000000000.. _authors: ******* Authors ******* Mopidy is copyright 2009-2016 Stein Magnus Jodal and contributors. Mopidy is licensed under the `Apache License, Version 2.0 `_. The following persons have contributed to Mopidy. The list is in the order of first contribution. For details on who have contributed what, please refer to our Git repository. .. include:: ../AUTHORS If want to help us making Mopidy better, the best way to do so is to contribute back to the community, either through code, documentation, tests, bug reports, or by helping other users, spreading the word, etc. See :ref:`contributing` for a head start. Mopidy-2.0.0/docs/devenv.rst0000664000175000017500000004443112660436420016142 0ustar jodaljodal00000000000000.. _devenv: *********************** Development environment *********************** This page describes a common development setup for working with Mopidy and Mopidy extensions. Of course, there may be other ways that work better for you and the tools you use, but here's one recommended way to do it. .. contents:: :local: Initial setup ============= The following steps help you get a good initial setup. They build on each other to some degree, so if you're not very familiar with Python development it might be wise to proceed in the order laid out here. .. contents:: :local: Install Mopidy the regular way ------------------------------ Install Mopidy the regular way. Mopidy has some non-Python dependencies which may be tricky to install. Thus we recommend to always start with a full regular Mopidy install, as described in :ref:`installation`. That is, if you're running e.g. Debian, start with installing Mopidy from Debian packages. Make a development workspace ---------------------------- Make a directory to be used as a workspace for all your Mopidy development:: mkdir ~/mopidy-dev It will contain all the Git repositories you'll check out when working on Mopidy and extensions. Make a virtualenv ----------------- Make a Python `virtualenv `_ for Mopidy development. The virtualenv will wall off Mopidy and its dependencies from the rest of your system. All development and installation of Python dependencies, versions of Mopidy, and extensions are done inside the virtualenv. This way your regular Mopidy install, which you set up in the first step, is unaffected by your hacking and will always be working. Most of us use the `virtualenvwrapper `_ to ease working with virtualenvs, so that's what we'll be using for the examples here. First, install and setup virtualenvwrapper as described in their docs. To create a virtualenv named ``mopidy`` which uses Python 2.7, allows access to system-wide packages like GStreamer, and uses the Mopidy workspace directory as the "project path", run:: mkvirtualenv -a ~/mopidy-dev --python `which python2.7` \ --system-site-packages mopidy Now, each time you open a terminal and want to activate the ``mopidy`` virtualenv, run:: workon mopidy This will both activate the ``mopidy`` virtualenv, and change the current working directory to ``~/mopidy-dev``. Clone the repo from GitHub -------------------------- Once inside the virtualenv, it's time to clone the ``mopidy/mopidy`` Git repo from GitHub:: git clone https://github.com/mopidy/mopidy.git When you've cloned the ``mopidy`` Git repo, ``cd`` into it:: cd ~/mopidy-dev/mopidy/ With a fresh clone of the Git repo, you should start out on the ``develop`` branch. This is where all features for the next feature release land. To confirm that you're on the right branch, run:: git branch Install development tools ------------------------- We use a number of Python development tools. The :file:`dev-requirements.txt` file has comments describing what we use each dependency for, so we might just as well include the file verbatim here: .. literalinclude:: ../dev-requirements.txt Install them all into the active virtualenv by running `pip `_:: pip install --upgrade -r dev-requirements.txt To upgrade the tools in the future, just rerun the exact same command. Install Mopidy from the Git repo -------------------------------- Next up, we'll want to run Mopidy from the Git repo. There's two reasons for this: first of all, it lets you easily change the source code, restart Mopidy, and see the change take effect. Second, it's a convenient way to keep at the bleeding edge, testing the latest developments in Mopidy itself or test some extension against the latest Mopidy changes. Assuming you're still inside the Git repo, use pip to install Mopidy from the Git repo in an "editable" form:: pip install --editable . This will not copy the source code into the virtualenv's ``site-packages`` directory, but instead create a link there pointing to the Git repo. Using ``cdsitepackages`` from virtualenvwrapper, we can quickly show that the installed :file:`Mopidy.egg-link` file points back to the Git repo:: $ cdsitepackages $ cat Mopidy.egg-link /home/user/mopidy-dev/mopidy .% $ It will also create a ``mopidy`` executable inside the virtualenv that will always run the latest code from the Git repo. Using another virtualenvwrapper command, ``cdvirtualenv``, we can show that too:: $ cdvirtualenv $ cat bin/mopidy ... The executable should contain something like this, using :mod:`pkg_resources` to look up Mopidy's "console script" entry point:: #!/home/user/virtualenvs/mopidy/bin/python2 # EASY-INSTALL-ENTRY-SCRIPT: 'Mopidy==0.19.5','console_scripts','mopidy' __requires__ = 'Mopidy==0.19.5' import sys from pkg_resources import load_entry_point if __name__ == '__main__': sys.exit( load_entry_point('Mopidy==0.19.5', 'console_scripts', 'mopidy')() ) .. note:: It still works to run ``python mopidy`` directly on the :file:`~/mopidy-dev/mopidy/mopidy/` Python package directory, but if you don't run the ``pip install`` command above, the extensions bundled with Mopidy will not be registered with :mod:`pkg_resources`, making Mopidy quite useless. Third, the ``pip install`` command will register the bundled Mopidy extensions so that Mopidy may find them through :mod:`pkg_resources`. The result of this can be seen in the Git repo, in a new directory called :file:`Mopidy.egg-info`, which is ignored by Git. The :file:`Mopidy.egg-info/entry_points.txt` file is of special interest as it shows both how the above executable and the bundled extensions are connected to the Mopidy source code: .. code-block:: ini [console_scripts] mopidy = mopidy.__main__:main [mopidy.ext] http = mopidy.http:Extension local = mopidy.local:Extension mpd = mopidy.mpd:Extension softwaremixer = mopidy.softwaremixer:Extension stream = mopidy.stream:Extension .. warning:: It's not uncommon to clean up in the Git repo now and then, e.g. by running ``git clean``. If you do this, then the :file:`Mopidy.egg-info` directory will be removed, and :mod:`pkg_resources` will no longer know how to locate the "console script" entry point or the bundled Mopidy extensions. The fix is simply to run the install command again:: pip install --editable . Finally, we can go back to the workspace, again using a virtualenvwrapper tool:: cdproject .. _running-from-git: Running Mopidy from Git ======================= As long as the virtualenv is activated, you can start Mopidy from any directory. Simply run:: mopidy To stop it again, press :kbd:`Ctrl+C`. Every time you change code in Mopidy or an extension and want to see it live, you must restart Mopidy. If you want to iterate quickly while developing, it may sound a bit tedious to restart Mopidy for every minor change. Then it's useful to have tests to exercise your code... .. _running-tests: Running tests ============= Mopidy has quite good test coverage, and we would like all new code going into Mopidy to come with tests. .. contents:: :local: Test it all ----------- You need to know at least one command; the one that runs all the tests:: tox This will run exactly the same tests as `Travis CI `_ runs for all our branches and pull requests. If this command turns green, you can be quite confident that your pull request will get the green flag from Travis as well, which is a requirement for it to be merged. As this is the ultimate test command, it's also the one taking the most time to run; up to a minute, depending on your system. But, if you have patience, this is all you need to know. Always run this command before pushing your changes to GitHub. If you take a look at the tox config file, :file:`tox.ini`, you'll see that tox runs tests in multiple environments, including a ``flake8`` environment that lints the source code for issues and a ``docs`` environment that tests that the documentation can be built. You can also limit tox to just test specific environments using the ``-e`` option, e.g. to run just unit tests:: tox -e py27 To learn more, see the `tox documentation `_ . Running unit tests ------------------ Under the hood, ``tox -e py27`` will use `pytest `_ as the test runner. We can also use it directly to run all tests:: py.test py.test has lots of possibilities, so you'll have to dive into their docs and plugins to get full benefit from it. To get you interested, here are some examples. We can limit to just tests in a single directory to save time:: py.test tests/http/ With the help of the pytest-xdist plugin, we can run tests with four Python processes in parallel, which usually cuts the test time in half or more:: py.test -n 4 Another useful feature from pytest-xdist, is the possiblity to stop on the first test failure, watch the file system for changes, and then rerun the tests. This makes for a very quick code-test cycle:: py.test -f # or --looponfail With the help of the pytest-cov plugin, we can get a report on what parts of the given module, ``mopidy`` in this example, are covered by the test suite:: py.test --cov=mopidy --cov-report=term-missing .. note:: Up to date test coverage statistics can also be viewed online at `coveralls.io `_. If we want to speed up the test suite, we can even get a list of the ten slowest tests:: py.test --durations=10 By now, you should be convinced that running py.test directly during development can be very useful. Continuous integration ---------------------- Mopidy uses the free service `Travis CI `_ for automatically running the test suite when code is pushed to GitHub. This works both for the main Mopidy repo, but also for any forks. This way, any contributions to Mopidy through GitHub will automatically be tested by Travis CI, and the build status will be visible in the GitHub pull request interface, making it easier to evaluate the quality of pull requests. For each successful build, Travis submits code coverage data to `coveralls.io `_. If you're out of work, coveralls might help you find areas in the code which could need better test coverage. .. _code-linting: Style checking and linting -------------------------- We're quite pedantic about :ref:`codestyle` and try hard to keep the Mopidy code base a very clean and nice place to work in. Luckily, you can get very far by using the `flake8 `_ linter to check your code for issues before submitting a pull request. Mopidy passes all of flake8's checks, with only a very few exceptions configured in :file:`setup.cfg`. You can either run the ``flake8`` tox environment, like Travis CI will do on your pull request:: tox -e flake8 Or you can run flake8 directly:: flake8 If successful, the command will not print anything at all. .. note:: In some rare cases it doesn't make sense to listen to flake8's warnings. In those cases, ignore the check by appending ``# noqa: `` to the source line that triggers the warning. The ``# noqa`` part will make flake8 skip all checks on the line, while the warning code will help other developers lookup what you are ignoring. .. _writing-docs: Writing documentation ===================== To write documentation, we use `Sphinx `_. See their site for lots of documentation on how to use Sphinx. .. note:: To generate a few graphs which are part of the documentation, you need some additional dependencies. You can install them from APT with:: sudo apt-get install python-pygraphviz graphviz To build the documentation, go into the :file:`docs/` directory:: cd ~/mopidy-dev/mopidy/docs/ Then, to see all available build targets, run:: make To generate an HTML version of the documentation, run:: make html The generated HTML will be available at :file:`_build/html/index.html`. To open it in a browser you can run either of the following commands, depending on your OS:: xdg-open _build/html/index.html # Linux open _build/html/index.html # OS X The documentation at https://docs.mopidy.com/ is hosted by `Read the Docs `_, which automatically updates the documentation when a change is pushed to the ``mopidy/mopidy`` repo at GitHub. Working on extensions ===================== Much of the above also applies to Mopidy extensions, though they're often a bit simpler. They don't have documentation sites and their test suites are either small and fast, or sadly missing entirely. Most of them use tox and flake8, and py.test can be used to run their test suites. .. contents:: :local: Installing extensions --------------------- As always, the ``mopidy`` virtualenv should be active when working on extensions:: workon mopidy Just like with non-development Mopidy installations, you can install extensions using pip:: pip install Mopidy-Scrobbler Installing an extension from its Git repo works the same way as with Mopidy itself. First, go to the Mopidy workspace:: cdproject # or cd ~/mopidy-dev/ Clone the desired Mopidy extension:: git clone https://github.com/mopidy/mopidy-spotify.git Change to the newly created extension directory:: cd mopidy-spotify/ Then, install the extension in "editable" mode, so that it can be imported from anywhere inside the virtualenv and the extension is registered and discoverable through :mod:`pkg_resources`:: pip install --editable . Every extension will have a ``README.rst`` file. It may contain information about extra dependencies required, development process, etc. Extensions usually have a changelog in the readme file. Upgrading extensions -------------------- Extensions often have a much quicker life cycle than Mopidy itself, often with daily releases in periods of active development. To find outdated extensions in your virtualenv, you can run:: pip search mopidy This will list all available Mopidy extensions and compare the installed versions with the latest available ones. To upgrade an extension installed with pip, simply use pip:: pip install --upgrade Mopidy-Scrobbler To upgrade an extension installed from a Git repo, it's usually enough to pull the new changes in:: cd ~/mopidy-dev/mopidy-spotify/ git pull Of course, if you have local modifications, you'll need to stash these away on a branch or similar first. Depending on the changes to the extension, it may be necessary to update the metadata about the extension package by installing it in "editable" mode again:: pip install --editable . Contribution workflow ===================== Before you being, make sure you've read the :ref:`contributing` page and the guidelines there. This section will focus more on the practical workflow. For the examples, we're making a change to Mopidy. Approximately the same workflow should work for most Mopidy extensions too. .. contents:: :local: Setting up Git remotes ---------------------- Assuming we already have a local Git clone of the upstream Git repo in :file:`~/mopidy-dev/mopidy/`, we can run ``git remote -v`` to list the configured remotes of the repo:: $ git remote -v origin https://github.com/mopidy/mopidy.git (fetch) origin https://github.com/mopidy/mopidy.git (push) For clarity, we can rename the ``origin`` remote to ``upstream``:: $ git remote rename origin upstream $ git remote -v upstream https://github.com/mopidy/mopidy.git (fetch) upstream https://github.com/mopidy/mopidy.git (push) If you haven't already, `fork the repository `_ to your own GitHub account. Then, add the new fork as a remote to your local clone:: git remote add myuser git@github.com:myuser/mopidy.git The end result is that you have both the upstream repo and your own fork as remotes:: $ git remote -v myuser git@github.com:myuser/mopidy.git (fetch) myuser git@github.com:myuser/mopidy.git (push) upstream https://github.com/mopidy/mopidy.git (fetch) upstream https://github.com/mopidy/mopidy.git (push) Creating a branch ----------------- Fetch the latest data from all remotes without affecting your working directory:: git remote update Now, we are ready to create and checkout a new branch off of the upstream ``develop`` branch for our work:: git checkout -b fix/666-crash-on-foo upstream/develop Do the work, while remembering to adhere to code style, test the changes, make necessary updates to the documentation, and making small commits with good commit messages. All as described in :ref:`contributing` and elsewhere in the :ref:`devenv` guide. Creating a pull request ----------------------- When everything is done and committed, push the branch to your fork on GitHub:: git push myuser fix/666-crash-on-foo Go to the repository on GitHub where you want the change merged, in this case https://github.com/mopidy/mopidy, and `create a pull request `_. Updating a pull request ----------------------- When the pull request is created, `Travis CI `__ will run all tests on it. If something fails, you'll get notified by email. You might as well just fix the issues right away, as we won't merge a pull request without a green Travis build. See :ref:`running-tests` on how to run the same tests locally as Travis CI runs on your pull request. When you've fixed the issues, you can update the pull request simply by pushing more commits to the same branch in your fork:: git push myuser fix/666-crash-on-foo Likewise, when you get review comments from other developers on your pull request, you're expected to create additional commits which addresses the comments. Push them to your branch so that the pull request is updated. .. note:: Setup the remote as the default push target for your branch:: git branch --set-upstream-to myuser/fix/666-crash-on-foo Then you can push more commits without specifying the remote:: git push Mopidy-2.0.0/docs/extensiondev.rst0000664000175000017500000006625412660436420017375 0ustar jodaljodal00000000000000.. _extensiondev: ********************* Extension development ********************* Mopidy started as simply an MPD server that could play music from Spotify. Early on, Mopidy got multiple "frontends" to expose Mopidy to more than just MPD clients: for example the scrobbler frontend that scrobbles your listening history to your Last.fm account, the MPRIS frontend that integrates Mopidy into the Ubuntu Sound Menu, and the HTTP server and JavaScript player API making web based Mopidy clients possible. In Mopidy 0.9 we added support for multiple music sources without stopping and reconfiguring Mopidy: for example the local backend for playing music from your disk, the stream backend for playing Internet radio streams, and the Spotify and SoundCloud backends, for playing music directly from those services. All of these are examples of what you can accomplish by creating a Mopidy extension. If you want to create your own Mopidy extension for something that does not exist yet, this guide to extension development will help you get your extension running in no time, and make it feel the way users would expect your extension to behave. Anatomy of an extension ======================= Extensions are located in a Python package called ``mopidy_something`` where "something" is the name of the application, library or web service you want to integrate with Mopidy. So, for example, if you plan to add support for a service named Soundspot to Mopidy, you would name your extension's Python package ``mopidy_soundspot``. The extension must be shipped with a ``setup.py`` file and be registered on `PyPI `_. The name of the distribution on PyPI would be something like "Mopidy-Soundspot". Make sure to include the name "Mopidy" somewhere in that name and that you check the capitalization. This is the name users will use when they install your extension from PyPI. Mopidy extensions must be licensed under an Apache 2.0 (like Mopidy itself), BSD, MIT or more liberal license to be able to be enlisted in the Mopidy documentation. The license text should be included in the ``LICENSE`` file in the root of the extension's Git repo. Combining this together, we get the following folder structure for our extension, Mopidy-Soundspot:: mopidy-soundspot/ # The Git repo root LICENSE # The license text MANIFEST.in # List of data files to include in PyPI package README.rst # Document what it is and how to use it mopidy_soundspot/ # Your code __init__.py ext.conf # Default config for the extension ... setup.py # Installation script Example content for the most important files follows below. cookiecutter project template ============================= We've also made a `cookiecutter `_ project template for `creating new Mopidy extensions `_. If you install cookiecutter and run a single command, you're asked a few questions about the name of your extension, etc. This is used to create a folder structure similar to the above, with all the needed files and most of the details filled in for you. This saves you a lot of tedious work and copy-pasting from this howto. See the readme of `cookiecutter-mopidy-ext `_ for further details. Example README.rst ================== The README file should quickly explain what the extension does, how to install it, and how to configure it. It should also contain a link to a tarball of the latest development version of the extension. It's important that this link ends with ``#egg=Mopidy-Something-dev`` for installation using ``pip install Mopidy-Something==dev`` to work. .. code-block:: rst **************** Mopidy-Soundspot **************** `Mopidy `_ extension for playing music from `Soundspot `_. Requires a Soundspot Platina subscription and the pysoundspot library. Installation ============ Install by running:: sudo pip install Mopidy-Soundspot Or, if available, install the Debian/Ubuntu package from `apt.mopidy.com `_. Configuration ============= Before starting Mopidy, you must add your Soundspot username and password to the Mopidy configuration file:: [soundspot] username = alice password = secret Project resources ================= - `Source code `_ - `Issue tracker `_ - `Development branch tarball `_ Changelog ========= v0.1.0 (2013-09-17) ------------------- - Initial release. Example setup.py ================ The ``setup.py`` file must use setuptools, and not distutils. This is because Mopidy extensions use setuptools' entry point functionality to register themselves as available Mopidy extensions when they are installed on your system. The example below also includes a couple of convenient tricks for reading the package version from the source code so that it is defined in a single place, and to reuse the README file as the long description of the package for the PyPI registration. The package must have ``install_requires`` on ``setuptools`` and ``Mopidy >= 0.14`` (or a newer version, if your extension requires it), in addition to any other dependencies required by your extension. If you implement a Mopidy frontend or backend, you'll need to include ``Pykka >= 1.1`` in the requirements. The ``entry_points`` part must be included. The ``mopidy.ext`` part cannot be changed, but the innermost string should be changed. It's format is ``ext_name = package_name:Extension``. ``ext_name`` should be a short name for your extension, typically the part after "Mopidy-" in lowercase. This name is used e.g. to name the config section for your extension. The ``package_name:Extension`` part is simply the Python path to the extension class that will connect the rest of the dots. :: from __future__ import absolute_import, unicode_literals import re from setuptools import setup, find_packages def get_version(filename): content = open(filename).read() metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", content)) return metadata['version'] setup( name='Mopidy-Soundspot', version=get_version('mopidy_soundspot/__init__.py'), url='https://github.com/your-account/mopidy-soundspot', license='Apache License, Version 2.0', author='Your Name', author_email='your-email@example.com', description='Very short description', long_description=open('README.rst').read(), packages=find_packages(exclude=['tests', 'tests.*']), zip_safe=False, include_package_data=True, install_requires=[ 'setuptools', 'Mopidy >= 0.14', 'Pykka >= 1.1', 'pysoundspot', ], entry_points={ 'mopidy.ext': [ 'soundspot = mopidy_soundspot:Extension', ], }, classifiers=[ 'Environment :: No Input/Output (Daemon)', 'Intended Audience :: End Users/Desktop', 'License :: OSI Approved :: Apache Software License', 'Operating System :: OS Independent', 'Programming Language :: Python :: 2', 'Topic :: Multimedia :: Sound/Audio :: Players', ], ) To make sure your README, license file and default config file is included in the package that is uploaded to PyPI, we'll also need to add a ``MANIFEST.in`` file:: include LICENSE include MANIFEST.in include README.rst include mopidy_soundspot/ext.conf For details on the ``MANIFEST.in`` file format, check out the `distutils docs `_. `check-manifest `_ is a very useful tool to check your ``MANIFEST.in`` file for completeness. Example __init__.py =================== The ``__init__.py`` file should be placed inside the ``mopidy_soundspot`` Python package. The root of your Python package should have an ``__version__`` attribute with a :pep:`386` compliant version number, for example "0.1". Next, it should have a class named ``Extension`` which inherits from Mopidy's extension base class, :class:`mopidy.ext.Extension`. This is the class referred to in the ``entry_points`` part of ``setup.py``. Any imports of other files in your extension, outside of Mopidy and it's core requirements, should be kept inside methods. This ensures that this file can be imported without raising :exc:`ImportError` exceptions for missing dependencies, etc. The default configuration for the extension is defined by the ``get_default_config()`` method in the ``Extension`` class which returns a :mod:`ConfigParser` compatible config section. The config section's name must be the same as the extension's short name, as defined in the ``entry_points`` part of ``setup.py``, for example ``soundspot``. All extensions must include an ``enabled`` config which normally should default to ``true``. Provide good defaults for all config values so that as few users as possible will need to change them. The exception is if the config value has security implications; in that case you should default to the most secure configuration. Leave any configurations that don't have meaningful defaults blank, like ``username`` and ``password``. In the example below, we've chosen to maintain the default config as a separate file named ``ext.conf``. This makes it easy to include the default config in documentation without duplicating it. This is ``mopidy_soundspot/__init__.py``:: from __future__ import absolute_import, unicode_literals import logging import os from mopidy import config, exceptions, ext __version__ = '0.1' # If you need to log, use loggers named after the current Python module logger = logging.getLogger(__name__) class Extension(ext.Extension): dist_name = 'Mopidy-Soundspot' ext_name = 'soundspot' version = __version__ def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') return config.read(conf_file) def get_config_schema(self): schema = super(Extension, self).get_config_schema() schema['username'] = config.String() schema['password'] = config.Secret() return schema def get_command(self): from .commands import SoundspotCommand return SoundspotCommand() def validate_environment(self): # Any manual checks of the environment to fail early. # Dependencies described by setup.py are checked by Mopidy, so you # should not check their presence here. pass def setup(self, registry): # You will typically only do one of the following things in a # single extension. # Register a frontend from .frontend import SoundspotFrontend registry.add('frontend', SoundspotFrontend) # Register a backend from .backend import SoundspotBackend registry.add('backend', SoundspotBackend) # Or nothing to register e.g. command extension pass And this is ``mopidy_soundspot/ext.conf``: .. code-block:: ini [soundspot] enabled = true username = password = For more detailed documentation on the extension class, see the :ref:`ext-api`. Example frontend ================ If you want to *use* Mopidy's core API from your extension, then you want to implement a frontend. The skeleton of a frontend would look like this. Notice that the frontend gets passed a reference to the core API when it's created. See the :ref:`frontend-api` for more details. :: import pykka from mopidy import core class SoundspotFrontend(pykka.ThreadingActor, core.CoreListener): def __init__(self, config, core): super(SoundspotFrontend, self).__init__() self.core = core # Your frontend implementation Example backend =============== If you want to extend Mopidy to support new music and playlist sources, you want to implement a backend. A backend does not have access to Mopidy's core API at all, but it does have a bunch of interfaces it can implement to extend Mopidy. The skeleton of a backend would look like this. See :ref:`backend-api` for more details. :: import pykka from mopidy import backend class SoundspotBackend(pykka.ThreadingActor, backend.Backend): def __init__(self, config, audio): super(SoundspotBackend, self).__init__() self.audio = audio # Your backend implementation Example command =============== If you want to extend the Mopidy with a new helper not run from the server, such as scanning for media, adding a command is the way to go. Your top level command name will always match your extension name, but you are free to add sub-commands with names of your choosing. The skeleton of a command would look like this. See :ref:`commands-api` for more details. :: from mopidy import commands class SoundspotCommand(commands.Command): help = 'Some text that will show up in --help' def __init__(self): super(SoundspotCommand, self).__init__() self.add_argument('--foo') def run(self, args, config, extensions): # Your command implementation return 0 Example web application ======================= As of Mopidy 0.19, extensions can use Mopidy's built-in web server to host static web clients as well as Tornado and WSGI web applications. For several examples, see the :ref:`http-server-api` docs or explore with :ref:`http-explore-extension` extension. Running an extension ==================== Once your extension is ready to go, to see it in action you'll need to register it with Mopidy. Typically this is done by running ``python setup.py install`` from your extension's Git repo root directory. While developing your extension and to avoid doing this every time you make a change, you can instead run ``python setup.py develop`` to effectively link Mopidy directly with your development files. Python conventions ================== In general, it would be nice if Mopidy extensions followed the same :ref:`codestyle` as Mopidy itself, as they're part of the same ecosystem. Among other things, the code style guide explains why all the above examples start with ``from __future__ import absolute_import, unicode_literals``. Use of Mopidy APIs ================== When writing an extension, you should only use APIs documented at :ref:`api-ref`. Other parts of Mopidy, like :mod:`mopidy.internal`, may change at any time and are not something extensions should use. Mopidy performs type checking to help catch extension bugs. This applies to both frontend calls into core and return values from backends. Additionally model fields always get validated to further guard against bad data. Logging in extensions ===================== For servers like Mopidy, logging is essential for understanding what's going on. We use the :mod:`logging` module from Python's standard library. When creating a logger, always namespace the logger using your Python package name as this will be visible in Mopidy's debug log:: import logging logger = logging.getLogger('mopidy_soundspot') # Or even better, use the Python module name as the logger name: logger = logging.getLogger(__name__) When logging at logging level ``info`` or higher (i.e. ``warning``, ``error``, and ``critical``, but not ``debug``) the log message will be displayed to all Mopidy users. Thus, the log messages at those levels should be well written and easy to understand. As the logger name is not included in Mopidy's default logging format, you should make it obvious from the log message who is the source of the log message. For example:: Loaded 17 Soundspot playlists Is much better than:: Loaded 17 playlists If you want to turn on debug logging for your own extension, but not for everything else due to the amount of noise, see the docs for the :confval:`loglevels/*` config section. Making HTTP requests from extensions ==================================== Many Mopidy extensions need to make HTTP requests to use some web API. Here's a few recommendations to those extensions. Proxies ------- If you make HTTP requests please make sure to respect the :ref:`proxy configs `, so that all the requests you make go through the proxy configured by the Mopidy user. To make this easier for extension developers, the helper function :func:`mopidy.httpclient.format_proxy` was added in Mopidy 1.1. This function returns the proxy settings `formatted the way Requests expects `__. User-Agent strings ------------------ When you make HTTP requests, it's helpful for debugging and usage analysis if the client identifies itself with a proper User-Agent string. In Mopidy 1.1, we added the helper function :func:`mopidy.httpclient.format_user_agent`. Here's an example of how to use it:: >>> from mopidy import httpclient >>> import mopidy_soundspot >>> httpclient.format_user_agent('%s/%s' % ( ... mopidy_soundspot.Extension.dist_name, mopidy_soundspot.__version__)) u'Mopidy-SoundSpot/2.0.0 Mopidy/1.0.7 Python/2.7.10' Example using Requests sessions ------------------------------- Most Mopidy extensions that make HTTP requests use the `Requests `_ library to do so. When using Requests, the most convenient way to make sure the proxy and User-Agent header is set properly is to create a Requests session object and use that object to make all your HTTP requests:: from mopidy import httpclient import requests import mopidy_soundspot def get_requests_session(proxy_config, user_agent): proxy = httpclient.format_proxy(proxy_config) full_user_agent = httpclient.format_user_agent(user_agent) session = requests.Session() session.proxies.update({'http': proxy, 'https': proxy}) session.headers.update({'user-agent': full_user_agent}) return session # ``mopidy_config`` is the config object passed to your frontend/backend # constructor session = get_requests_session( proxy_config=mopidy_config['proxy'], user_agent='%s/%s' % ( mopidy_soundspot.Extension.dist_name, mopidy_soundspot.__version__)) response = session.get('http://example.com') # Now do something with ``response`` and/or make further requests using the # ``session`` object. For further details, see Requests' docs on `session objects `__. Testing extensions ================== Creating test cases for your extensions makes them much simpler to maintain over the long term. It can also make it easier for you to review and accept pull requests from other contributors knowing that they will not break the extension in some unanticipated way. Before getting started, it is important to familiarize yourself with the Python `mock library `_. When it comes to running tests, Mopidy typically makes use of testing tools like `tox `_ and `pytest `_. Testing approach ---------------- To a large extent the testing approach to follow depends on how your extension is structured, which parts of Mopidy it interacts with, and if it uses any 3rd party APIs or makes any HTTP requests to the outside world. The sections that follow contain code extracts that highlight some of the key areas that should be tested. For more exhaustive examples, you may want to take a look at the test cases that ship with Mopidy itself which covers everything from instantiating various controllers, reading configuration files, and simulating events that your extension can listen to. In general your tests should cover the extension definition, the relevant Mopidy controllers, and the Pykka backend and / or frontend actors that form part of the extension. Testing the extension definition -------------------------------- Test cases for checking the definition of the extension should ensure that: - the extension provides a ``ext.conf`` configuration file containing the relevant parameters with their default values, - that the config schema is fully defined, and - that the extension's actor(s) are added to the Mopidy registry on setup. An example of what these tests could look like is provided below:: def test_get_default_config(self): ext = Extension() config = ext.get_default_config() assert '[my_extension]' in config assert 'enabled = true' in config assert 'param_1 = value_1' in config assert 'param_2 = value_2' in config assert 'param_n = value_n' in config def test_get_config_schema(self): ext = Extension() schema = ext.get_config_schema() assert 'enabled' in schema assert 'param_1' in schema assert 'param_2' in schema assert 'param_n' in schema def test_setup(self): registry = mock.Mock() ext = Extension() ext.setup(registry) calls = [mock.call('frontend', frontend_lib.MyFrontend), mock.call('backend', backend_lib.MyBackend)] registry.add.assert_has_calls(calls, any_order=True) Testing backend actors ---------------------- Backends can usually be constructed with a small mockup of the configuration file, and mocking the audio actor:: @pytest.fixture def config(): return { 'http': { 'hostname': '127.0.0.1', 'port': '6680' }, 'proxy': { 'hostname': 'host_mock', 'port': 'port_mock' }, 'my_extension': { 'enabled': True, 'param_1': 'value_1', 'param_2': 'value_2', 'param_n': 'value_n', } } def get_backend(config): return backend.MyBackend(config=config, audio=mock.Mock()) The following libraries might be useful for mocking any HTTP requests that your extension makes: - `responses `_ - A utility library for mocking out the requests Python library. - `vcrpy `_ - Automatically mock your HTTP interactions to simplify and speed up testing. At the very least, you'll probably want to patch ``requests`` or any other web API's that you use to avoid any unintended HTTP requests from being made by your backend during testing:: from mock import patch @mock.patch('requests.get', mock.Mock(side_effect=Exception('Intercepted unintended HTTP call'))) Backend tests should also ensure that: - the backend provides a unique URI scheme, - that it sets up the various providers (e.g. library, playback, etc.) :: def test_uri_schemes(config): backend = get_backend(config) assert 'my_scheme' in backend.uri_schemes def test_init_sets_up_the_providers(config): backend = get_backend(config) assert isinstance(backend.library, library.MyLibraryProvider) assert isinstance(backend.playback, playback.MyPlaybackProvider) Once you have a backend instance to work with, testing the various playback, library, and other providers is straight forward and should not require any special setup or processing. Testing libraries ----------------- Library test cases should cover the implementations of the standard Mopidy API (e.g. ``browse``, ``lookup``, ``refresh``, ``get_images``, ``search``, etc.) Testing playback controllers ---------------------------- Testing ``change_track`` and ``translate_uri`` is probably the highest priority, since these methods are used to prepare the track and provide its audio URL to Mopidy's core for playback. Testing frontends ----------------- Because most frontends will interact with the Mopidy core, it will most likely be necessary to have a full core running for testing purposes:: self.core = core.Core.start( config, backends=[get_backend(config)]).proxy() It may be advisable to take a quick look at the `Pykka API `_ at this point to make sure that you are familiar with ``ThreadingActor``, ``ThreadingFuture``, and the ``proxies`` that allow you to access the attributes and methods of the actor directly. You'll also need a list of :class:`~mopidy.models.Track` and a list of URIs in order to populate the core with some simple tracks that can be used for testing:: class BaseTest(unittest.TestCase): tracks = [ models.Track(uri='my_scheme:track:id1', length=40000), # Regular track models.Track(uri='my_scheme:track:id2', length=None), # No duration ] uris = [ 'my_scheme:track:id1', 'my_scheme:track:id2'] In the ``setup()`` method of your test class, you will then probably need to monkey patch looking up tracks in the library (so that it will always use the lists that you defined), and then populate the core's tracklist:: def lookup(uris): result = {uri: [] for uri in uris} for track in self.tracks: if track.uri in result: result[track.uri].append(track) return result self.core.library.lookup = lookup self.tl_tracks = self.core.tracklist.add(uris=self.uris).get() With all of that done you should finally be ready to instantiate your frontend:: self.frontend = frontend.MyFrontend.start(config(), self.core).proxy() Keep in mind that the normal core and frontend methods will usually return ``pykka.ThreadingFuture`` objects, so you will need to add ``.get()`` at the end of most method calls in order to get to the actual return values. Triggering events ----------------- There may be test case scenarios that require simulating certain event triggers that your extension's actors can listen for and respond on. An example for patching the listener to store these events, and then play them back for your actor, may look something like this:: self.events = [] self.patcher = mock.patch('mopidy.listener.send') self.send_mock = self.patcher.start() def send(cls, event, **kwargs): self.events.append((event, kwargs)) self.send_mock.side_effect = send Once all of the events have been captured, a method like ``replay_events()`` can be called at the relevant points in the code to have the events fire:: def replay_events(self, my_actor, until=None): while self.events: if self.events[0][0] == until: break event, kwargs = self.events.pop(0) frontend.on_event(event, **kwargs).get() For further details and examples, refer to the `/tests `_ directory on the Mopidy development branch.Mopidy-2.0.0/docs/codestyle.rst0000664000175000017500000000334312660436420016643 0ustar jodaljodal00000000000000.. _codestyle: ********** Code style ********** - Always import ``unicode_literals`` and use unicode literals for everything except where you're explicitly working with bytes, which are marked with the ``b`` prefix. Do this:: from __future__ import unicode_literals foo = 'I am a unicode string, which is a sane default' bar = b'I am a bytestring' Not this:: foo = u'I am a unicode string' bar = 'I am a bytestring, but was it intentional?' - Follow :pep:`8` unless otherwise noted. `flake8 `_ should be used to check your code against the guidelines. - Use four spaces for indentation, *never* tabs. - Use CamelCase with initial caps for class names:: ClassNameWithCamelCase - Use underscore to split variable, function and method names for readability. Don't use CamelCase. :: lower_case_with_underscores - Use the fact that empty strings, lists and tuples are :class:`False` and don't compare boolean values using ``==`` and ``!=``. - Follow whitespace rules as described in :pep:`8`. Good examples:: spam(ham[1], {eggs: 2}) spam(1) dict['key'] = list[index] - Limit lines to 80 characters and avoid trailing whitespace. However note that wrapped lines should be *one* indentation level in from level above, except for ``if``, ``for``, ``with``, and ``while`` lines which should have two levels of indentation:: if (foo and bar ... baz and foobar): a = 1 from foobar import (foo, bar, ... baz) - For consistency, prefer ``'`` over ``"`` for strings, unless the string contains ``'``. - Take a look at :pep:`20` for a nice peek into a general mindset useful for Python coding. Mopidy-2.0.0/docs/changelog.rst0000664000175000017500000044577112660436420016616 0ustar jodaljodal00000000000000********* Changelog ********* This changelog is used to track all major changes to Mopidy. v2.0.0 (2016-02-15) =================== Mopidy 2.0 is here! Since the release of 1.1, we've closed or merged approximately 80 issues and pull requests through about 350 commits by 14 extraordinary people, including 10 newcomers. That's about the same amount of issues and commits as between 1.0 and 1.1. The number of contributors is a bit lower but we didn't have a real life sprint during this development cycle. Thanks to :ref:`everyone ` who has :ref:`contributed `! With the release of Mopidy 1.0 we promised that any extension working with Mopidy 1.0 should continue working with all Mopidy 1.x releases. Mopidy 2.0 is quite a friendly major release and will only break a single extension that we know of: Mopidy-Spotify. To ensure that everything continues working, please upgrade to Mopidy 2.0 and Mopidy-Spotify 3.0 at the same time. No deprecated functionality has been removed in Mopidy 2.0. The major features of Mopidy 2.0 are: - Gapless playback has been mostly implemented. It works as long as you don't change tracks in the middle of a track or use previous and next. In a future release, previous and next will also become gapless. It is now quite easy to have Mopidy streaming audio over the network using Icecast. See the updated :ref:`streaming` docs for details of how to set it up and workarounds for the remaining issues. - Mopidy has upgraded from GStreamer 0.10 to 1.x. This has been in our backlog for more than three years. With this upgrade we're ridding ourselves of years of GStreamer bugs that have been fixed in newer releases, we can get into Debian testing again, and we've removed the last major roadblock for running Mopidy on Python 3. Dependencies ------------ - Mopidy now requires GStreamer >= 1.2.3, as we've finally ported from GStreamer 0.10. Since we're requiring a new major version of our major dependency, we're upping the major version of Mopidy too. (Fixes: :issue:`225`) Core API -------- - Start ``tlid`` counting at 1 instead of 0 to keep in sync with MPD's ``songid``. - :meth:`~mopidy.core.PlaybackController.get_time_position` now returns the seek target while a seek is in progress. This gives better results than just failing the position query. (Fixes: :issue:`312` PR: :issue:`1346`) - Add :meth:`mopidy.core.PlaylistsController.get_uri_schemes`. (PR: :issue:`1362`) - The ``track_playback_ended`` event now includes the correct ``tl_track`` reference when changing to the next track in consume mode. (Fixes: :issue:`1402` PR: :issue:`1403` PR: :issue:`1406`) Models ------ - **Deprecated:** :attr:`mopidy.models.Album.images` is deprecated. Use :meth:`mopidy.core.LibraryController.get_images` instead. (Fixes: :issue:`1325`) Extension support ----------------- - Log exception and continue if an extension crashes during setup. Previously, we let Mopidy crash if an extension's setup crashed. (PR: :issue:`1337`) Local backend ------------- - Made :confval:`local/data_dir` really deprecated. This change breaks older versions of Mopidy-Local-SQLite and Mopidy-Local-Images. M3U backend ----------- - Add :confval:`m3u/base_dir` for resolving relative paths in M3U files. (Fixes: :issue:`1428`, PR: :issue:`1442`) - Derive track name from file name for non-extended M3U playlists. (Fixes: :issue:`1364`, PR: :issue:`1369`) - Major refactoring of the M3U playlist extension. (Fixes: :issue:`1370` PR: :issue:`1386`) - Add :confval:`m3u/default_encoding` and :confval:`m3u/default_extension` config values for improved text encoding support. - No longer scan playlist directory and parse playlists at startup or refresh. Similarly to the file extension, this now happens on request. - Use :class:`mopidy.models.Ref` instances when reading and writing playlists. Therefore, ``Track.length`` is no longer stored in extended M3U playlists and ``#EXTINF`` runtime is always set to -1. - Improve reliability of playlist updates using the core playlist API by applying the write-replace pattern for file updates. Stream backend -------------- - Make sure both lookup and playback correctly handle playlists and our blacklist support. (Fixes: :issue:`1445`, PR: :issue:`1447`) MPD frontend ------------ - Implemented commands for modifying stored playlists: - ``playlistadd`` - ``playlistclear`` - ``playlistdelete`` - ``playlistmove`` - ``rename`` - ``rm`` - ``save`` (Fixes: :issue:`1014`, PR: :issue:`1187`, :issue:`1308`, :issue:`1322`) - Start ``songid`` counting at 1 instead of 0 to match the original MPD server. - Idle events are now emitted on ``seeked`` events. This fix means that clients relying on ``idle`` events now get notified about seeks. (Fixes: :issue:`1331`, PR: :issue:`1347`) - Idle events are now emitted on ``playlists_loaded`` events. This fix means that clients relying on ``idle`` events now get notified about playlist loads. (Fixes: :issue:`1331`, PR: :issue:`1347`) - Event handler for ``playlist_deleted`` has been unbroken. This unreported bug would cause the MPD frontend to crash preventing any further communication via the MPD protocol. (PR: :issue:`1347`) Zeroconf -------- - Require ``stype`` argument to :class:`mopidy.zeroconf.Zeroconf`. - Use Avahi's interface selection by default. (Fixes: :issue:`1283`) - Use Avahi server's hostname instead of ``socket.getfqdn()`` in service display name. Cleanups -------- - Removed warning if :file:`~/.mopidy` exists. We stopped using this location in 0.6, released in October 2011. - Removed warning if :file:`~/.config/mopidy/settings.py` exists. We stopped using this settings file in 0.14, released in April 2013. - The ``on_event`` handler in our listener helper now catches exceptions. This means that any errors in event handling won't crash the actor in question. - Catch errors when loading :confval:`logging/config_file`. (Fixes: :issue:`1320`) - **Breaking:** Removed unused internal :class:`mopidy.internal.process.BaseThread`. This breaks Mopidy-Spotify 1.4.0. Versions < 1.4.0 was already broken by Mopidy 1.1, while versions >= 2.0 doesn't use this class. Audio ----- - **Breaking:** The audio scanner now returns ISO-8601 formatted strings instead of :class:`~datetime.datetime` objects for dates found in tags. Because of this change, we can now return years without months or days, which matches the semantics of the date fields in our data models. - **Breaking:** :meth:`mopidy.audio.Audio.set_appsrc`'s ``caps`` argument has changed format due to the upgrade from GStreamer 0.10 to GStreamer 1. As far as we know, this is only used by Mopidy-Spotify. As an example, with GStreamer 0.10 the Mopidy-Spotify caps was:: audio/x-raw-int, endianness=(int)1234, channels=(int)2, width=(int)16, depth=(int)16, signed=(boolean)true, rate=(int)44100 With GStreamer 1 this changes to:: audio/x-raw,format=S16LE,rate=44100,channels=2,layout=interleaved If your Mopidy backend uses ``set_appsrc()``, please refer to GStreamer documentation for details on the new caps string format. - **Breaking:** :func:`mopidy.audio.utils.create_buffer`'s ``capabilities`` argument is no longer in use and has been removed. As far as we know, this was only used by Mopidy-Spotify. - Duplicate seek events getting to ``appsrc`` based backends is now fixed. This should prevent seeking in Mopidy-Spotify from glitching. (Fixes: :issue:`1404`) - Workaround crash caused by a race that does not seem to affect functionality. This should be fixed properly together with :issue:`1222`. (Fixes: :issue:`1430`, PR: :issue:`1438`) - Add a new config option, :confval:`audio/buffer_time`, for setting the buffer time of the GStreamer queue. If you experience buffering before track changes, it may help to increase this. (Workaround for :issue:`1409`) - ``tags_changed`` events are only emitted for fields that have changed. Previous behavior was to emit this for all fields received from GStreamer. (PR: :issue:`1439`) Gapless ------- - Add partial support for gapless playback. Gapless now works as long as you don't change tracks or use next/previous. (PR: :issue:`1288`) The :ref:`streaming` docs has been updated with the workarounds still needed to properly stream Mopidy audio through Icecast. - Core playback has been refactored to better handle gapless, and async state changes. - Tests have been updated to always use a core actor so async state changes don't trip us up. - Seek events are now triggered when the seek completes. Previously the event was emitted when the seek was requested, not when it completed. Further changes have been made to make seek work correctly for gapless related corner cases. (Fixes: :issue:`1305` PR: :issue:`1346`) v1.1.2 (2016-01-18) =================== Bug fix release. - Main: Catch errors when loading the :confval:`logging/config_file` file. (Fixes: :issue:`1320`) - Core: If changing to another track while the player is paused, the new track would not be added to the history or marked as currently playing. (Fixes: :issue:`1352`, PR: :issue:`1356`) - Core: Skips over unplayable tracks if the user attempts to change tracks while paused, like we already did if in playing state. (Fixes :issue:`1378`, PR: :issue:`1379`) - Core: Make :meth:`~mopidy.core.LibraryController.lookup` ignore tracks with empty URIs. (Partly fixes: :issue:`1340`, PR: :issue:`1381`) - Core: Fix crash if backends emits events with wrong names or arguments. (Fixes: :issue:`1383`) - Stream: If an URI is considered playable, don't consider it as a candidate for playlist parsing. Just looking at MIME type prefixes isn't enough, as for example Ogg Vorbis has the MIME type ``application/ogg``. (Fixes: :issue:`1299`) - Local: If the scan or clear commands are used on a library that does not exist, exit with an error. (Fixes: :issue:`1298`) - MPD: Notify idling clients when a seek is performed. (Fixes: :issue:`1331`) - MPD: Don't return tracks with empty URIs. (Partly fixes: :issue:`1340`, PR: :issue:`1343`) - MPD: Add ``volume`` command that was reintroduced, though still as a deprecated command, in MPD 0.18 and is in use by some clients like mpc. (Fixes: :issue:`1393`, PR: :issue:`1397`) - Proxy: Handle case where :confval:`proxy/port` is either missing from config or set to an empty string. (PR: :issue:`1371`) v1.1.1 (2015-09-14) =================== Bug fix release. - Dependencies: Specify that we need Requests >= 2.0, not just any version. This ensures that we fail earlier if Mopidy is used with a too old Requests. - Core: Make :meth:`mopidy.core.LibraryController.refresh` work for all backends with a library provider. Previously, it wrongly worked for all backends with a playlists provider. (Fixes: :issue:`1257`) - Core: Respect :confval:`core/cache_dir` and :confval:`core/data_dir` config values added in 1.1.0 when creating the dirs Mopidy need to store data. This should not change the behavior for desktop users running Mopidy. When running Mopidy as a system service installed from a package which sets the core dir configs properly (e.g. Debian and Arch packages), this fix avoids the creation of a couple of directories that should not be used, typically :file:`/var/lib/mopidy/.local` and :file:`/var/lib/mopidy/.cache`. (Fixes: :issue:`1259`, PR: :issue:`1266`) - Core: Fix error in :meth:`~mopidy.core.TracklistController.get_eot_tlid` docstring. (Fixes: :issue:`1269`) - Audio: Add ``timeout`` parameter to :meth:`~mopidy.audio.scan.Scanner.scan`. (Part of: :issue:`1250`, PR: :issue:`1281`) - Extension support: Make :meth:`~mopidy.ext.Extension.get_cache_dir`, :meth:`~mopidy.ext.Extension.get_config_dir`, and :meth:`~mopidy.ext.Extension.get_data_dir` class methods, so they can be used without creating an instance of the :class:`~mopidy.ext.Extension` class. (Fixes: :issue:`1275`) - Local: Deprecate :confval:`local/data_dir` and respect :confval:`core/data_dir` instead. This does not change the defaults for desktop users, only system services installed from packages that properly set :confval:`core/data_dir`, like the Debian and Arch packages. (Fixes: :issue:`1259`, PR: :issue:`1266`) - Local: Change default value of :confval:`local/scan_flush_threshold` from 1000 to 100 to shorten the time Mopidy-Local-SQLite blocks incoming requests while scanning the local library. - M3U: Changed default for the :confval:`m3u/playlists_dir` from ``$XDG_DATA_DIR/mopidy/m3u`` to unset, which now means the extension's data dir. This does not change the defaults for desktop users, only system services installed from packages that properly set :confval:`core/data_dir`, like the Debian and Arch pakages. (Fixes: :issue:`1259`, PR: :issue:`1266`) - Stream: Expand nested playlists to find the stream URI. This used to work, but regressed in 1.1.0 with the extraction of stream playlist parsing from GStreamer to being handled by the Mopidy-Stream backend. (Fixes: :issue:`1250`, PR: :issue:`1281`) - Stream: If "file" is present in the :confval:`stream/protocols` config value and the :ref:`ext-file` extension is enabled, we exited with an error because two extensions claimed the same URI scheme. We now log a warning recommending to remove "file" from the :confval:`stream/protocols` config, and then proceed startup. (Fixes: :issue:`1248`, PR: :issue:`1254`) - Stream: Fix bug in new playlist parser. A non-ASCII char in an urilist comment would cause a crash while parsing due to comparision of a non-ASCII bytestring with a Unicode string. (Fixes: :issue:`1265`) - File: Adjust log levels when failing to expand ``$XDG_MUSIC_DIR`` into a real path. This usually happens when running Mopidy as a system service, and thus with a limited set of environment variables. (Fixes: :issue:`1249`, PR: :issue:`1255`) - File: When browsing files, we no longer scan the files to check if they're playable. This makes browsing of the file hierarchy instant for HTTP clients, which do no scanning of the files' metadata, and a bit faster for MPD clients, which no longer scan the files twice. (Fixes: :issue:`1260`, PR: :issue:`1261`) - File: Allow looking up metadata about any ``file://`` URI, just like we did in Mopidy 1.0.x, where Mopidy-Stream handled ``file://`` URIs. In Mopidy 1.1.0, Mopidy-File did not allow one to lookup files outside the directories listed in :confval:`file/media_dir`. This broke Mopidy-Local-SQLite when the :confval:`local/media_dir` directory was not within one of the :confval:`file/media_dirs` directories. For browsing of files, we still limit access to files inside the :confval:`file/media_dir` directories. For lookup, you can now read metadata for any file you know the path of. (Fixes: :issue:`1268`, PR: :issue:`1273`) - Audio: Fix timeout handling in scanner. This regression caused timeouts to expire before it should, causing scans to fail. - Audio: Update scanner to emit MIME type instead of an error when missing a plugin. v1.1.0 (2015-08-09) =================== Mopidy 1.1 is here! Since the release of 1.0, we've closed or merged approximately 65 issues and pull requests through about 400 commits by a record high 20 extraordinary people, including 14 newcomers. That's less issues and commits than in the 1.0 release, but even more contributors, and a doubling of the number of newcomers. Thanks to :ref:`everyone ` who has :ref:`contributed `, especially those that joined the sprint at EuroPython 2015 in Bilbao, Spain a couple of weeks ago! As we promised with the release of Mopidy 1.0, any extension working with Mopidy 1.0 should continue working with all Mopidy 1.x releases. However, this release brings a lot stronger enforcement of our documented APIs. If an extension doesn't use the APIs properly, it may no longer work. The advantage of this change is that Mopidy is now more robust against errors in extensions, and also provides vastly better error messages when extensions misbehave. This should make it easier to create quality extensions. The major features of Mopidy 1.1 are: - Validation of the arguments to all core API methods, as well as all responses from backends and all data model attributes. - New bundled backend, Mopidy-File. It is similar to Mopidy-Local, but allows you to browse and play music from local disk without running a scan to index the music first. The drawback is that it doesn't support searching. - The Mopidy-MPD server should now be up to date with the 0.19 version of the MPD protocol. Dependencies ------------ - Mopidy now requires Requests. - Heads up: Porting from GStreamer 0.10 to 1.x and support for running Mopidy with Python 3.4+ is not far off on our roadmap. Core API -------- - **Deprecated:** Calling the following methods with ``kwargs`` is being deprecated. (PR: :issue:`1090`) - :meth:`mopidy.core.LibraryController.search` - :meth:`mopidy.core.PlaylistsController.filter` - :meth:`mopidy.core.TracklistController.filter` - :meth:`mopidy.core.TracklistController.remove` - Updated core controllers to handle backend exceptions in all calls that rely on multiple backends. (Issue: :issue:`667`) - Update core methods to do strict input checking. (Fixes: :issue:`700`) - Add ``tlid`` alternatives to methods that take ``tl_track`` and also add ``get_{eot,next,previous}_tlid`` methods as light weight alternatives to the ``tl_track`` versions of the calls. (Fixes: :issue:`1131`, PR: :issue:`1136`, :issue:`1140`) - Add :meth:`mopidy.core.PlaybackController.get_current_tlid`. (Part of: :issue:`1137`) - Update core to handle backend crashes and bad data. (Fixes: :issue:`1161`) - Add :confval:`core/max_tracklist_length` config and limitation. (Fixes: :issue:`997` PR: :issue:`1225`) - Added ``playlist_deleted`` event. (Fixes: :issue:`996`) Models ------ - Added type checks and other sanity checks to model construction and serialization. (Fixes: :issue:`865`) - Memory usage for models has been greatly improved. We now have a lower overhead per instance by using slots, interned identifiers and automatically reuse instances. For the test data set this was developed against, a library of ~14.000 tracks, went from needing ~75MB to ~17MB. (Fixes: :issue:`348`) - Added :attr:`mopidy.models.Artist.sortname` field that is mapped to ``musicbrainz-sortname`` tag. (Fixes: :issue:`940`) Configuration ------------- - Add new configurations to set base directories to be used by Mopidy and Mopidy extensions: :confval:`core/cache_dir`, :confval:`core/config_dir`, and :confval:`core/data_dir`. (Fixes: :issue:`843`, PR: :issue:`1232`) Extension support ----------------- - Add new methods to :class:`~mopidy.ext.Extension` class for getting cache, config and data directories specific to your extension: - :meth:`mopidy.ext.Extension.get_cache_dir` - :meth:`mopidy.ext.Extension.get_config_dir` - :meth:`mopidy.ext.Extension.get_data_dir` Extensions should use these methods so that the correct directories are used both when Mopidy is run by a regular user and when run as a system service. (Fixes: :issue:`843`, PR: :issue:`1232`) - Add :func:`mopidy.httpclient.format_proxy` and :func:`mopidy.httpclient.format_user_agent`. (Part of: :issue:`1156`) - It is now possible to import :mod:`mopidy.backends` without having GObject or GStreamer installed. In other words, a lot of backend extensions should now be able to run tests in a virtualenv with global site-packages disabled. This removes a lot of potential error sources. (Fixes: :issue:`1068`, PR: :issue:`1115`) Local backend ------------- - Filter out :class:`None` from :meth:`~mopidy.backend.LibraryProvider.get_distinct` results. All returned results should be strings. (Fixes: :issue:`1202`) Stream backend -------------- - Move stream playlist parsing from GStreamer to the stream backend. (Fixes: :issue:`671`) File backend ------------ The :ref:`Mopidy-File ` backend is a new bundled backend. It is similar to Mopidy-Local since it works with local files, but it differs in a few key ways: - Mopidy-File lets you browse your media files by their file hierarchy. - It supports multiple media directories, all exposed under the "Files" directory when you browse your library with e.g. an MPD client. - There is no index of the media files, like the JSON or SQLite files used by Mopidy-Local. Thus no need to scan the music collection before starting Mopidy. Everything is read from the file system when needed and changes to the file system is thus immediately visible in Mopidy clients. - Because there is no index, there is no support for search. Our long term plan is to keep this very simple file backend in Mopidy, as it has a well defined and limited scope, while splitting the more feature rich Mopidy-Local extension out to an independent project. (Fixes: :issue:`1004`, PR: :issue:`1207`) M3U backend ----------- - Support loading UTF-8 encoded M3U files with the ``.m3u8`` file extension. (PR: :issue:`1193`) MPD frontend ------------ - The MPD command ``count`` now ignores tracks with no length, which would previously cause a :exc:`TypeError`. (PR: :issue:`1192`) - Concatenate multiple artists, composers and performers using the "A;B" format instead of "A, B". This is a part of updating our protocol implementation to match MPD 0.19. (PR: :issue:`1213`) - Add "not implemented" skeletons of new commands in the MPD protocol version 0.19: - Current playlist: - ``rangeid`` - ``addtagid`` - ``cleartagid`` - Mounts and neighbors: - ``mount`` - ``unmount`` - ``listmounts`` - ``listneighbors`` - Music DB: - ``listfiles`` - Track data now include the ``Last-Modified`` field if set on the track model. (Fixes: :issue:`1218`, PR: :issue:`1219`) - Implement ``tagtypes`` MPD command. (PR: :issue:`1235`) - Exclude empty tags fields from metadata output. (Fixes: :issue:`1045`, PR: :issue:`1235`) - Implement protocol extensions to output Album URIs and Album Images when outputting track data to clients. (PR: :issue:`1230`) - The MPD commands ``lsinfo`` and ``listplaylists`` are now implemented using the :meth:`~mopidy.core.PlaylistsController.as_list` method, which retrieves a lot less data and is thus much faster than the deprecated :meth:`~mopidy.core.PlaylistsController.get_playlists`. The drawback is that the ``Last-Modified`` timestamp is not available through this method, and the timestamps in the MPD command responses are now always set to the current time. Internal changes ---------------- - Tests have been cleaned up to stop using deprecated APIs where feasible. (Partial fix: :issue:`1083`, PR: :issue:`1090`) v1.0.8 (2015-07-22) =================== Bug fix release. - Fix reversal of ``Title`` and ``Name`` in MPD protocol (Fixes: :issue:`1212` PR: :issue:`1214`) - Fix crash if an M3U file in the :confval:`m3u/playlist_dir` directory has a file name not decodable with the current file system encoding. (Fixes: :issue:`1209`) v1.0.7 (2015-06-26) =================== Bug fix release. - Fix error in the MPD command ``list title ...``. The error was introduced in v1.0.6. v1.0.6 (2015-06-25) =================== Bug fix release. - Core/MPD/Local: Add support for ``title`` in :meth:`mopidy.core.LibraryController.get_distinct`. (Fixes: :issue:`1181`, PR: :issue:`1183`) - Core: Make sure track changes make it to audio while paused. (Fixes: :issue:`1177`, PR: :issue:`1185`) v1.0.5 (2015-05-19) =================== Bug fix release. - Core: Add workaround for playlist providers that do not support creating playlists. (Fixes: :issue:`1162`, PR :issue:`1165`) - M3U: Fix encoding error when saving playlists with non-ASCII track titles. (Fixes: :issue:`1175`, PR :issue:`1176`) v1.0.4 (2015-04-30) =================== Bug fix release. - Audio: Since all previous attempts at tweaking the queuing for :issue:`1097` seems to break things in subtle ways for different users. We are giving up on tweaking the defaults and just going to live with a bit more lag on software volume changes. (Fixes: :issue:`1147`) v1.0.3 (2015-04-28) =================== Bug fix release. - HTTP: Another follow-up to the Tornado <3.0 fixing. Since the tests aren't run for Tornado 2.3 we didn't catch that our previous fix wasn't sufficient. (Fixes: :issue:`1153`, PR: :issue:`1154`) - Audio: Follow-up fix for :issue:`1097` still exhibits issues for certain setups. We are giving this get an other go by setting the buffer size to maximum 100ms instead of a fixed number of buffers. (Addresses: :issue:`1147`, PR: :issue:`1154`) v1.0.2 (2015-04-27) =================== Bug fix release. - HTTP: Make event broadcasts work with Tornado 2.3 again. The threading fix in v1.0.1 broke this. - Audio: Fix for :issue:`1097` tuned down the buffer size in the queue. Turns out this can cause distortions in certain cases. Give this an other go with a more generous buffer size. (Addresses: :issue:`1147`, PR: :issue:`1152`) - Audio: Make sure mute events get emitted by software mixer. (Fixes: :issue:`1146`, PR: :issue:`1152`) v1.0.1 (2015-04-23) =================== Bug fix release. - Core: Make the new history controller available for use. (Fixes: :js:`6`) - Audio: Software volume control has been reworked to greatly reduce the delay between changing the volume and the change taking effect. (Fixes: :issue:`1097`, PR: :issue:`1101`) - Audio: As a side effect of the previous bug fix, software volume is no longer tied to the PulseAudio application volume when using ``pulsesink``. This behavior was confusing for many users and doesn't work well with the plans for multiple outputs. - Audio: Update scanner to decode all media it finds. This should fix cases where the scanner hangs on non-audio files like video. The scanner will now also let us know if we found any decodeable audio. (Fixes: :issue:`726`, PR: issue:`1124`) - HTTP: Fix threading bug that would cause duplicate delivery of WS messages. (PR: :issue:`1127`) - MPD: Fix case where a playlist that is present in both browse and as a listed playlist breaks the MPD frontend protocol output. (Fixes :issue:`1120`, PR: :issue:`1142`) v1.0.0 (2015-03-25) =================== Three months after our fifth anniversary, Mopidy 1.0 is finally here! Since the release of 0.19, we've closed or merged approximately 140 issues and pull requests through more than 600 commits by a record high 19 extraordinary people, including seven newcomers. Thanks to :ref:`everyone ` who has :ref:`contributed `! For the longest time, the focus of Mopidy 1.0 was to be another incremental improvement, to be numbered 0.20. The result is still very much an incremental improvement, with lots of small and larger improvements across Mopidy's functionality. The major features of Mopidy 1.0 are: - :ref:`Semantic Versioning `. We promise to not break APIs before Mopidy 2.0. A Mopidy extension working with Mopidy 1.0 should continue to work with all Mopidy 1.x releases. - Preparation work to ease migration to a cleaned up and leaner core API in Mopidy 2.0, and to give us some of the benefits of the cleaned up core API right away. - Preparation work to enable gapless playback in an upcoming 1.x release. Dependencies ------------ Since the previous release there are no changes to Mopidy's dependencies. However, porting from GStreamer 0.10 to 1.x and support for running Mopidy with Python 3.4+ is not far off on our roadmap. Core API -------- In the API used by all frontends and web extensions there is lots of methods and arguments that are now deprecated in preparation for the next major release. With the exception of some internals that leaked out in the playback controller, no core APIs have been removed in this release. In other words, most clients should continue to work unchanged when upgrading to Mopidy 1.0. Though, it is strongly encouraged to review any use of the deprecated parts of the API as those parts will be removed in Mopidy 2.0. - **Deprecated:** Deprecate all Python properties in the core API. The previously undocumented getter and setter methods are now the official API. This aligns the Python API with the WebSocket/JavaScript API. Python frontends needs to be updated. WebSocket/JavaScript API users are not affected. (Fixes: :issue:`952`) - Add :class:`mopidy.core.HistoryController` which keeps track of what tracks have been played. (Fixes: :issue:`423`, :issue:`1056`, PR: :issue:`803`, :issue:`1063`) - Add :class:`mopidy.core.MixerController` which keeps track of volume and mute. (Fixes: :issue:`962`) Core library controller ~~~~~~~~~~~~~~~~~~~~~~~ - **Deprecated:** :meth:`mopidy.core.LibraryController.find_exact`. Use :meth:`mopidy.core.LibraryController.search` with the ``exact`` keyword argument set to :class:`True`. - **Deprecated:** The ``uri`` argument to :meth:`mopidy.core.LibraryController.lookup`. Use new ``uris`` keyword argument instead. - Add ``exact`` keyword argument to :meth:`mopidy.core.LibraryController.search`. - Add ``uris`` keyword argument to :meth:`mopidy.core.LibraryController.lookup` which allows for simpler lookup of multiple URIs. (Fixes: :issue:`1008`, PR: :issue:`1047`) - Updated :meth:`mopidy.core.LibraryController.search` and :meth:`mopidy.core.LibraryController.find_exact` to normalize and warn about malformed queries from clients. (Fixes: :issue:`1067`, PR: :issue:`1073`) - Add :meth:`mopidy.core.LibraryController.get_distinct` for getting unique values for a given field. (Fixes: :issue:`913`, PR: :issue:`1022`) - Add :meth:`mopidy.core.LibraryController.get_images` for looking up images for any URI that is known to the backends. (Fixes :issue:`973`, PR: :issue:`981`, :issue:`992` and :issue:`1013`) Core playlist controller ~~~~~~~~~~~~~~~~~~~~~~~~ - **Deprecated:** :meth:`mopidy.core.PlaylistsController.get_playlists`. Use :meth:`~mopidy.core.PlaylistsController.as_list` and :meth:`~mopidy.core.PlaylistsController.get_items` instead. (Fixes: :issue:`1057`, PR: :issue:`1075`) - **Deprecated:** :meth:`mopidy.core.PlaylistsController.filter`. Use :meth:`~mopidy.core.PlaylistsController.as_list` and filter yourself. - Add :meth:`mopidy.core.PlaylistsController.as_list`. (Fixes: :issue:`1057`, PR: :issue:`1075`) - Add :meth:`mopidy.core.PlaylistsController.get_items`. (Fixes: :issue:`1057`, PR: :issue:`1075`) Core tracklist controller ~~~~~~~~~~~~~~~~~~~~~~~~~ - **Removed:** The following methods were documented as internal. They are now fully private and unavailable outside the core actor. (Fixes: :issue:`1058`, PR: :issue:`1062`) - :meth:`mopidy.core.TracklistController.mark_played` - :meth:`mopidy.core.TracklistController.mark_playing` - :meth:`mopidy.core.TracklistController.mark_unplayable` - Add ``uris`` argument to :meth:`mopidy.core.TracklistController.add` which allows for simpler addition of multiple URIs to the tracklist. (Fixes: :issue:`1060`, PR: :issue:`1065`) Core playback controller ~~~~~~~~~~~~~~~~~~~~~~~~ - **Removed:** Remove several internal parts that were leaking into the public API and was never intended to be used externally. (Fixes: :issue:`1070`, PR: :issue:`1076`) - :meth:`mopidy.core.PlaybackController.change_track` is now internal. - Removed ``on_error_step`` keyword argument from :meth:`mopidy.core.PlaybackController.play` - Removed ``clear_current_track`` keyword argument to :meth:`mopidy.core.PlaybackController.stop`. - Made the following event triggers internal: - :meth:`mopidy.core.PlaybackController.on_end_of_track` - :meth:`mopidy.core.PlaybackController.on_stream_changed` - :meth:`mopidy.core.PlaybackController.on_tracklist_changed` - :meth:`mopidy.core.PlaybackController.set_current_tl_track` is now internal. - **Deprecated:** The old methods on :class:`mopidy.core.PlaybackController` for volume and mute management have been deprecated. Use :class:`mopidy.core.MixerController` instead. (Fixes: :issue:`962`) - When seeking while paused, we no longer change to playing. (Fixes: :issue:`939`, PR: :issue:`1018`) - Changed :meth:`mopidy.core.PlaybackController.play` to take the return value from :meth:`mopidy.backend.PlaybackProvider.change_track` into account when determining the success of the :meth:`~mopidy.core.PlaybackController.play` call. (PR: :issue:`1071`) - Add :meth:`mopidy.core.Listener.stream_title_changed` and :meth:`mopidy.core.PlaybackController.get_stream_title` for letting clients know about the current title in streams. (PR: :issue:`938`, :issue:`1030`) Backend API ----------- In the API implemented by all backends there have been way fewer but somewhat more drastic changes with some methods removed and new ones being required for certain functionality to continue working. Most backends were already updated to be compatible with Mopidy 1.0 before the release. New versions of the backends will be released shortly after Mopidy itself. Backend library providers ~~~~~~~~~~~~~~~~~~~~~~~~~ - **Removed:** Remove :meth:`mopidy.backend.LibraryProvider.find_exact`. - Add an ``exact`` keyword argument to :meth:`mopidy.backend.LibraryProvider.search` to replace the old :meth:`~mopidy.backend.LibraryProvider.find_exact` method. Backend playlist providers ~~~~~~~~~~~~~~~~~~~~~~~~~~ - **Removed:** Remove default implementation of :attr:`mopidy.backend.PlaylistsProvider.playlists`. This is potentially backwards incompatible. (PR: :issue:`1046`) - Changed the API for :class:`mopidy.backend.PlaylistsProvider`. Note that this change is **not** backwards compatible. These changes are important to reduce the Mopidy startup time. (Fixes: :issue:`1057`, PR: :issue:`1075`) - Add :meth:`mopidy.backend.PlaylistsProvider.as_list`. - Add :meth:`mopidy.backend.PlaylistsProvider.get_items`. - Remove :attr:`mopidy.backend.PlaylistsProvider.playlists` property. Backend playback providers ~~~~~~~~~~~~~~~~~~~~~~~~~~ - Changed the API for :class:`mopidy.backend.PlaybackProvider`. Note that this change is **not** backwards compatible for certain backends. These changes are crucial to adding gapless in one of the upcoming releases. (Fixes: :issue:`1052`, PR: :issue:`1064`) - :meth:`mopidy.backend.PlaybackProvider.translate_uri` has been added. It is strongly recommended that all backends migrate to using this API for translating "Mopidy URIs" to real ones for playback. - The semantics and signature of :meth:`mopidy.backend.PlaybackProvider.play` has changed. The method is now only used to set the playback state to playing, and no longer takes a track. Backends must migrate to :meth:`mopidy.backend.PlaybackProvider.translate_uri` or :meth:`mopidy.backend.PlaybackProvider.change_track` to continue working. - :meth:`mopidy.backend.PlaybackProvider.prepare_change` has been added. Models ------ - Add :class:`mopidy.models.Image` model to be returned by :meth:`mopidy.core.LibraryController.get_images`. (Part of :issue:`973`) - Change the semantics of :attr:`mopidy.models.Track.last_modified` to be milliseconds instead of seconds since Unix epoch, or a simple counter, depending on the source of the track. This makes it match the semantics of :attr:`mopidy.models.Playlist.last_modified`. (Fixes: :issue:`678`, PR: :issue:`1036`) Commands -------- - Make the ``mopidy`` command print a friendly error message if the :mod:`gobject` Python module cannot be imported. (Fixes: :issue:`836`) - Add support for repeating the :option:`-v ` argument four times to set the log level for all loggers to the lowest possible value, including log records at levels lower than ``DEBUG`` too. - Add path to the current ``mopidy`` executable to the output of ``mopidy deps``. This make it easier to see that a user is using pip-installed Mopidy instead of APT-installed Mopidy without asking for ``which mopidy`` output. Configuration ------------- - Add support for the log level value ``all`` to the loglevels configurations. This can be used to show absolutely all log records, including those at custom levels below ``DEBUG``. - Add debug logging of unknown sections. (Fixes: :issue:`694`, PR: :issue:`1002`) Logging ------- - Add custom log level ``TRACE`` (numerical level 5), which can be used by Mopidy and extensions to log at an even more detailed level than ``DEBUG``. - Add support for per logger color overrides. (Fixes: :issue:`808`, PR: :issue:`1005`) Local backend ------------- - Improve error logging for scanner. (Fixes: :issue:`856`, PR: :issue:`874`) - Add symlink support with loop protection to file finder. (Fixes: :issue:`858`, PR: :issue:`874`) - Add ``--force`` option for ``mopidy local scan`` for forcing a full rescan of the library. (Fixes: :issue:`910`, PR: :issue:`1010`) - Stop ignoring ``offset`` and ``limit`` in searches when using the default JSON backed local library. (Fixes: :issue:`917`, PR: :issue:`949`) - Removed double triggering of ``playlists_loaded`` event. (Fixes: :issue:`998`, PR: :issue:`999`) - Cleanup and refactoring of local playlist code. Preserves playlist names better and fixes bug in deletion of playlists. (Fixes: :issue:`937`, PR: :issue:`995` and rebased into :issue:`1000`) - Sort local playlists by name. (Fixes: :issue:`1026`, PR: :issue:`1028`) - Moved playlist support out to a new extension, :ref:`ext-m3u`. - *Deprecated:* The config value :confval:`local/playlists_dir` is no longer in use and can be removed from your config. Local library API ~~~~~~~~~~~~~~~~~ - Implementors of :meth:`mopidy.local.Library.lookup` should now return a list of :class:`~mopidy.models.Track` instead of a single track, just like the other ``lookup()`` methods in Mopidy. For now, returning a single track will continue to work. (PR: :issue:`840`) - Add support for giving local libraries direct access to tags and duration. (Fixes: :issue:`967`) - Add :meth:`mopidy.local.Library.get_images` for looking up images for local URIs. (Fixes: :issue:`1031`, PR: :issue:`1032` and :issue:`1037`) Stream backend -------------- - Add support for HTTP proxies when doing initial metadata lookup for a stream. (Fixes :issue:`390`, PR: :issue:`982`) - Add basic tests for the stream library provider. M3U backend ----------- - Mopidy-M3U is a new bundled backend. It provides the same M3U support as was previously part of the local backend. See :ref:`m3u-migration` for how to migrate your local playlists to work with the M3U backend. (Fixes: :issue:`1054`, PR: :issue:`1066`) - In playlist names, replace "/", which are illegal in M3U file names, with "|". (PR: :issue:`1084`) MPD frontend ------------ - Add support for blacklisting MPD commands. This is used to prevent clients from using ``listall`` and ``listallinfo`` which recursively lookup the entire "database". If you insist on using a client that needs these commands change :confval:`mpd/command_blacklist`. - Start setting the ``Name`` field with the stream title when listening to radio streams. (Fixes: :issue:`944`, PR: :issue:`1030`) - Enable browsing of artist references, in addition to albums and playlists. (PR: :issue:`884`) - Switch the ``list`` command over to using the new method :meth:`mopidy.core.LibraryController.get_distinct` for increased performance. (Fixes: :issue:`913`) - In stored playlist names, replace "/", which are illegal, with "|" instead of a whitespace. Pipes are more similar to forward slash. - Share a single mapping between names and URIs across all MPD sessions. (Fixes: :issue:`934`, PR: :issue:`968`) - Add support for ``toggleoutput`` command. (PR: :issue:`1015`) - The ``mixrampdb`` and ``mixrampdelay`` commands are now known to Mopidy, but are not implemented. (PR: :issue:`1015`) - Fix crash on socket error when using a locale causing the exception's error message to contain characters not in ASCII. (Fixes: issue:`971`, PR: :issue:`1044`) HTTP frontend ------------- - **Deprecated:** Deprecated the :confval:`http/static_dir` config. Please make your web clients pip-installable Mopidy extensions to make it easier to install for end users. - Prevent a race condition in WebSocket event broadcasting from crashing the web server. (PR: :issue:`1020`) Mixers ------ - Add support for disabling volume control in Mopidy entirely by setting the configuration :confval:`audio/mixer` to ``none``. (Fixes: :issue:`936`, PR: :issue:`1015`, :issue:`1035`) Audio ----- - **Removed:** Support for visualizers and the :confval:`audio/visualizer` config value. The 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 did never make sense for a server such as Mopidy. - **Deprecated:** Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. Pass a :class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` to end the stream. This should only affect Mopidy-Spotify. - 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. - Internal code cleanup within audio subsystem: - Started splitting audio code into smaller better defined pieces. - Improved GStreamer related debug logging. - Provide better error messages for missing plugins. - Add foundation for trying to re-add multiple output support. - Add internal helper for converting GStreamer data types to Python. - Reduce scope of audio scanner to just find tags and duration. Modification time, URI and minimum length handling are now outside of this class. - Update scanner to operate with milliseconds for duration. - Update scanner to use a custom source, typefind and decodebin. This allows us to detect playlists before we try to decode them. - Refactored scanner to create a new pipeline per track, this is needed as reseting decodebin is much slower than tearing it down and making a fresh one. - Move and rename helper for converting tags to tracks. - Ignore albums without a name when converting tags to tracks. - Support UTF-8 in M3U playlists. (Fixes: :issue:`853`) - Add workaround for volume not persisting across tracks on OS X. (Issue: :issue:`886`, PR: :issue:`958`) - Improved missing plugin error reporting in scanner. (PR: :issue:`1033`) - Introduced a new return type for the scanner, a named tuple with ``uri``, ``tags``, ``duration``, ``seekable`` and ``mime``. (PR: :issue:`1033`) - Added support for checking if the media is seekable, and getting the initial MIME type guess. (PR: :issue:`1033`) Mopidy.js client library ------------------------ This version has been released to npm as Mopidy.js v0.5.0. - Reexport When.js library as ``Mopidy.when``, to make it easily available to users of Mopidy.js. (Fixes: :js:`1`) - Default to ``wss://`` as the WebSocket protocol if the page is hosted on ``https://``. This has no effect if the ``webSocketUrl`` setting is specified. (Pull request: :js:`2`) - Upgrade dependencies. Development ----------- - Add new :ref:`contribution guidelines `. - Add new :ref:`development guide `. - Speed up event emitting. - Changed test runner from nose to py.test. (PR: :issue:`1024`) v0.19.5 (2014-12-23) ==================== Today is Mopidy's five year anniversary. We're celebrating with a bugfix release and are looking forward to the next five years! - Config: Support UTF-8 in extension's default config. If an extension with non-ASCII characters in its default config was installed, and Mopidy didn't already have a config file, Mopidy would crashed when trying to create the initial config file based on the default config of all available extensions. (Fixes: :discuss:`428`) - Extensions: Fix crash when unpacking data from :exc:`pkg_resources.VersionConflict` created with a single argument. (Fixes: :issue:`911`) - Models: Hide empty collections from :func:`repr()` representations. - Models: Field values are no longer stored on the model instance when the value matches the default value for the field. This makes two models equal when they have a field which in one case is implicitly set to the default value and in the other case explicitly set to the default value, but with otherwise equal fields. (Fixes: :issue:`837`) - Models: Changed the default value of :attr:`mopidy.models.Album.num_tracks`, :attr:`mopidy.models.Track.track_no`, and :attr:`mopidy.models.Track.last_modified` from ``0`` to :class:`None`. - Core: When skipping to the next track in consume mode, remove the skipped track from the tracklist. This is consistent with the original MPD server's behavior. (Fixes: :issue:`902`) - Local: Fix scanning of modified files. (PR: :issue:`904`) - MPD: Re-enable browsing of empty directories. (PR: :issue:`906`) - MPD: Remove track comments from responses. They are not included by the original MPD server, and this works around :issue:`881`. (PR: :issue:`882`) - HTTP: Errors while starting HTTP apps are logged instead of crashing the HTTP server. (Fixes: :issue:`875`) v0.19.4 (2014-09-01) ==================== Bug fix release. - Configuration: :option:`mopidy --config` now supports directories. - Logging: Fix that some loggers would be disabled if :confval:`logging/config_file` was set. (Fixes: :issue:`740`) - Quit process with exit code 1 when stopping because of a backend, frontend, or mixer initialization error. - Backend API: Update :meth:`mopidy.backend.LibraryProvider.browse` signature and docs to match how the core use the backend's browse method. (Fixes: :issue:`833`) - Local library API: Add :attr:`mopidy.local.Library.ROOT_DIRECTORY_URI` constant for use by implementors of :meth:`mopidy.local.Library.browse`. (Related to: :issue:`833`) - HTTP frontend: Guard against double close of WebSocket, which causes an :exc:`AttributeError` on Tornado < 3.2. - MPD frontend: Make the ``list`` command return albums when sending 3 arguments. This was incorrectly returning artists after the MPD command changes in 0.19.0. (Fixes: :issue:`817`) - MPD frontend: Fix a race condition where two threads could try to free the same data simultaneously. (Fixes: :issue:`781`) v0.19.3 (2014-08-03) ==================== Bug fix release. - Audio: Fix negative track length for radio streams. (Fixes: :issue:`662`, PR: :issue:`796`) - Audio: Tell GStreamer to not pick Jack sink. (Fixes: :issue:`604`) - Zeroconf: Fix discovery by adding ``.local`` to the announced hostname. (PR: :issue:`795`) - Zeroconf: Fix intermittent DBus/Avahi exception. - Extensions: Fail early if trying to setup an extension which doesn't implement the :meth:`mopidy.ext.Extension.setup` method. (Fixes: :issue:`813`) v0.19.2 (2014-07-26) ==================== Bug fix release, directly from the Mopidy development sprint at EuroPython 2014 in Berlin. - Audio: Make :confval:`audio/mixer_volume` work on the software mixer again. This was broken with the mixer changes in 0.19.0. (Fixes: :issue:`791`) - HTTP frontend: When using Tornado 4.0, allow WebSocket requests from other hosts. (Fixes: :issue:`788`) - MPD frontend: Fix crash when MPD commands are called with the wrong number of arguments. This was broken with the MPD command changes in 0.19.0. (Fixes: :issue:`789`) v0.19.1 (2014-07-23) ==================== Bug fix release. - Dependencies: Mopidy now requires Tornado >= 2.3, instead of >= 3.1. This should make Mopidy continue to work on Debian/Raspbian stable, where Tornado 2.3 is the newest version available. - HTTP frontend: Add missing string interpolation placeholder. - Development: ``mopidy --version`` and :meth:`mopidy.core.Core.get_version` now returns the correct version when Mopidy is run from a Git repo other than Mopidy's own. (Related to :issue:`706`) v0.19.0 (2014-07-21) ==================== The focus of 0.19 have been on improving the MPD implementation, replacing GStreamer mixers with our own mixer API, and on making web clients installable with ``pip``, like any other Mopidy extension. Since the release of 0.18, we've closed or merged 53 issues and pull requests through about 445 commits by :ref:`12 people `, including five new guys. Thanks to everyone that has contributed! **Dependencies** - Mopidy now requires Tornado >= 3.1. - Mopidy no longer requires CherryPy or ws4py. Previously, these were optional dependencies required for the HTTP frontend to work. **Backend API** - *Breaking change:* Imports of the backend API from :mod:`mopidy.backends` no longer works. The new API introuced in v0.18 is now required. Most extensions already use the new API location. **Commands** - The ``mopidy-convert-config`` tool for migrating the ``setings.py`` configuration file used by Mopidy up until 0.14 to the new config file format has been removed after over a year of trusty service. If you still need to convert your old ``settings.py`` configuration file, do so using and older release, like Mopidy 0.18, or migrate the configuration to the new format by hand. **Configuration** - Add ``optional=True`` support to :class:`mopidy.config.Boolean`. **Logging** - Fix proper decoding of exception messages that depends on the user's locale. - Colorize logs depending on log level. This can be turned off with the new :confval:`logging/color` configuration. (Fixes: :issue:`772`) **Extension support** - *Breaking change:* Removed the :class:`~mopidy.ext.Extension` methods that were deprecated in 0.18: :meth:`~mopidy.ext.Extension.get_backend_classes`, :meth:`~mopidy.ext.Extension.get_frontend_classes`, and :meth:`~mopidy.ext.Extension.register_gstreamer_elements`. Use :meth:`mopidy.ext.Extension.setup` instead, as most extensions already do. **Audio** - *Breaking change:* Removed support for GStreamer mixers. GStreamer 1.x does not support volume control, so we changed to use software mixing by default in v0.17.0. Now, we're removing support for all other GStreamer mixers and are reintroducing mixers as something extensions can provide independently of GStreamer. (Fixes: :issue:`665`, PR: :issue:`760`) - *Breaking change:* Changed the :confval:`audio/mixer` config value to refer to Mopidy mixer extensions instead of GStreamer mixers. The default value, ``software``, still has the same behavior. All other values will either no longer work or will at the very least require you to install an additional extension. - Changed the :confval:`audio/mixer_volume` config value behavior from affecting GStreamer mixers to affecting Mopidy mixer extensions instead. The end result should be the same without any changes to this config value. - Deprecated the :confval:`audio/mixer_track` config value. This config value is no longer in use. Mixer extensions that need additional configuration handle this themselves. - Use :ref:`proxy-config` when streaming media from the Internet. (Partly fixing :issue:`390`) - Fix proper decoding of exception messages that depends on the user's locale. - Fix recognition of ASX and XSPF playlists with tags in all caps or with carriage return line endings. (Fixes: :issue:`687`) - Support simpler ASX playlist variant with ```` elements without children. - Added ``target_state`` attribute to the audio layer's :meth:`~mopidy.audio.AudioListener.state_changed` event. Currently, it is :class:`None` except when we're paused because of buffering. Then the new field exposes our target state after buffering has completed. **Mixers** - Added new :class:`mopidy.mixer.Mixer` API which can be implemented by extensions. - Created a bundled extension, :ref:`ext-softwaremixer`, for controlling volume in software in GStreamer's pipeline. This is Mopidy's default mixer. To use this mixer, set the :confval:`audio/mixer` config value to ``software``. - Created an external extension, `Mopidy-ALSAMixer `_, for controlling volume with hardware through ALSA. To use this mixer, install the extension, and set the :confval:`audio/mixer` config value to ``alsamixer``. **HTTP frontend** - CherryPy and ws4py have been replaced with Tornado. This will hopefully reduce CPU usage on OS X (:issue:`445`) and improve error handling in corner cases, like when returning from suspend (:issue:`718`). - Added support for packaging web clients as Mopidy extensions and installing them using pip. See the :ref:`http-server-api` for details. (Fixes: :issue:`440`) - Added web page at ``/mopidy/`` which lists all web clients installed as Mopidy extensions. (Fixes: :issue:`440`) - Added support for extending the HTTP frontend with additional server side functionality. See :ref:`http-server-api` for details. - Exposed the core API using HTTP POST requests with JSON-RPC payloads at ``/mopidy/rpc``. This is the same JSON-RPC interface as is exposed over the WebSocket at ``/mopidy/ws``, so you can run any core API command. The HTTP POST interfaces does not give you access to events from Mopidy, like the WebSocket does. The WebSocket interface is still recommended for web clients. The HTTP POST interface may be easier to use for simpler programs, that just needs to query the currently playing track or similar. See :ref:`http-post-api` for details. - If Zeroconf is enabled, we now announce the ``_mopidy-http._tcp`` service in addition to ``_http._tcp``. This is to make it easier to automatically find Mopidy's HTTP server among other Zeroconf-published HTTP servers on the local network. **Mopidy.js client library** This version has been released to npm as Mopidy.js v0.4.0. - Update Mopidy.js to use when.js 3. If you maintain a Mopidy client, you should review the `differences between when.js 2 and 3 `_ and the `when.js debugging guide `_. - All of Mopidy.js' promise rejection values are now of the Error type. This ensures that all JavaScript VMs will show a useful stack trace if a rejected promise's value is used to throw an exception. To allow catch clauses to handle different errors differently, server side errors are of the type ``Mopidy.ServerError``, and connection related errors are of the type ``Mopidy.ConnectionError``. - Add support for method calls with by-name arguments. The old calling convention, ``by-position-only``, is still the default, but this will change in the future. A warning is logged to the console if you don't explicitly select a calling convention. See the :ref:`mopidy-js` docs for details. **MPD frontend** - Proper command tokenization for MPD requests. This replaces the old regex based system with an MPD protocol specific tokenizer responsible for breaking requests into pieces before the handlers have at them. (Fixes: :issue:`591` and :issue:`592`) - Updated command handler system. As part of the tokenizer cleanup we've updated how commands are registered and making it simpler to create new handlers. - Simplified a bunch of handlers. All the "browse" type commands now use a common browse helper under the hood for less repetition. Likewise the query handling of "search" commands has been somewhat simplified. - Adds placeholders for missing MPD commands, preparing the way for bumping the protocol version once they have been added. - Respond to all pending requests before closing connection. (PR: :issue:`722`) - Stop incorrectly catching `LookupError` in command handling. (Fixes: :issue:`741`) - Browse support for playlists and albums has been added. (PR: :issue:`749`, :issue:`754`) - The ``lsinfo`` command now returns browse results before local playlists. This is helpful as not all clients sort the returned items. (PR: :issue:`755`) - Browse now supports different entries with identical names. (PR: :issue:`762`) - Search terms that are empty or consists of only whitespace are no longer included in the search query sent to backends. (PR: :issue:`758`) **Local backend** - The JSON local library backend now logs a friendly message telling you about ``mopidy local scan`` if you don't have a local library cache. (Fixes: :issue:`711`) - The ``local scan`` command now use multiple threads to walk the file system and check files' modification time. This speeds up scanning, escpecially when scanning remote file systems over e.g. NFS. - the ``local scan`` command now creates necessary folders if they don't already exist. Previously, this was only done by the Mopidy server, so doing a ``local scan`` before running the server the first time resulted in a crash. (Fixes: :issue:`703`) - Fix proper decoding of exception messages that depends on the user's locale. **Stream backend** - Add config value :confval:`stream/metadata_blacklist` to blacklist certain URIs we should not open to read metadata from before they are opened for playback. This is typically needed for services that invalidate URIs after a single use. (Fixes: :issue:`660`) v0.18.3 (2014-02-16) ==================== Bug fix release. - Fix documentation build. v0.18.2 (2014-02-16) ==================== Bug fix release. - We now log warnings for wrongly configured extensions, and clearly label them in ``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) ==================== Bug fix release. - Disable extension instead of crashing if a dependency has the wrong version. (Fixes: :issue:`657`) - Make logging work to both console, debug log file, and any custom logging setup from :confval:`logging/config_file` at the same time. (Fixes: :issue:`661`) v0.18.0 (2014-01-19) ==================== The focus of 0.18 have been on two fronts: the local library and browsing. First, the local library's old tag cache file used for storing the track metadata scanned from your music collection has been replaced with a far simpler implementation using JSON as the storage format. At the same time, the local library have been made replaceable by extensions, so you can now create extensions that use your favorite database to store the metadata. Second, we've finally implemented the long awaited "file system" browsing feature that you know from MPD. It is supported by both the MPD frontend and the local and Spotify backends. It is also used by the new Mopidy-Dirble extension to provide you with a directory of Internet radio stations from all over the world. Since the release of 0.17, we've closed or merged 49 issues and pull requests through about 285 commits by :ref:`11 people `, including six new guys. Thanks to everyone that has contributed! **Core API** - Add :meth:`mopidy.core.Core.version` for HTTP clients to manage compatibility between API versions. (Fixes: :issue:`597`) - Add :class:`mopidy.models.Ref` class for use as a lightweight reference to other model types, containing just an URI, a name, and an object type. It is barely used for now, but its use will be extended over time. - Add :meth:`mopidy.core.LibraryController.browse` method for browsing a virtual file system of tracks. Backends can implement support for this by implementing :meth:`mopidy.backend.LibraryProvider.browse`. - Events emitted on play/stop, pause/resume, next/previous and on end of track has been cleaned up to work consistently. See the message of :commit:`1d108752f6` for the full details. (Fixes: :issue:`629`) **Backend API** - Move the backend API classes from :mod:`mopidy.backends.base` to :mod:`mopidy.backend` and remove the ``Base`` prefix from the class names: - From :class:`mopidy.backends.base.Backend` to :class:`mopidy.backend.Backend` - From :class:`mopidy.backends.base.BaseLibraryProvider` to :class:`mopidy.backend.LibraryProvider` - From :class:`mopidy.backends.base.BasePlaybackProvider` to :class:`mopidy.backend.PlaybackProvider` - From :class:`mopidy.backends.base.BasePlaylistsProvider` to :class:`mopidy.backend.PlaylistsProvider` - From :class:`mopidy.backends.listener.BackendListener` to :class:`mopidy.backend.BackendListener` Imports from the old locations still works, but are deprecated. - Add :meth:`mopidy.backend.LibraryProvider.browse`, which can be implemented by backends that wants to expose directories of tracks in Mopidy's virtual file system. **Frontend API** - The dummy backend used for testing many frontends have moved from :mod:`mopidy.backends.dummy` to :mod:`mopidy.backend.dummy`. (PR: :issue:`984`) **Commands** - Reduce amount of logging from dependencies when using :option:`mopidy -v`. (Fixes: :issue:`593`) - Add support for additional logging verbosity levels with ``mopidy -vv`` and ``mopidy -vvv`` which increases the amount of logging from dependencies. (Fixes: :issue:`593`) **Configuration** - The default for the :option:`mopidy --config` option has been updated to include ``$XDG_CONFIG_DIRS`` in addition to ``$XDG_CONFIG_DIR``. (Fixes :issue:`431`) - Added support for deprecating config values in order to allow for graceful removal of the no longer used config value :confval:`local/tag_cache_file`. **Extension support** - Switched to using a registry model for classes provided by extension. This allows extensions to be extended by other extensions, as needed by for example pluggable libraries for the local backend. See :class:`mopidy.ext.Registry` for details. (Fixes :issue:`601`) - Added the new method :meth:`mopidy.ext.Extension.setup`. This method replaces the now deprecated :meth:`~mopidy.ext.Extension.get_backend_classes`, :meth:`~mopidy.ext.Extension.get_frontend_classes`, and :meth:`~mopidy.ext.Extension.register_gstreamer_elements`. **Audio** - Added :confval:`audio/mixer_volume` to set the initial volume of mixers. This is especially useful for setting the software mixer volume to something else than the default 100%. (Fixes: :issue:`633`) **Local backend** .. note:: After upgrading to Mopidy 0.18 you must run ``mopidy local scan`` to reindex your local music collection. This is due to the change of storage format. - Added support for browsing local directories in Mopidy's virtual file system. - Finished the work on creating pluggable libraries. Users can now reconfigure Mopidy to use alternate library providers of their choosing for local files. (Fixes issue :issue:`44`, partially resolves :issue:`397`, and causes a temporary regression of :issue:`527`.) - Switched default local library provider from a "tag cache" file that closely resembled the one used by the original MPD server to a compressed JSON file. This greatly simplifies our library code and reuses our existing model serialization code, as used by the HTTP API and web clients. - Removed our outdated and bug-ridden "tag cache" local library implementation. - Added the config value :confval:`local/library` to select which library to use. It defaults to ``json``, which is the only local library bundled with Mopidy. - Added the config value :confval:`local/data_dir` to have a common config for where to store local library data. This is intended to avoid every single local library provider having to have it's own config value for this. - Added the config value :confval:`local/scan_flush_threshold` to control how often to tell local libraries to store changes when scanning local music. **Streaming backend** - Add live lookup of URI metadata. (Fixes :issue:`540`) - Add support for extended M3U playlist, meaning that basic track metadata stored in playlists will be used by Mopidy. **HTTP frontend** - Upgrade Mopidy.js dependencies and add support for using Mopidy.js with Browserify. This version has been released to npm as Mopidy.js v0.2.0. (Fixes: :issue:`609`) **MPD frontend** - Make the ``lsinfo``, ``listall``, and ``listallinfo`` commands support browsing of Mopidy's virtual file system. (Fixes: :issue:`145`) - Empty commands now return a ``ACK [5@0] {} No command given`` error instead of ``OK``. This is consistent with the original MPD server implementation. **Internal changes** - Events from the audio actor, backends, and core actor are now emitted asyncronously through the GObject event loop. This should resolve the issue that has blocked the merge of the EOT-vs-EOS fix for a long time. v0.17.0 (2013-11-23) ==================== The focus of 0.17 has been on introducing subcommands to the ``mopidy`` command, making it possible for extensions to add subcommands of their own, and to improve the default config file when starting Mopidy the first time. In addition, we've grown support for Zeroconf publishing of the MPD and HTTP servers, and gotten a much faster scanner. The scanner now also scans some additional tags like composers and performers. Since the release of 0.16, we've closed or merged 22 issues and pull requests through about 200 commits by :ref:`five people `, including one new contributor. **Commands** - Switched to subcommands for the ``mopidy`` command , this implies the following changes: (Fixes: :issue:`437`) ===================== ================= Old command New command ===================== ================= mopidy --show-deps mopidy deps mopidy --show-config mopidy config mopidy-scan mopidy local scan ===================== ================= - Added hooks for extensions to create their own custom subcommands and converted ``mopidy-scan`` as a first user of the new API. (Fixes: :issue:`436`) **Configuration** - When ``mopidy`` is started for the first time we create an empty :file:`{$XDG_CONFIG_DIR}/mopidy/mopidy.conf` file. We now populate this file with the default config for all installed extensions so it'll be easier to set up Mopidy without looking through all the documentation for relevant config values. (Fixes: :issue:`467`) **Core API** - The :class:`~mopidy.models.Track` model has grown fields for ``composers``, ``performers``, ``genre``, and ``comment``. - The search field ``track`` has been renamed to ``track_name`` to avoid confusion with ``track_no``. (Fixes: :issue:`535`) - The signature of the tracklist's :meth:`~mopidy.core.TracklistController.filter` and :meth:`~mopidy.core.TracklistController.remove` methods have changed. Previously, they expected e.g. ``tracklist.filter(tlid=17)``. Now, the value must always be a list, e.g. ``tracklist.filter(tlid=[17])``. This change allows you to get or remove multiple tracks with a single call, e.g. ``tracklist.remove(tlid=[1, 2, 7])``. This is especially useful for web clients, as requests can be batched. This also brings the interface closer to the library's :meth:`~mopidy.core.LibraryController.find_exact` and :meth:`~mopidy.core.LibraryController.search` methods. **Audio** - Change default volume mixer from ``autoaudiomixer`` to ``software``. GStreamer 1.x does not support volume control, so we're changing to use software mixing by default, as that may be the only thing we'll support in the future when we upgrade to GStreamer 1.x. **Local backend** - Library scanning has been switched back from GStreamer's discoverer to our custom implementation due to various issues with GStreamer 0.10's built in scanner. This also fixes the scanner slowdown. (Fixes: :issue:`565`) - When scanning, we no longer default the album artist to be the same as the track artist. Album artist is now only populated if the scanned file got an explicit album artist set. - The scanner will now extract multiple artists from files with multiple artist tags. - The scanner will now extract composers and performers, as well as genre, bitrate, and comments. (Fixes: :issue:`577`) - Fix scanner so that time of last modification is respected when deciding which files can be skipped when scanning the music collection for changes. - The scanner now ignores the capitalization of file extensions in :confval:`local/excluded_file_extensions`, so you no longer need to list both ``.jpg`` and ``.JPG`` to ignore JPEG files when scanning. (Fixes: :issue:`525`) - The scanner now by default ignores ``*.nfo`` and ``*.html`` files too. **MPD frontend** - The MPD service is now published as a Zeroconf service if avahi-daemon is running on the system. Some MPD clients will use this to present Mopidy as an available server on the local network without needing any configuration. See the :confval:`mpd/zeroconf` config value to change the service name or disable the service. (Fixes: :issue:`39`) - Add support for ``composer``, ``performer``, ``comment``, ``genre``, and ``performer``. These tags can be used with ``list ...``, ``search ...``, and ``find ...`` and their variants, and are supported in the ``any`` tag also - The ``bitrate`` field in the ``status`` response is now always an integer. This follows the behavior of the original MPD server. (Fixes: :issue:`577`) **HTTP frontend** - The HTTP service is now published as a Zeroconf service if avahi-daemon is running on the system. Some browsers will present HTTP Zeroconf services on the local network as "local sites" bookmarks. See the :confval:`http/zeroconf` config value to change the service name or disable the service. (Fixes: :issue:`39`) **DBUS/MPRIS** - The ``mopidy`` process now registers it's GObject event loop as the default eventloop for dbus-python. (Fixes: :mpris:`2`) v0.16.1 (2013-11-02) ==================== This is very small release to get Mopidy's Debian package ready for inclusion in Debian. **Commands** - Fix removal of last dir level in paths to dependencies in ``mopidy --show-deps`` output. - Add manpages for all commands. **Local backend** - Fix search filtering by track number that was added in 0.16.0. **MPD frontend** - Add support for ``list "albumartist" ...`` which was missed when ``find`` and ``search`` learned to handle ``albumartist`` in 0.16.0. (Fixes: :issue:`553`) v0.16.0 (2013-10-27) ==================== The goals for 0.16 were to add support for queuing playlists of e.g. radio streams directly to Mopidy, without manually extracting the stream URLs from the playlist first, and to move the Spotify, Last.fm, and MPRIS support out to independent Mopidy extensions, living outside the main Mopidy repo. In addition, we've seen some cleanup to the playback vs tracklist part of the core API, which will require some changes for users of the HTTP/JavaScript APIs, as well as the addition of audio muting to the core API. To speed up the :ref:`development of new extensions `, we've added a cookiecutter project to get the skeleton of a Mopidy extension up and running in a matter of minutes. Read below for all the details and for links to issues with even more details. Since the release of 0.15, we've closed or merged 31 issues and pull requests through about 200 commits by :ref:`five people `, including three new contributors. **Dependencies** Parts of Mopidy have been moved to their own external extensions. If you want Mopidy to continue to work like it used to, you may have to install one or more of the following extensions as well: - The Spotify backend has been moved to `Mopidy-Spotify `_. - The Last.fm scrobbler has been moved to `Mopidy-Scrobbler `_. - The MPRIS frontend has been moved to `Mopidy-MPRIS `_. **Core** - Parts of the functionality in :class:`mopidy.core.PlaybackController` have been moved to :class:`mopidy.core.TracklistController`: =================================== ================================== Old location New location =================================== ================================== playback.get_consume() tracklist.get_consume() playback.set_consume(v) tracklist.set_consume(v) playback.consume tracklist.consume playback.get_random() tracklist.get_random() playback.set_random(v) tracklist.set_random(v) playback.random tracklist.random playback.get_repeat() tracklist.get_repeat() playback.set_repeat(v) tracklist.set_repeat(v) playback.repeat tracklist.repeat playback.get_single() tracklist.get_single() playback.set_single(v) tracklist.set_single(v) playback.single tracklist.single playback.get_tracklist_position() tracklist.index(tl_track) playback.tracklist_position tracklist.index(tl_track) playback.get_tl_track_at_eot() tracklist.eot_track(tl_track) playback.tl_track_at_eot tracklist.eot_track(tl_track) playback.get_tl_track_at_next() tracklist.next_track(tl_track) playback.tl_track_at_next tracklist.next_track(tl_track) playback.get_tl_track_at_previous() tracklist.previous_track(tl_track) playback.tl_track_at_previous tracklist.previous_track(tl_track) =================================== ================================== The ``tl_track`` argument to the last four new functions are used as the reference ``tl_track`` in the tracklist to find e.g. the next track. Usually, this will be :attr:`~mopidy.core.PlaybackController.current_tl_track`. - Added :attr:`mopidy.core.PlaybackController.mute` for muting and unmuting audio. (Fixes: :issue:`186`) - Added :meth:`mopidy.core.CoreListener.mute_changed` event that is triggered when the mute state changes. - In "random" mode, after a full playthrough of the tracklist, playback continued from the last track played to the end of the playlist in non-random order. It now stops when all tracks have been played once, unless "repeat" mode is enabled. (Fixes: :issue:`453`) - In "single" mode, after a track ended, playback continued with the next track in the tracklist. It now stops after playing a single track, unless "repeat" mode is enabled. (Fixes: :issue:`496`) **Audio** - Added support for parsing and playback of playlists in GStreamer. For end users this basically means that you can now add a radio playlist to Mopidy and we will automatically download it and play the stream inside it. Currently we support M3U, PLS, XSPF and ASX files. Also note that we can currently only play the first stream in the playlist. - We now handle the rare case where an audio track has max volume equal to min. This was causing divide by zero errors when scaling volumes to a zero to hundred scale. (Fixes: :issue:`525`) - Added support for muting audio without setting the volume to 0. This works both for the software and hardware mixers. (Fixes: :issue:`186`) **Local backend** - Replaced our custom media library scanner with GStreamer's builtin scanner. This should make scanning less error prone and faster as timeouts should be infrequent. (Fixes: :issue:`198`) - Media files with less than 100ms duration are now excluded from the library. - Media files with the file extensions ``.jpeg``, ``.jpg``, ``.png``, ``.txt``, and ``.log`` are now skipped by the media library scanner. You can change the list of excluded file extensions by setting the :confval:`local/excluded_file_extensions` config value. (Fixes: :issue:`516`) - Unknown URIs found in playlists are now made into track objects with the URI set instead of being ignored. This makes it possible to have playlists with e.g. HTTP radio streams and not just ``local:track:...`` URIs. This used to work, but was broken in Mopidy 0.15.0. (Fixes: :issue:`527`) - Fixed crash when playing ``local:track:...`` URIs which contained non-ASCII chars after uridecode. - Removed media files are now also removed from the in-memory media library when the media library is reloaded from disk. (Fixes: :issue:`500`) **MPD frontend** - Made the formerly unused commands ``outputs``, ``enableoutput``, and ``disableoutput`` mute/unmute audio. (Related to: :issue:`186`) - The MPD command ``list`` now works with ``"albumartist"`` as its second argument, e.g. ``list "album" "albumartist" "anartist"``. (Fixes: :issue:`468`) - The MPD commands ``find`` and ``search`` now accepts ``albumartist`` and ``track`` (this is the track number, not the track name) as field types to limit the search result with. - The MPD command ``count`` is now implemented. It accepts the same type of arguments as ``find`` and ``search``, but returns the number of tracks and their total playtime instead. **Extension support** - A cookiecutter project for quickly creating new Mopidy extensions have been created. You can find it at `cookiecutter-mopidy-ext `_. (Fixes: :issue:`522`) v0.15.0 (2013-09-19) ==================== A release with a number of small and medium fixes, with no specific focus. **Dependencies** - Mopidy no longer supports Python 2.6. Currently, the only Python version supported by Mopidy is Python 2.7. We're continuously working towards running Mopidy on Python 3. (Fixes: :issue:`344`) **Command line options** - Converted from the optparse to the argparse library for handling command line options. - :option:`mopidy --show-config` will now take into consideration any :option:`mopidy --option` arguments appearing later on the command line. This helps you see the effective configuration for runs with the same :option:`mopidy --options` arguments. **Audio** - Added support for audio visualization. :confval:`audio/visualizer` can now be set to GStreamer visualizers. - Properly encode localized mixer names before logging. **Local backend** - An album's number of discs and a track's disc number are now extracted when scanning your music collection. - The scanner now gives up scanning a file after a second, and continues with the next file. This fixes some hangs on non-media files, like logs. (Fixes: :issue:`476`, :issue:`483`) - Added support for pluggable library updaters. This allows extension writers to start providing their own custom libraries instead of being stuck with just our tag cache as the only option. - Converted local backend to use new ``local:playlist:path`` and ``local:track:path`` URI scheme. Also moves support of ``file://`` to streaming backend. **Spotify backend** - Prepend playlist folder names to the playlist name, so that the playlist hierarchy from your Spotify account is available in Mopidy. (Fixes: :issue:`62`) - Fix proxy config values that was broken with the config system change in 0.14. (Fixes: :issue:`472`) **MPD frontend** - Replace newline, carriage return and forward slash in playlist names. (Fixes: :issue:`474`, :issue:`480`) - Accept ``listall`` and ``listallinfo`` commands without the URI parameter. The methods are still not implemented, but now the commands are accepted as valid. **HTTP frontend** - Fix too broad truth test that caused :class:`mopidy.models.TlTrack` objects with ``tlid`` set to ``0`` to be sent to the HTTP client without the ``tlid`` field. (Fixes: :issue:`501`) - Upgrade Mopidy.js dependencies. This version has been released to npm as Mopidy.js v0.1.1. **Extension support** - :class:`mopidy.config.Secret` is now deserialized to unicode instead of bytes. This may require modifications to extensions. v0.14.2 (2013-07-01) ==================== This is a maintenance release to make Mopidy 0.14 work with pyspotify 1.11. **Dependencies** - pyspotify >= 1.9, < 2 is now required for Spotify support. In other words, you're free to upgrade to pyspotify 1.11, but it isn't a requirement. v0.14.1 (2013-04-28) ==================== This release addresses an issue in v0.14.0 where the new :option:`mopidy-convert-config` tool and the new :option:`mopidy --option` command line option was broken because some string operations inadvertently converted some byte strings to unicode. v0.14.0 (2013-04-28) ==================== The 0.14 release has a clear focus on two things: the new configuration system and extension support. Mopidy's documentation has also been greatly extended and improved. Since the last release a month ago, we've closed or merged 53 issues and pull requests. A total of seven :ref:`authors ` have contributed, including one new. **Dependencies** - setuptools or distribute is now required. We've introduced this dependency to use setuptools' entry points functionality to find installed Mopidy extensions. **New configuration system** - Mopidy has a new configuration system based on ini-style files instead of a Python file. This makes configuration easier for users, and also makes it possible for Mopidy extensions to have their own config sections. As part of this change we have cleaned up the naming of our config values. To ease migration we've made a tool named :option:`mopidy-convert-config` for automatically converting the old ``settings.py`` to a new ``mopidy.conf`` file. This tool takes care of all the renamed config values as well. See ``mopidy-convert-config`` for details on how to use it. - A long wanted feature: You can now enable or disable specific frontends or backends without having to redefine :attr:`~mopidy.settings.FRONTENDS` or :attr:`~mopidy.settings.BACKENDS` in your config. Those config values are gone completely. **Extension support** - Mopidy now supports extensions. This means that any developer now easily can create a Mopidy extension to add new control interfaces or music backends. This helps spread the maintenance burden across more developers, and also makes it possible to extend Mopidy with new backends the core developers are unable to create and/or maintain because of geo restrictions, etc. If you're interested in creating an extension for Mopidy, read up on :ref:`extensiondev`. - All of Mopidy's existing frontends and backends are now plugged into Mopidy as extensions, but they are still distributed together with Mopidy and are enabled by default. - The NAD mixer have been moved out of Mopidy core to its own project, Mopidy-NAD. See :ref:`ext` for more information. - Janez Troha has made the first two external extensions for Mopidy: a backend for playing music from Soundcloud, and a backend for playing music from a Beets music library. See :ref:`ext` for more information. **Command line options** - The command option :option:`mopidy --list-settings` is now named :option:`mopidy --show-config`. - The command option :option:`mopidy --list-deps` is now named :option:`mopidy --show-deps`. - What configuration files to use can now be specified through the command option :option:`mopidy --config`, multiple files can be specified using colon as a separator. - Configuration values can now be overridden through the command option :option:`mopidy --option`. For example: ``mopidy --option spotify/enabled=false``. - The GStreamer command line options, :option:`mopidy --gst-*` and :option:`mopidy --help-gst` are no longer supported. To set GStreamer debug flags, you can use environment variables such as :envvar:`GST_DEBUG`. Refer to GStreamer's documentation for details. **Spotify backend** - Add support for starred playlists, both your own and those owned by other users. (Fixes: :issue:`326`) - Fix crash when a new playlist is added by another Spotify client. (Fixes: :issue:`387`, :issue:`425`) **MPD frontend** - Playlists with identical names are now handled properly by the MPD frontend by suffixing the duplicate names with e.g. ``[2]``. This is needed because MPD identify playlists by name only, while Mopidy and Spotify supports multiple playlists with the same name, and identify them using an URI. (Fixes: :issue:`114`) **MPRIS frontend** - The frontend is now disabled if the :envvar:`DISPLAY` environment variable is unset. This avoids some harmless error messages, that have been known to confuse new users debugging other problems. **Development** - Developers running Mopidy from a Git clone now need to run ``python setup.py develop`` to register the bundled extensions. If you don't do this, Mopidy will not find any frontends or backends. Note that we highly recomend you do this in a virtualenv, not system wide. As a bonus, the command also gives you a ``mopidy`` executable in your search path. v0.13.0 (2013-03-31) ==================== The 0.13 release brings small improvements and bugfixes throughout Mopidy. There are no major new features, just incremental improvement of what we already have. **Dependencies** - Pykka >= 1.1 is now required. **Core** - Removed the :attr:`mopidy.settings.DEBUG_THREAD` setting and the :option:`--debug-thread` command line option. Sending SIGUSR1 to the Mopidy process will now always make it log tracebacks for all alive threads. - Log a warning if a track isn't playable to make it more obvious that backend X needs backend Y to be present for playback to work. - :meth:`mopidy.core.TracklistController.add` now accepts an ``uri`` which it will lookup in the library and then add to the tracklist. This is helpful for e.g. web clients that doesn't want to transfer all track meta data back to the server just to add it to the tracklist when the server already got all the needed information easily available. (Fixes: :issue:`325`) - Change the following methods to accept an ``uris`` keyword argument: - :meth:`mopidy.core.LibraryController.find_exact` - :meth:`mopidy.core.LibraryController.search` Search queries will only be forwarded to backends handling the given URI roots, and the backends may use the URI roots to further limit what results are returned. For example, a search with ``uris=['file:']`` will only be processed by the local backend. A search with ``uris=['file:///media/music']`` will only be processed by the local backend, and, if such filtering is supported by the backend, will only return results with URIs within the given URI root. **Audio sub-system** - Make audio error logging handle log messages with non-ASCII chars. (Fixes: :issue:`347`) **Local backend** - Make ``mopidy-scan`` work with Ogg Vorbis files. (Fixes: :issue:`275`) - Fix playback of files with non-ASCII chars in their file path. (Fixes: :issue:`353`) **Spotify backend** - Let GStreamer handle time position tracking and seeks. (Fixes: :issue:`191`) - For all playlists owned by other Spotify users, we now append the owner's username to the playlist name. (Partly fixes: :issue:`114`) **HTTP frontend** - Mopidy.js now works both from browsers and from Node.js environments. This means that you now can make Mopidy clients in Node.js. Mopidy.js has been published to the `npm registry `_ for easy installation in Node.js projects. - Upgrade Mopidy.js' build system Grunt from 0.3 to 0.4. - Upgrade Mopidy.js' dependencies when.js from 1.6.1 to 2.0.0. - Expose :meth:`mopidy.core.Core.get_uri_schemes` to HTTP clients. It is available through Mopidy.js as ``mopidy.getUriSchemes()``. **MPRIS frontend** - Publish album art URIs if available. - Publish disc number of track if available. v0.12.0 (2013-03-12) ==================== The 0.12 release has been delayed for a while because of some issues related some ongoing GStreamer cleanup we didn't invest enough time to finish. Finally, we've come to our senses and have now cherry-picked the good parts to bring you a new release, while postponing the GStreamer changes to 0.13. The release adds a new backend for playing audio streams, as well as various minor improvements throughout Mopidy. - Make Mopidy work on early Python 2.6 versions. (Fixes: :issue:`302`) - ``optparse`` fails if the first argument to ``add_option`` is a unicode string on Python < 2.6.2rc1. - ``foo(**data)`` fails if the keys in ``data`` is unicode strings on Python < 2.6.5rc1. **Audio sub-system** - Improve selection of mixer tracks for volume control. (Fixes: :issue:`307`) **Local backend** - Make ``mopidy-scan`` support symlinks. **Stream backend** We've added a new backend for playing audio streams, the :mod:`stream backend `. It is activated by default. The stream backend supports the intersection of what your GStreamer installation supports and what protocols are included in the :attr:`mopidy.settings.STREAM_PROTOCOLS` setting. Current limitations: - No metadata about the current track in the stream is available. - Playlists are not parsed, so you can't play e.g. a M3U or PLS file which contains stream URIs. You need to extract the stream URL from the playlist yourself. See :issue:`303` for progress on this. **Core API** - :meth:`mopidy.core.PlaylistsController.get_playlists` now accepts an argument ``include_tracks``. This defaults to :class:`True`, which has the same old behavior. If set to :class:`False`, the tracks are stripped from the playlists before they are returned. This can be used to limit the amount of data returned if the response is to be passed out of the application, e.g. to a web client. (Fixes: :issue:`297`) **Models** - Add :attr:`mopidy.models.Album.images` field for including album art URIs. (Partly fixes :issue:`263`) - Add :attr:`mopidy.models.Track.disc_no` field. (Partly fixes: :issue:`286`) - Add :attr:`mopidy.models.Album.num_discs` field. (Partly fixes: :issue:`286`) v0.11.1 (2012-12-24) ==================== Spotify search was broken in 0.11.0 for users of Python 2.6. This release fixes it. If you're using Python 2.7, v0.11.0 and v0.11.1 should be equivalent. v0.11.0 (2012-12-24) ==================== In celebration of Mopidy's three year anniversary December 23, we're releasing Mopidy 0.11. This release brings several improvements, most notably better search which now includes matching artists and albums from Spotify in the search results. **Settings** - The settings validator now complains if a setting which expects a tuple of values (e.g. :attr:`mopidy.settings.BACKENDS`, :attr:`mopidy.settings.FRONTENDS`) has a non-iterable value. This typically happens because the setting value contains a single value and one has forgotten to add a comma after the string, making the value a tuple. (Fixes: :issue:`278`) **Spotify backend** - Add :attr:`mopidy.settings.SPOTIFY_TIMEOUT` setting which allows you to control how long we should wait before giving up on Spotify searches, etc. - Add support for looking up albums, artists, and playlists by URI in addition to tracks. (Fixes: :issue:`67`) As an example of how this can be used, you can try the the following MPD commands which now all adds one or more tracks to your tracklist:: add "spotify:track:1mwt9hzaH7idmC5UCoOUkz" add "spotify:album:3gpHG5MGwnipnap32lFYvI" add "spotify:artist:5TgQ66WuWkoQ2xYxaSTnVP" add "spotify:user:p3.no:playlist:0XX6tamRiqEgh3t6FPFEkw" - Increase max number of tracks returned by searches from 100 to 200, which seems to be Spotify's current max limit. **Local backend** - Load track dates from tag cache. - Add support for searching by track date. **MPD frontend** - Add :attr:`mopidy.settings.MPD_SERVER_CONNECTION_TIMEOUT` setting which controls how long an MPD client can stay inactive before the connection is closed by the server. - Add support for the ``findadd`` command. - Updated to match the MPD 0.17 protocol (Fixes: :issue:`228`): - Add support for ``seekcur`` command. - Add support for ``config`` command. - Add support for loading a range of tracks from a playlist to the ``load`` command. - Add support for ``searchadd`` command. - Add support for ``searchaddpl`` command. - Add empty stubs for channel commands for client to client communication. - Add support for search by date. - Make ``seek`` and ``seekid`` not restart the current track before seeking in it. - Include fake tracks representing albums and artists in the search results. When these are added to the tracklist, they expand to either all tracks in the album or all tracks by the artist. This makes it easy to play full albums in proper order, which is a feature that have been frequently requested. (Fixes: :issue:`67`, :issue:`148`) **Internal changes** *Models:* - Specified that :attr:`mopidy.models.Playlist.last_modified` should be in UTC. - Added :class:`mopidy.models.SearchResult` model to encapsulate search results consisting of more than just tracks. *Core API:* - Change the following methods to return :class:`mopidy.models.SearchResult` objects which can include both track results and other results: - :meth:`mopidy.core.LibraryController.find_exact` - :meth:`mopidy.core.LibraryController.search` - Change the following methods to accept either a dict with filters or kwargs. Previously they only accepted kwargs, which made them impossible to use from the Mopidy.js through JSON-RPC, which doesn't support kwargs. - :meth:`mopidy.core.LibraryController.find_exact` - :meth:`mopidy.core.LibraryController.search` - :meth:`mopidy.core.PlaylistsController.filter` - :meth:`mopidy.core.TracklistController.filter` - :meth:`mopidy.core.TracklistController.remove` - Actually trigger the :meth:`mopidy.core.CoreListener.volume_changed` event. - Include the new volume level in the :meth:`mopidy.core.CoreListener.volume_changed` event. - The ``track_playback_{paused,resumed,started,ended}`` events now include a :class:`mopidy.models.TlTrack` instead of a :class:`mopidy.models.Track`. *Audio:* - Mixers with fewer than 100 volume levels could report another volume level than what you just set due to the conversion between Mopidy's 0-100 range and the mixer's range. Now Mopidy returns the recently set volume if the mixer reports a volume level that matches the recently set volume, otherwise the mixer's volume level is rescaled to the 1-100 range and returned. v0.10.0 (2012-12-12) ==================== We've added an HTTP frontend for those wanting to build web clients for Mopidy! **Dependencies** - pyspotify >= 1.9, < 1.11 is now required for Spotify support. In other words, you're free to upgrade to pyspotify 1.10, but it isn't a requirement. **Documentation** - Added installation instructions for Fedora. **Spotify backend** - Save a lot of memory by reusing artist, album, and track models. - Make sure the playlist loading hack only runs once. **Local backend** - Change log level from error to warning on messages emitted when the tag cache isn't found and a couple of similar cases. - Make ``mopidy-scan`` ignore invalid dates, e.g. dates in years outside the range 1-9999. - Make ``mopidy-scan`` accept :option:`-q`/:option:`--quiet` and :option:`-v`/:option:`--verbose` options to control the amount of logging output when scanning. - The scanner can now handle files with other encodings than UTF-8. Rebuild your tag cache with ``mopidy-scan`` to include tracks that may have been ignored previously. **HTTP frontend** - Added new optional HTTP frontend which exposes Mopidy's core API through JSON-RPC 2.0 messages over a WebSocket. See :ref:`http-api` for further details. - Added a JavaScript library, Mopidy.js, to make it easier to develop web based Mopidy clients using the new HTTP frontend. **Bug fixes** - :issue:`256`: Fix crash caused by non-ASCII characters in paths returned from ``glib``. The bug can be worked around by overriding the settings that includes offending ``$XDG_`` variables. v0.9.0 (2012-11-21) =================== Support for using the local and Spotify backends simultaneously have for a very long time been our most requested feature. Finally, it's here! **Dependencies** - pyspotify >= 1.9, < 1.10 is now required for Spotify support. **Documentation** - New :ref:`installation` guides, organized by OS and distribution so that you can follow one concise list of instructions instead of jumping around the docs to look for instructions for each dependency. - Moved :ref:`raspberrypi-installation` howto from the wiki to the docs. - Updated :ref:`mpd-clients` overview. - Added :ref:`mpris-clients` and :ref:`upnp-clients` overview. **Multiple backends support** - Both the local backend and the Spotify backend are now turned on by default. The local backend is listed first in the :attr:`mopidy.settings.BACKENDS` setting, and are thus given the highest priority in e.g. search results, meaning that we're listing search hits from the local backend first. If you want to prioritize the backends in another way, simply set ``BACKENDS`` in your own settings file and reorder the backends. There are no other setting changes related to the local and Spotify backends. As always, see :mod:`mopidy.settings` for the full list of available settings. **Spotify backend** - The Spotify backend now includes release year and artist on albums. - :issue:`233`: The Spotify backend now returns the track if you search for the Spotify track URI. - Added support for connecting to the Spotify service through an HTTP or SOCKS proxy, which is supported by pyspotify >= 1.9. - Subscriptions to other Spotify user's "starred" playlists are ignored, as they currently isn't fully supported by pyspotify. **Local backend** - :issue:`236`: The ``mopidy-scan`` command failed to include tags from ALAC files (Apple lossless) because it didn't support multiple tag messages from GStreamer per track it scanned. - Added support for search by filename to local backend. **MPD frontend** - :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now accepts unquoted playlist names if they don't contain spaces. - :issue:`246`: The MPD command ``list album artist ""`` and similar ``search``, ``find``, and ``list`` commands with empty filter values caused a :exc:`LookupError`, but should have been ignored by the MPD server. - The MPD frontend no longer lowercases search queries. This broke e.g. search by URI, where casing may be essential. - The MPD command ``plchanges`` always returned the entire playlist. It now returns an empty response when the client has seen the latest version. - The MPD commands ``search`` and ``find`` now allows the key ``file``, which is used by ncmpcpp instead of ``filename``. - The MPD commands ``search`` and ``find`` now allow search query values to be empty strings. - The MPD command ``listplaylists`` will no longer return playlists without a name. This could crash ncmpcpp. - The MPD command ``list`` will no longer return artist names, album names, or dates that are blank. - The MPD command ``decoders`` will now return an empty response instead of a "not implemented" error to make the ncmpcpp browse view work the first time it is opened. **MPRIS frontend** - The MPRIS playlists interface is now supported by our MPRIS frontend. This means that you now can select playlists to queue and play from the Ubuntu Sound Menu. **Audio mixers** - Made the :mod:`NAD mixer ` responsive to interrupts during amplifier calibration. It will now quit immediately, while previously it completed the calibration first, and then quit, which could take more than 15 seconds. **Developer support** - Added optional background thread for debugging deadlocks. When the feature is enabled via the ``--debug-thread`` option or :attr:`mopidy.settings.DEBUG_THREAD` setting a ``SIGUSR1`` signal will dump the traceback for all running threads. - The settings validator will now allow any setting prefixed with ``CUSTOM_`` to exist in the settings file. **Internal changes** Internally, Mopidy have seen a lot of changes to pave the way for multiple backends and the future HTTP frontend. - A new layer and actor, "core", has been added to our stack, inbetween the frontends and the backends. The responsibility of the core layer and actor is to take requests from the frontends, pass them on to one or more backends, and combining the response from the backends into a single response to the requesting frontend. Frontends no longer know anything about the backends. They just use the :ref:`core-api`. - The dependency graph between the core controllers and the backend providers have been straightened out, so that we don't have any circular dependencies. The frontend, core, backend, and audio layers are now strictly separate. The frontend layer calls on the core layer, and the core layer calls on the backend layer. Both the core layer and the backends are allowed to call on the audio layer. Any data flow in the opposite direction is done by broadcasting of events to listeners, through e.g. :class:`mopidy.core.CoreListener` and :class:`mopidy.audio.AudioListener`. See :ref:`concepts` for more details and illustrations of all the relations. - All dependencies are now explicitly passed to the constructors of the frontends, core, and the backends. This makes testing each layer with dummy/mocked lower layers easier than with the old variant, where dependencies where looked up in Pykka's actor registry. - All properties in the core API now got getters, and setters if setting them is allowed. They are not explictly listed in the docs as they have the same behavior as the documented properties, but they are available and may be used. This is useful for the future HTTP frontend. *Models:* - Added :attr:`mopidy.models.Album.date` attribute. It has the same format as the existing :attr:`mopidy.models.Track.date`. - Added :class:`mopidy.models.ModelJSONEncoder` and :func:`mopidy.models.model_json_decoder` for automatic JSON serialization and deserialization of data structures which contains Mopidy models. This is useful for the future HTTP frontend. *Library:* - :meth:`mopidy.core.LibraryController.find_exact` and :meth:`mopidy.core.LibraryController.search` now returns plain lists of tracks instead of playlist objects. - :meth:`mopidy.core.LibraryController.lookup` now returns a list of tracks instead of a single track. This makes it possible to support lookup of artist or album URIs which then can expand to a list of tracks. *Playback:* - The base playback provider has been updated with sane default behavior instead of empty functions. By default, the playback provider now lets GStreamer keep track of the current track's time position. The local backend simply uses the base playback provider without any changes. Any future backend that just feeds URIs to GStreamer to play can also use the base playback provider without any changes. - Removed :attr:`mopidy.core.PlaybackController.track_at_previous`. Use :attr:`mopidy.core.PlaybackController.tl_track_at_previous` instead. - Removed :attr:`mopidy.core.PlaybackController.track_at_next`. Use :attr:`mopidy.core.PlaybackController.tl_track_at_next` instead. - Removed :attr:`mopidy.core.PlaybackController.track_at_eot`. Use :attr:`mopidy.core.PlaybackController.tl_track_at_eot` instead. - Removed :attr:`mopidy.core.PlaybackController.current_tlid`. Use :attr:`mopidy.core.PlaybackController.current_tl_track` instead. *Playlists:* The playlists part of the core API has been revised to be more focused around the playlist URI, and some redundant functionality has been removed: - Renamed "stored playlists" to "playlists" everywhere, including the core API used by frontends. - :attr:`mopidy.core.PlaylistsController.playlists` no longer supports assignment to it. The `playlists` property on the backend layer still does, and all functionality is maintained by assigning to the playlists collections at the backend level. - :meth:`mopidy.core.PlaylistsController.delete` now accepts an URI, and not a playlist object. - :meth:`mopidy.core.PlaylistsController.save` now returns the saved playlist. The returned playlist may differ from the saved playlist, and should thus be used instead of the playlist passed to :meth:`mopidy.core.PlaylistsController.save`. - :meth:`mopidy.core.PlaylistsController.rename` has been removed, since renaming can be done with :meth:`mopidy.core.PlaylistsController.save`. - :meth:`mopidy.core.PlaylistsController.get` has been replaced by :meth:`mopidy.core.PlaylistsController.filter`. - The event :meth:`mopidy.core.CoreListener.playlist_changed` has been changed to include the playlist that was changed. *Tracklist:* - Renamed "current playlist" to "tracklist" everywhere, including the core API used by frontends. - Removed :meth:`mopidy.core.TracklistController.append`. Use :meth:`mopidy.core.TracklistController.add` instead, which is now capable of adding multiple tracks. - :meth:`mopidy.core.TracklistController.get` has been replaced by :meth:`mopidy.core.TracklistController.filter`. - :meth:`mopidy.core.TracklistController.remove` can now remove multiple tracks, and returns the tracks it removed. - When the tracklist is changed, we now trigger the new :meth:`mopidy.core.CoreListener.tracklist_changed` event. Previously we triggered :meth:`mopidy.core.CoreListener.playlist_changed`, which is intended for stored playlists, not the tracklist. *Towards Python 3 support:* - Make the entire code base use unicode strings by default, and only fall back to bytestrings where it is required. Another step closer to Python 3. v0.8.1 (2012-10-30) =================== A small maintenance release to fix a bug introduced in 0.8.0 and update Mopidy to work with Pykka 1.0. **Dependencies** - Pykka >= 1.0 is now required. **Bug fixes** - :issue:`213`: Fix "streaming task paused, reason not-negotiated" errors observed by some users on some Spotify tracks due to a change introduced in 0.8.0. See the issue for a patch that applies to 0.8.0. - :issue:`216`: Volume returned by the MPD command `status` contained a floating point ``.0`` suffix. This bug was introduced with the large audio output and mixer changes in v0.8.0 and broke the MPDroid Android client. It now returns an integer again. v0.8.0 (2012-09-20) =================== This release does not include any major new features. We've done a major cleanup of how audio outputs and audio mixers work, and on the way we've resolved a bunch of related issues. **Audio output and mixer changes** - Removed multiple outputs support. Having this feature currently seems to be more trouble than what it is worth. The :attr:`mopidy.settings.OUTPUTS` setting is no longer supported, and has been replaced with :attr:`mopidy.settings.OUTPUT` which is a GStreamer bin description string in the same format as ``gst-launch`` expects. Default value is ``autoaudiosink``. (Fixes: :issue:`81`, :issue:`115`, :issue:`121`, :issue:`159`) - Switch to pure GStreamer based mixing. This implies that users setup a GStreamer bin with a mixer in it in :attr:`mopidy.settings.MIXER`. The default value is ``autoaudiomixer``, a custom mixer that attempts to find a mixer that will work on your system. If this picks the wrong mixer you can of course override it. Setting the mixer to :class:`None` is also supported. MPD protocol support for volume has also been updated to return -1 when we have no mixer set. ``software`` can be used to force software mixing. - Removed the Denon hardware mixer, as it is not maintained. - Updated the NAD hardware mixer to work in the new GStreamer based mixing regime. Settings are now passed as GStreamer element properties. In practice that means that the following old-style config:: MIXER = u'mopidy.mixers.nad.NadMixer' MIXER_EXT_PORT = u'/dev/ttyUSB0' MIXER_EXT_SOURCE = u'Aux' MIXER_EXT_SPEAKERS_A = u'On' MIXER_EXT_SPEAKERS_B = u'Off' Now is reduced to simply:: MIXER = u'nadmixer port=/dev/ttyUSB0 source=aux speakers-a=on speakers-b=off' The ``port`` property defaults to ``/dev/ttyUSB0``, and the rest of the properties may be left out if you don't want the mixer to adjust the settings on your NAD amplifier when Mopidy is started. **Changes** - When unknown settings are encountered, we now check if it's similar to a known setting, and suggests to the user what we think the setting should have been. - Added :option:`--list-deps` option to the ``mopidy`` command that lists required and optional dependencies, their current versions, and some other information useful for debugging. (Fixes: :issue:`74`) - Added ``tools/debug-proxy.py`` to tee client requests to two backends and diff responses. Intended as a developer tool for checking for MPD protocol changes and various client support. Requires gevent, which currently is not a dependency of Mopidy. - Support tracks with only release year, and not a full release date, like e.g. Spotify tracks. - Default value of ``LOCAL_MUSIC_PATH`` has been updated to be ``$XDG_MUSIC_DIR``, which on most systems this is set to ``$HOME``. Users of local backend that relied on the old default ``~/music`` need to update their settings. Note that the code responsible for finding this music now also ignores UNIX hidden files and folders. - File and path settings now support ``$XDG_CACHE_DIR``, ``$XDG_DATA_DIR`` and ``$XDG_MUSIC_DIR`` substitution. Defaults for such settings have been updated to use this instead of hidden away defaults. - Playback is now done using ``playbin2`` from GStreamer instead of rolling our own. This is the first step towards resolving :issue:`171`. **Bug fixes** - :issue:`72`: Created a Spotify track proxy that will switch to using loaded data as soon as it becomes available. - :issue:`150`: Fix bug which caused some clients to block Mopidy completely. The bug was caused by some clients sending ``close`` and then shutting down the connection right away. This trigged a situation in which the connection cleanup code would wait for an response that would never come inside the event loop, blocking everything else. - :issue:`162`: Fixed bug when the MPD command ``playlistinfo`` is used with a track position. Track position and CPID was intermixed, so it would cause a crash if a CPID matching the track position didn't exist. - Fixed crash on lookup of unknown path when using local backend. - :issue:`189`: ``LOCAL_MUSIC_PATH`` and path handling in rest of settings has been updated so all of the code now uses the correct value. - Fixed incorrect track URIs generated by M3U playlist parsing code. Generated tracks are now relative to ``LOCAL_MUSIC_PATH``. - :issue:`203`: Re-add support for software mixing. v0.7.3 (2012-08-11) =================== A small maintenance release to fix a crash affecting a few users, and a couple of small adjustments to the Spotify backend. **Changes** - Fixed crash when logging :exc:`IOError` exceptions on systems using languages with non-ASCII characters, like French. - Move the default location of the Spotify cache from `~/.cache/mopidy` to `~/.cache/mopidy/spotify`. You can change this by setting :attr:`mopidy.settings.SPOTIFY_CACHE_PATH`. - Reduce time required to update the Spotify cache on startup. One one system/Spotify account, the time from clean cache to ready for use was reduced from 35s to 12s. v0.7.2 (2012-05-07) =================== This is a maintenance release to make Mopidy 0.7 build on systems without all of Mopidy's runtime dependencies, like Launchpad PPAs. **Changes** - Change from version tuple at :attr:`mopidy.VERSION` to :pep:`386` compliant version string at :attr:`mopidy.__version__` to conform to :pep:`396`. v0.7.1 (2012-04-22) =================== This is a maintenance release to make Mopidy 0.7 work with pyspotify >= 1.7. **Changes** - Don't override pyspotify's ``notify_main_thread`` callback. The default implementation is sensible, while our override did nothing. v0.7.0 (2012-02-25) =================== Not a big release with regard to features, but this release got some performance improvements over v0.6, especially for slower Atom systems. It also fixes a couple of other bugs, including one which made Mopidy crash when using GStreamer from the prereleases of Ubuntu 12.04. **Changes** - The MPD command ``playlistinfo`` is now faster, thanks to John Bäckstrand. - Added the method :meth:`mopidy.backends.base.CurrentPlaylistController.length()`, :meth:`mopidy.backends.base.CurrentPlaylistController.index()`, and :meth:`mopidy.backends.base.CurrentPlaylistController.slice()` to reduce the need for copying the entire current playlist from one thread to another. Thanks to John Bäckstrand for pinpointing the issue. - Fix crash on creation of config and cache directories if intermediate directories does not exist. This was especially the case on OS X, where ``~/.config`` doesn't exist for most users. - Fix ``gst.LinkError`` which appeared when using newer versions of GStreamer, e.g. on Ubuntu 12.04 Alpha. (Fixes: :issue:`144`) - Fix crash on mismatching quotation in ``list`` MPD queries. (Fixes: :issue:`137`) - Volume is now reported to be the same as the volume was set to, also when internal rounding have been done due to :attr:`mopidy.settings.MIXER_MAX_VOLUME` has been set to cap the volume. This should make it possible to manage capped volume from clients that only increase volume with one step at a time, like ncmpcpp does. v0.6.1 (2011-12-28) =================== This is a maintenance release to make Mopidy 0.6 work with pyspotify >= 1.5, which Mopidy's develop branch have supported for a long time. This should also make the Debian packages work out of the box again. **Important changes** - pyspotify 1.5 or greater is required. **Changes** - Spotify playlist folder boundaries are now properly detected. In other words, if you use playlist folders, you will no longer get lots of log messages about bad playlists. v0.6.0 (2011-10-09) =================== The development of Mopidy have been quite slow for the last couple of months, but we do have some goodies to release which have been idling in the develop branch since the warmer days of the summer. This release brings support for the MPD ``idle`` command, which makes it possible for a client wait for updates from the server instead of polling every second. Also, we've added support for the MPRIS standard, so that Mopidy can be controlled over D-Bus from e.g. the Ubuntu Sound Menu. Please note that 0.6.0 requires some updated dependencies, as listed under *Important changes* below. **Important changes** - Pykka 0.12.3 or greater is required. - pyspotify 1.4 or greater is required. - All config, data, and cache locations are now based on the XDG spec. - This means that your settings file will need to be moved from ``~/.mopidy/settings.py`` to ``~/.config/mopidy/settings.py``. - Your Spotify cache will now be stored in ``~/.cache/mopidy`` instead of ``~/.mopidy/spotify_cache``. - The local backend's ``tag_cache`` should now be in ``~/.local/share/mopidy/tag_cache``, likewise your playlists will be in ``~/.local/share/mopidy/playlists``. - The local client now tries to lookup where your music is via XDG, it will fall-back to ``~/music`` or use whatever setting you set manually. - The MPD command ``idle`` is now supported by Mopidy for the following subsystems: player, playlist, options, and mixer. (Fixes: :issue:`32`) - A new frontend :mod:`mopidy.frontends.mpris` have been added. It exposes Mopidy through the `MPRIS interface `_ over D-Bus. In practice, this makes it possible to control Mopidy through the `Ubuntu Sound Menu `_. **Changes** - Replace :attr:`mopidy.backends.base.Backend.uri_handlers` with :attr:`mopidy.backends.base.Backend.uri_schemes`, which just takes the part up to the colon of an URI, and not any prefix. - Add Listener API, :mod:`mopidy.listeners`, to be implemented by actors wanting to receive events from the backend. This is a formalization of the ad hoc events the Last.fm scrobbler has already been using for some time. - Replaced all of the MPD network code that was provided by asyncore with custom stack. This change was made to facilitate support for the ``idle`` command, and to reduce the number of event loops being used. - Fix metadata update in Shoutcast streaming. (Fixes: :issue:`122`) - Unescape all incoming MPD requests. (Fixes: :issue:`113`) - Increase the maximum number of results returned by Spotify searches from 32 to 100. - Send Spotify search queries to pyspotify as unicode objects, as required by pyspotify 1.4. (Fixes: :issue:`129`) - Add setting :attr:`mopidy.settings.MPD_SERVER_MAX_CONNECTIONS`. (Fixes: :issue:`134`) - Remove `destroy()` methods from backend controller and provider APIs, as it was not in use and actually not called by any code. Will reintroduce when needed. v0.5.0 (2011-06-15) =================== Since last time we've added support for audio streaming to SHOUTcast servers and fixed the longstanding playlist loading issue in the Spotify backend. As always the release has a bunch of bug fixes and minor improvements. Please note that 0.5.0 requires some updated dependencies, as listed under *Important changes* below. **Important changes** - If you use the Spotify backend, you *must* upgrade to libspotify 0.0.8 and pyspotify 1.3. If you install from APT, libspotify and pyspotify will automatically be upgraded. If you are not installing from APT, follow the instructions at :ref:`installation`. - If you have explicitly set the :attr:`mopidy.settings.SPOTIFY_HIGH_BITRATE` setting, you must update your settings file. The new setting is named :attr:`mopidy.settings.SPOTIFY_BITRATE` and accepts the integer values 96, 160, and 320. - Mopidy now supports running with 1 to N outputs at the same time. This feature was mainly added to facilitate SHOUTcast support, which Mopidy has also gained. In its current state outputs can not be toggled during runtime. **Changes** - Local backend: - Fix local backend time query errors that where coming from stopped pipeline. (Fixes: :issue:`87`) - Spotify backend: - Thanks to Antoine Pierlot-Garcin's recent work on updating and improving pyspotify, stored playlists will again load when Mopidy starts. The workaround of searching and reconnecting to make the playlists appear are no longer necessary. (Fixes: :issue:`59`) - Track's that are no longer available in Spotify's archives are now "autolinked" to corresponding tracks in other albums, just like the official Spotify clients do. (Fixes: :issue:`34`) - MPD frontend: - Refactoring and cleanup. Most notably, all request handlers now get an instance of :class:`mopidy.frontends.mpd.dispatcher.MpdContext` as the first argument. The new class contains reference to any object in Mopidy the MPD protocol implementation should need access to. - Close the client connection when the command ``close`` is received. - Do not allow access to the command ``kill``. - ``commands`` and ``notcommands`` now have correct output if password authentication is turned on, but the connected user has not been authenticated yet. - Command line usage: - Support passing options to GStreamer. See :option:`--help-gst` for a list of available options. (Fixes: :issue:`95`) - Improve :option:`--list-settings` output. (Fixes: :issue:`91`) - Added :option:`--interactive` for reading missing local settings from ``stdin``. (Fixes: :issue:`96`) - Improve shutdown procedure at CTRL+C. Add signal handler for ``SIGTERM``, which initiates the same shutdown procedure as CTRL+C does. - Tag cache generator: - Made it possible to abort :command:`mopidy-scan` with CTRL+C. - Fixed bug regarding handling of bad dates. - Use :mod:`logging` instead of ``print`` statements. - Found and worked around strange WMA metadata behaviour. - Backend API: - Calling on :meth:`mopidy.backends.base.playback.PlaybackController.next` and :meth:`mopidy.backends.base.playback.PlaybackController.previous` no longer implies that playback should be started. The playback state--whether playing, paused or stopped--will now be kept. - The method :meth:`mopidy.backends.base.playback.PlaybackController.change_track` has been added. Like ``next()``, and ``prev()``, it changes the current track without changing the playback state. v0.4.1 (2011-05-06) =================== This is a bug fix release fixing audio problems on older GStreamer and some minor bugs. **Bug fixes** - Fix broken audio on at least GStreamer 0.10.30, which affects Ubuntu 10.10. The GStreamer `appsrc` bin wasn't being linked due to lack of default caps. (Fixes: :issue:`85`) - Fix crash in :mod:`mopidy.mixers.nad` that occures at startup when the :mod:`io` module is available. We used an `eol` keyword argument which is supported by :meth:`serial.FileLike.readline`, but not by :meth:`io.RawBaseIO.readline`. When the :mod:`io` module is available, it is used by PySerial instead of the `FileLike` implementation. - Fix UnicodeDecodeError in MPD frontend on non-english locale. Thanks to Antoine Pierlot-Garcin for the patch. (Fixes: :issue:`88`) - Do not create Pykka proxies that are not going to be used in :mod:`mopidy.core`. The underlying actor may already intentionally be dead, and thus the program may crash on creating a proxy it doesn't need. Combined with the Pykka 0.12.2 release this fixes a crash in the Last.fm frontend which may occur when all dependencies are installed, but the frontend isn't configured. (Fixes: :issue:`84`) v0.4.0 (2011-04-27) =================== Mopidy 0.4.0 is another release without major feature additions. In 0.4.0 we've fixed a bunch of issues and bugs, with the help of several new contributors who are credited in the changelog below. The major change of 0.4.0 is an internal refactoring which clears way for future features, and which also make Mopidy work on Python 2.7. In other words, Mopidy 0.4.0 works on Ubuntu 11.04 and Arch Linux. Please note that 0.4.0 requires some updated dependencies, as listed under *Important changes* below. Also, the known bug in the Spotify playlist loading from Mopidy 0.3.0 is still present. .. warning:: Known bug in Spotify playlist loading There is a known bug in the loading of Spotify playlists. To avoid the bug, follow the simple workaround described at :issue:`59`. **Important changes** - Mopidy now depends on `Pykka `_ >=0.12. If you install from APT, Pykka will automatically be installed. If you are not installing from APT, you may install Pykka from PyPI:: sudo pip install -U Pykka - If you use the Spotify backend, you *should* upgrade to libspotify 0.0.7 and the latest pyspotify from the Mopidy developers. If you install from APT, libspotify and pyspotify will automatically be upgraded. If you are not installing from APT, follow the instructions at :ref:`installation`. **Changes** - Mopidy now use Pykka actors for thread management and inter-thread communication. The immediate advantage of this is that Mopidy now works on Python 2.7, which is the default on e.g. Ubuntu 11.04. (Fixes: :issue:`66`) - Spotify backend: - Fixed multiple segmentation faults due to bugs in Pyspotify. Thanks to Antoine Pierlot-Garcin and Jamie Kirkpatrick for patches to Pyspotify. - Better error messages on wrong login or network problems. Thanks to Antoine Pierlot-Garcin for patches to Mopidy and Pyspotify. (Fixes: :issue:`77`) - Reduce log level for trivial log messages from warning to info. (Fixes: :issue:`71`) - Pause playback on network connection errors. (Fixes: :issue:`65`) - Local backend: - Fix crash in :command:`mopidy-scan` if a track has no artist name. Thanks to Martins Grunskis for test and patch and "octe" for patch. - Fix crash in `tag_cache` parsing if a track has no total number of tracks in the album. Thanks to Martins Grunskis for the patch. - MPD frontend: - Add support for "date" queries to both the ``find`` and ``search`` commands. This makes media library browsing in ncmpcpp work, though very slow due to all the meta data requests to Spotify. - Add support for ``play "-1"`` when in playing or paused state, which fixes resume and addition of tracks to the current playlist while playing for the MPoD client. - Fix bug where ``status`` returned ``song: None``, which caused MPDroid to crash. (Fixes: :issue:`69`) - Gracefully fallback to IPv4 sockets on systems that supports IPv6, but has turned it off. (Fixes: :issue:`75`) - GStreamer output: - Use ``uridecodebin`` for playing audio from both Spotify and the local backend. This contributes to support for multiple backends simultaneously. - Settings: - Fix crash on ``--list-settings`` on clean installation. Thanks to Martins Grunskis for the bug report and patch. (Fixes: :issue:`63`) - Packaging: - Replace test data symlinks with real files to avoid symlink issues when installing with pip. (Fixes: :issue:`68`) - Debugging: - Include platform, architecture, Linux distribution, and Python version in the debug log, to ease debugging of issues with attached debug logs. v0.3.1 (2011-01-22) =================== A couple of fixes to the 0.3.0 release is needed to get a smooth installation. **Bug fixes** - The Spotify application key was missing from the Python package. - Installation of the Python package as a normal user failed because it did not have permissions to install ``mopidy.desktop``. The file is now only installed if the installation is executed as the root user. v0.3.0 (2011-01-22) =================== Mopidy 0.3.0 brings a bunch of small changes all over the place, but no large changes. The main features are support for high bitrate audio from Spotify, and MPD password authentication. Regarding the docs, we've improved the :ref:`installation instructions ` and done a bit of testing of the available :ref:`Android ` and :ref:`iOS clients ` for MPD. Please note that 0.3.0 requires some updated dependencies, as listed under *Important changes* below. Also, there is a known bug in the Spotify playlist loading, as described below. As the bug will take some time to fix and has a known workaround, we did not want to delay the release while waiting for a fix to this problem. .. warning:: Known bug in Spotify playlist loading There is a known bug in the loading of Spotify playlists. This bug affects both Mopidy 0.2.1 and 0.3.0, given that you use libspotify 0.0.6. To avoid the bug, either use Mopidy 0.2.1 with libspotify 0.0.4, or use either Mopidy version with libspotify 0.0.6 and follow the simple workaround described at :issue:`59`. **Important changes** - If you use the Spotify backend, you need to upgrade to libspotify 0.0.6 and the latest pyspotify from the Mopidy developers. Follow the instructions at :ref:`installation`. - If you use the Last.fm frontend, you need to upgrade to pylast 0.5.7. Run ``sudo pip install --upgrade pylast`` or install Mopidy from APT. **Changes** - Spotify backend: - Support high bitrate (320k) audio. Set the new setting :attr:`mopidy.settings.SPOTIFY_HIGH_BITRATE` to :class:`True` to switch to high bitrate audio. - Rename :mod:`mopidy.backends.libspotify` to :mod:`mopidy.backends.spotify`. If you have set :attr:`mopidy.settings.BACKENDS` explicitly, you may need to update the setting's value. - Catch and log error caused by playlist folder boundaries being threated as normal playlists. More permanent fix requires support for checking playlist types in pyspotify (see :issue:`62`). - Fix crash on failed lookup of track by URI. (Fixes: :issue:`60`) - Local backend: - Add :command:`mopidy-scan` command to generate ``tag_cache`` files without any help from the original MPD server. See :ref:`generating-a-local-library` for instructions on how to use it. - Fix support for UTF-8 encoding in tag caches. - MPD frontend: - Add support for password authentication. See :attr:`mopidy.settings.MPD_SERVER_PASSWORD` for details on how to use it. (Fixes: :issue:`41`) - Support ``setvol 50`` without quotes around the argument. Fixes volume control in Droid MPD. - Support ``seek 1 120`` without quotes around the arguments. Fixes seek in Droid MPD. - Last.fm frontend: - Update to use Last.fm's new Scrobbling 2.0 API, as the old Submissions Protocol 1.2.1 is deprecated. (Fixes: :issue:`33`) - Fix crash when track object does not contain all the expected meta data. - Fix crash when response from Last.fm cannot be decoded as UTF-8. (Fixes: :issue:`37`) - Fix crash when response from Last.fm contains invalid XML. - Fix crash when response from Last.fm has an invalid HTTP status line. - Mixers: - Support use of unicode strings for settings specific to :mod:`mopidy.mixers.nad`. - Settings: - Automatically expand the "~" characted to the user's home directory and make the path absolute for settings with names ending in ``_PATH`` or ``_FILE``. - Rename the following settings. The settings validator will warn you if you need to change your local settings. - ``LOCAL_MUSIC_FOLDER`` to :attr:`mopidy.settings.LOCAL_MUSIC_PATH` - ``LOCAL_PLAYLIST_FOLDER`` to :attr:`mopidy.settings.LOCAL_PLAYLIST_PATH` - ``LOCAL_TAG_CACHE`` to :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` - ``SPOTIFY_LIB_CACHE`` to :attr:`mopidy.settings.SPOTIFY_CACHE_PATH` - Fix bug which made settings set to :class:`None` or 0 cause a :exc:`mopidy.SettingsError` to be raised. - Packaging and distribution: - Setup APT repository and create Debian packages of Mopidy. See :ref:`installation` for instructions for how to install Mopidy, including all dependencies, from APT. - Install ``mopidy.desktop`` file that makes Mopidy available from e.g. Gnome application menus. - API: - Rename and generalize ``Playlist._with(**kwargs)`` to :meth:`mopidy.models.ImmutableObject.copy`. - Add ``musicbrainz_id`` field to :class:`mopidy.models.Artist`, :class:`mopidy.models.Album`, and :class:`mopidy.models.Track`. - Prepare for multi-backend support (see :issue:`40`) by introducing the :ref:`provider concept `. Split the backend API into a :ref:`backend controller API ` (for frontend use) and a :ref:`backend provider API ` (for backend implementation use), which includes the following changes: - Rename ``BaseBackend`` to :class:`mopidy.backends.base.Backend`. - Rename ``BaseCurrentPlaylistController`` to :class:`mopidy.backends.base.CurrentPlaylistController`. - Split ``BaseLibraryController`` to :class:`mopidy.backends.base.LibraryController` and :class:`mopidy.backends.base.BaseLibraryProvider`. - Split ``BasePlaybackController`` to :class:`mopidy.backends.base.PlaybackController` and :class:`mopidy.backends.base.BasePlaybackProvider`. - Split ``BaseStoredPlaylistsController`` to :class:`mopidy.backends.base.StoredPlaylistsController` and :class:`mopidy.backends.base.BaseStoredPlaylistsProvider`. - Move ``BaseMixer`` to :class:`mopidy.mixers.base.BaseMixer`. - Add docs for the current non-stable output API, :class:`mopidy.outputs.base.BaseOutput`. v0.2.1 (2011-01-07) =================== This is a maintenance release without any new features. **Bug fixes** - Fix crash in :mod:`mopidy.frontends.lastfm` which occurred at playback if either :mod:`pylast` was not installed or the Last.fm scrobbling was not correctly configured. The scrobbling thread now shuts properly down at failure. v0.2.0 (2010-10-24) =================== In Mopidy 0.2.0 we've added a `Last.fm `_ scrobbling support, which means that Mopidy now can submit meta data about the tracks you play to your Last.fm profile. See :mod:`mopidy.frontends.lastfm` for details on new dependencies and settings. If you use Mopidy's Last.fm support, please join the `Mopidy group at Last.fm `_. With the exception of the work on the Last.fm scrobbler, there has been a couple of quiet months in the Mopidy camp. About the only thing going on, has been stabilization work and bug fixing. All bugs reported on GitHub, plus some, have been fixed in 0.2.0. Thus, we hope this will be a great release! We've worked a bit on OS X support, but not all issues are completely solved yet. :issue:`25` is the one that is currently blocking OS X support. Any help solving it will be greatly appreciated! Finally, please :ref:`update your pyspotify installation ` when upgrading to Mopidy 0.2.0. The latest pyspotify got a fix for the segmentation fault that occurred when playing music and searching at the same time, thanks to Valentin David. **Important changes** - Added a Last.fm scrobbler. See :mod:`mopidy.frontends.lastfm` for details. **Changes** - Logging and command line options: - Simplify the default log format, :attr:`mopidy.settings.CONSOLE_LOG_FORMAT`. From a user's point of view: Less noise, more information. - Rename the :option:`--dump` command line option to :option:`--save-debug-log`. - Rename setting :attr:`mopidy.settings.DUMP_LOG_FORMAT` to :attr:`mopidy.settings.DEBUG_LOG_FORMAT` and use it for :option:`--verbose` too. - Rename setting :attr:`mopidy.settings.DUMP_LOG_FILENAME` to :attr:`mopidy.settings.DEBUG_LOG_FILENAME`. - MPD frontend: - MPD command ``list`` now supports queries by artist, album name, and date, as used by e.g. the Ario client. (Fixes: :issue:`20`) - MPD command ``add ""`` and ``addid ""`` now behaves as expected. (Fixes :issue:`16`) - MPD command ``playid "-1"`` now correctly resumes playback if paused. - Random mode: - Fix wrong behavior on end of track and next after random mode has been used. (Fixes: :issue:`18`) - Fix infinite recursion loop crash on playback of non-playable tracks when in random mode. (Fixes :issue:`17`) - Fix assertion error that happened if one removed tracks from the current playlist, while in random mode. (Fixes :issue:`22`) - Switched from using subprocesses to threads. (Fixes: :issue:`14`) - :mod:`mopidy.outputs.gstreamer`: Set ``caps`` on the ``appsrc`` bin before use. This makes sound output work with GStreamer >= 0.10.29, which includes the versions used in Ubuntu 10.10 and on OS X if using Homebrew. (Fixes: :issue:`21`, :issue:`24`, contributes to :issue:`14`) - Improved handling of uncaught exceptions in threads. The entire process should now exit immediately. v0.1.0 (2010-08-23) =================== After three weeks of long nights and sprints we're finally pleased enough with the state of Mopidy to remove the alpha label, and do a regular release. Mopidy 0.1.0 got important improvements in search functionality, working track position seeking, no known stability issues, and greatly improved MPD client support. There are lots of changes since 0.1.0a3, and we urge you to at least read the *important changes* below. This release does not support OS X. We're sorry about that, and are working on fixing the OS X issues for a future release. You can track the progress at :issue:`14`. **Important changes** - License changed from GPLv2 to Apache License, version 2.0. - GStreamer is now a required dependency. See our :ref:`GStreamer installation docs `. - :mod:`mopidy.backends.libspotify` is now the default backend. :mod:`mopidy.backends.despotify` is no longer available. This means that you need to install the :ref:`dependencies for libspotify `. - If you used :mod:`mopidy.backends.libspotify` previously, pyspotify must be updated when updating to this release, to get working seek functionality. - :attr:`mopidy.settings.SERVER_HOSTNAME` and :attr:`mopidy.settings.SERVER_PORT` has been renamed to :attr:`mopidy.settings.MPD_SERVER_HOSTNAME` and :attr:`mopidy.settings.MPD_SERVER_PORT` to allow for multiple frontends in the future. **Changes** - Exit early if not Python >= 2.6, < 3. - Validate settings at startup and print useful error messages if the settings has not been updated or anything is misspelled. - Add command line option :option:`--list-settings` to print the currently active settings. - Include Sphinx scripts for building docs, pylintrc, tests and test data in the packages created by ``setup.py`` for i.e. PyPI. - MPD frontend: - Search improvements, including support for multi-word search. - Fixed ``play "-1"`` and ``playid "-1"`` behaviour when playlist is empty or when a current track is set. - Support ``plchanges "-1"`` to work better with MPDroid. - Support ``pause`` without arguments to work better with MPDroid. - Support ``plchanges``, ``play``, ``consume``, ``random``, ``repeat``, and ``single`` without quotes to work better with BitMPC. - Fixed deletion of the currently playing track from the current playlist, which crashed several clients. - Implement ``seek`` and ``seekid``. - Fix ``playlistfind`` output so the correct song is played when playing songs directly from search results in GMPC. - Fix ``load`` so that one can append a playlist to the current playlist, and make it return the correct error message if the playlist is not found. - Support for single track repeat added. (Fixes: :issue:`4`) - Relocate from :mod:`mopidy.mpd` to :mod:`mopidy.frontends.mpd`. - Split gigantic protocol implementation into eleven modules. - Rename ``mopidy.frontends.mpd.{serializer => translator}`` to match naming in backends. - Remove setting :attr:`mopidy.settings.SERVER` and :attr:`mopidy.settings.FRONTEND` in favour of the new :attr:`mopidy.settings.FRONTENDS`. - Run MPD server in its own process. - Backends: - Rename :mod:`mopidy.backends.gstreamer` to :mod:`mopidy.backends.local`. - Remove :mod:`mopidy.backends.despotify`, as Despotify is little maintained and the Libspotify backend is working much better. (Fixes: :issue:`9`, :issue:`10`, :issue:`13`) - A Spotify application key is now bundled with the source. :attr:`mopidy.settings.SPOTIFY_LIB_APPKEY` is thus removed. - If failing to play a track, playback will skip to the next track. - Both :mod:`mopidy.backends.libspotify` and :mod:`mopidy.backends.local` have been rewritten to use the new common GStreamer audio output module, :mod:`mopidy.outputs.gstreamer`. - Mixers: - Added new :mod:`mopidy.mixers.gstreamer_software.GStreamerSoftwareMixer` which now is the default mixer on all platforms. - New setting :attr:`mopidy.settings.MIXER_MAX_VOLUME` for capping the maximum output volume. - Backend API: - Relocate from :mod:`mopidy.backends` to :mod:`mopidy.backends.base`. - The ``id`` field of :class:`mopidy.models.Track` has been removed, as it is no longer needed after the CPID refactoring. - :meth:`mopidy.backends.base.BaseBackend()` now accepts an ``output_queue`` which it can use to send messages (i.e. audio data) to the output process. - :meth:`mopidy.backends.base.BaseLibraryController.find_exact()` now accepts keyword arguments of the form ``find_exact(artist=['foo'], album=['bar'])``. - :meth:`mopidy.backends.base.BaseLibraryController.search()` now accepts keyword arguments of the form ``search(artist=['foo', 'fighters'], album=['bar', 'grooves'])``. - :meth:`mopidy.backends.base.BaseCurrentPlaylistController.append()` replaces :meth:`mopidy.backends.base.BaseCurrentPlaylistController.load()`. Use :meth:`mopidy.backends.base.BaseCurrentPlaylistController.clear()` if you want to clear the current playlist. - The following fields in :class:`mopidy.backends.base.BasePlaybackController` has been renamed to reflect their relation to methods called on the controller: - ``next_track`` to ``track_at_next`` - ``next_cp_track`` to ``cp_track_at_next`` - ``previous_track`` to ``track_at_previous`` - ``previous_cp_track`` to ``cp_track_at_previous`` - :attr:`mopidy.backends.base.BasePlaybackController.track_at_eot` and :attr:`mopidy.backends.base.BasePlaybackController.cp_track_at_eot` has been added to better handle the difference between the user pressing next and the current track ending. - Rename :meth:`mopidy.backends.base.BasePlaybackController.new_playlist_loaded_callback()` to :meth:`mopidy.backends.base.BasePlaybackController.on_current_playlist_change()`. - Rename :meth:`mopidy.backends.base.BasePlaybackController.end_of_track_callback()` to :meth:`mopidy.backends.base.BasePlaybackController.on_end_of_track()`. - Remove :meth:`mopidy.backends.base.BaseStoredPlaylistsController.search()` since it was barely used, untested, and we got no use case for non-exact search in stored playlists yet. Use :meth:`mopidy.backends.base.BaseStoredPlaylistsController.get()` instead. v0.1.0a3 (2010-08-03) ===================== In the last two months, Mopidy's MPD frontend has gotten lots of stability fixes and error handling improvements, proper support for having the same track multiple times in a playlist, and support for IPv6. We have also fixed the choppy playback on the libspotify backend. For the road ahead of us, we got an updated release roadmap with our goals for the 0.1 to 0.3 releases. Enjoy the best alpha relase of Mopidy ever :-) **Changes** - MPD frontend: - Support IPv6. - ``addid`` responds properly on errors instead of crashing. - ``commands`` support, which makes RelaXXPlayer work with Mopidy. (Fixes: :issue:`6`) - Does no longer crash on invalid data, i.e. non-UTF-8 data. - ``ACK`` error messages are now MPD-compliant, which should make clients handle errors from Mopidy better. - Requests to existing commands with wrong arguments are no longer reported as unknown commands. - ``command_list_end`` before ``command_list_start`` now returns unknown command error instead of crashing. - ``list`` accepts field argument without quotes and capitalized, to work with GMPC and ncmpc. - ``noidle`` command now returns ``OK`` instead of an error. Should make some clients work a bit better. - Having multiple identical tracks in a playlist is now working properly. (CPID refactoring) - Despotify backend: - Catch and log :exc:`spytify.SpytifyError`. (Fixes: :issue:`11`) - Libspotify backend: - Fix choppy playback using the Libspotify backend by using blocking ALSA mode. (Fixes: :issue:`7`) - Backend API: - A new data structure called ``cp_track`` is now used in the current playlist controller and the playback controller. A ``cp_track`` is a two-tuple of (CPID integer, :class:`mopidy.models.Track`), identifying an instance of a track uniquely within the current playlist. - :meth:`mopidy.backends.BaseCurrentPlaylistController.load()` now accepts lists of :class:`mopidy.models.Track` instead of :class:`mopidy.models.Playlist`, as none of the other fields on the ``Playlist`` model was in use. - :meth:`mopidy.backends.BaseCurrentPlaylistController.add()` now returns the ``cp_track`` added to the current playlist. - :meth:`mopidy.backends.BaseCurrentPlaylistController.remove()` now takes criterias, just like :meth:`mopidy.backends.BaseCurrentPlaylistController.get()`. - :meth:`mopidy.backends.BaseCurrentPlaylistController.get()` now returns a ``cp_track``. - :attr:`mopidy.backends.BaseCurrentPlaylistController.tracks` is now read-only. Use the methods to change its contents. - :attr:`mopidy.backends.BaseCurrentPlaylistController.cp_tracks` is a read-only list of ``cp_track``. Use the methods to change its contents. - :attr:`mopidy.backends.BasePlaybackController.current_track` is now just for convenience and read-only. To set the current track, assign a ``cp_track`` to :attr:`mopidy.backends.BasePlaybackController.current_cp_track`. - :attr:`mopidy.backends.BasePlaybackController.current_cpid` is the read-only CPID of the current track. - :attr:`mopidy.backends.BasePlaybackController.next_cp_track` is the next ``cp_track`` in the playlist. - :attr:`mopidy.backends.BasePlaybackController.previous_cp_track` is the previous ``cp_track`` in the playlist. - :meth:`mopidy.backends.BasePlaybackController.play()` now takes a ``cp_track``. v0.1.0a2 (2010-06-02) ===================== It has been a rather slow month for Mopidy, but we would like to keep up with the established pace of at least a release per month. **Changes** - Improvements to MPD protocol handling, making Mopidy work much better with a group of clients, including ncmpc, MPoD, and Theremin. - New command line flag :option:`--dump` for dumping debug log to ``dump.log`` in the current directory. - New setting :attr:`mopidy.settings.MIXER_ALSA_CONTROL` for forcing what ALSA control :class:`mopidy.mixers.alsa.AlsaMixer` should use. v0.1.0a1 (2010-05-04) ===================== Since the previous release Mopidy has seen about 300 commits, more than 200 new tests, a libspotify release, and major feature additions to Spotify. The new releases from Spotify have lead to updates to our dependencies, and also to new bugs in Mopidy. Thus, this is primarily a bugfix release, even though the not yet finished work on a GStreamer backend have been merged. All users are recommended to upgrade to 0.1.0a1, and should at the same time ensure that they have the latest versions of our dependencies: Despotify r508 if you are using DespotifyBackend, and pyspotify 1.1 with libspotify 0.0.4 if you are using LibspotifyBackend. As always, report problems at our IRC channel or our issue tracker. Thanks! **Changes** - Backend API changes: - Removed ``backend.playback.volume`` wrapper. Use ``backend.mixer.volume`` directly. - Renamed ``backend.playback.playlist_position`` to ``current_playlist_position`` to match naming of ``current_track``. - Replaced ``get_by_id()`` with a more flexible ``get(**criteria)``. - Merged the ``gstreamer`` branch from Thomas Adamcik: - More than 200 new tests, and thus several bug fixes to existing code. - Several new generic features, like shuffle, consume, and playlist repeat. (Fixes: :issue:`3`) - **[Work in Progress]** A new backend for playing music from a local music archive using the GStreamer library. - Made :class:`mopidy.mixers.alsa.AlsaMixer` work on machines without a mixer named "Master". - Make :class:`mopidy.backends.DespotifyBackend` ignore local files in playlists (feature added in Spotify 0.4.3). Reported by Richard Haugen Olsen. - And much more. v0.1.0a0 (2010-03-27) ===================== "*Release early. Release often. Listen to your customers.*" wrote Eric S. Raymond in *The Cathedral and the Bazaar*. Three months of development should be more than enough. We have more to do, but Mopidy is working and usable. 0.1.0a0 is an alpha release, which basicly means we will still change APIs, add features, etc. before the final 0.1.0 release. But the software is usable as is, so we release it. Please give it a try and give us feedback, either at our IRC channel or through the `issue tracker `_. Thanks! **Changes** - Initial version. No changelog available. Mopidy-2.0.0/docs/index.rst0000664000175000017500000001016712660436420015761 0ustar jodaljodal00000000000000****** Mopidy ****** Mopidy is an extensible music server written in Python. Mopidy plays music from local disk, Spotify, SoundCloud, Google Play Music, and more. You edit the playlist from any phone, tablet, or computer using a range of MPD and web clients. **Stream music from the cloud** Vanilla Mopidy only plays music from your :ref:`local disk ` and :ref:`radio streams `. Through :ref:`extensions `, Mopidy can play music from cloud services like Spotify, SoundCloud, and Google Play Music. With Mopidy's extension support, backends for new music sources can be easily added. **Mopidy is just a server** Mopidy is a Python application that runs in a terminal or in the background on Linux computers or Macs that have network connectivity and audio output. Out of the box, Mopidy is an :ref:`MPD ` and :ref:`HTTP ` server. :ref:`Additional frontends ` for controlling Mopidy can be installed from extensions. **Everybody use their favorite client** You and the people around you can all connect their favorite :ref:`MPD ` or :ref:`web client ` to the Mopidy server to search for music and manage the playlist together. With a browser or MPD client, which is available for all popular operating systems, you can control the music from any phone, tablet, or computer. **Mopidy on Raspberry Pi** The :ref:`Raspberry Pi ` is a popular device to run Mopidy on, either using Raspbian or Arch Linux. It is quite slow, but it is very affordable. In fact, the Kickstarter funded Gramofon: Modern Cloud Jukebox project used Mopidy on a Raspberry Pi to prototype the Gramofon device. Mopidy is also a major building block in the Pi Musicbox integrated audio jukebox system for Raspberry Pi. **Mopidy is hackable** Mopidy's extension support and :ref:`Python `, :ref:`JSON-RPC `, and :ref:`JavaScript APIs ` makes Mopidy perfect for building your own hacks. In one project, a Raspberry Pi was embedded in an old cassette player. The buttons and volume control are wired up with GPIO on the Raspberry Pi, and is used to control playback through a custom Mopidy extension. The cassettes have NFC tags used to select playlists from Spotify. **Getting started** To get started with Mopidy, start by reading :ref:`installation`. .. _getting-help: **Getting help** If you get stuck, you can get help at the `Mopidy discussion forum `_. We also hang around at IRC on the ``#mopidy`` channel at `irc.freenode.net `_. The IRC channel has `public searchable logs `_. If you stumble into a bug or have a feature request, please create an issue in the `issue tracker `_. If you're unsure if it's a bug or not, ask for help in the forum or at IRC first. The `source code `_ may also be of help. If you want to stay up to date on Mopidy developments, you can follow `@mopidy `_ on Twitter. There's also a `mailing list `_ used for announcements related to Mopidy and Mopidy extensions. .. toctree:: :caption: Usage :maxdepth: 2 installation/index config running service audio troubleshooting .. _ext: .. toctree:: :caption: Extensions :maxdepth: 2 ext/local ext/file ext/m3u ext/stream ext/http ext/mpd ext/softwaremixer ext/mixers ext/backends ext/frontends ext/web .. toctree:: :caption: Clients :maxdepth: 2 clients/http clients/mpd clients/mpris clients/upnp .. toctree:: :caption: About :maxdepth: 1 authors sponsors changelog versioning .. toctree:: :caption: Development :maxdepth: 2 contributing devenv releasing codestyle extensiondev .. toctree:: :caption: Reference :maxdepth: 2 glossary command api/index modules/index Indices and tables ================== * :ref:`genindex` * :ref:`modindex` Mopidy-2.0.0/README.rst0000664000175000017500000000623012660436420014653 0ustar jodaljodal00000000000000****** Mopidy ****** Mopidy is an extensible music server written in Python. Mopidy plays music from local disk, Spotify, SoundCloud, Google Play Music, and more. You edit the playlist from any phone, tablet, or computer using a range of MPD and web clients. **Stream music from the cloud** Vanilla Mopidy only plays music from your local disk and radio streams. Through extensions, Mopidy can play music from cloud services like Spotify, SoundCloud, and Google Play Music. With Mopidy's extension support, backends for new music sources can be easily added. **Mopidy is just a server** Mopidy is a Python application that runs in a terminal or in the background on Linux computers or Macs that have network connectivity and audio output. Out of the box, Mopidy is an MPD and HTTP server. Additional frontends for controlling Mopidy can be installed from extensions. **Everybody use their favorite client** You and the people around you can all connect their favorite MPD or web client to the Mopidy server to search for music and manage the playlist together. With a browser or MPD client, which is available for all popular operating systems, you can control the music from any phone, tablet, or computer. **Mopidy on Raspberry Pi** The Raspberry Pi is a popular device to run Mopidy on, either using Raspbian or Arch Linux. It is quite slow, but it is very affordable. In fact, the Kickstarter funded Gramofon: Modern Cloud Jukebox project used Mopidy on a Raspberry Pi to prototype the Gramofon device. Mopidy is also a major building block in the Pi Musicbox integrated audio jukebox system for Raspberry Pi. **Mopidy is hackable** Mopidy's extension support and Python, JSON-RPC, and JavaScript APIs makes Mopidy perfect for building your own hacks. In one project, a Raspberry Pi was embedded in an old cassette player. The buttons and volume control are wired up with GPIO on the Raspberry Pi, and is used to control playback through a custom Mopidy extension. The cassettes have NFC tags used to select playlists from Spotify. To get started with Mopidy, check out `the installation docs `_. - `Documentation `_ - `Discussion forum `_ - `Source code `_ - `Issue tracker `_ - IRC: ``#mopidy`` at `irc.freenode.net `_ - Announcement list: `mopidy@googlegroups.com `_ - Twitter: `@mopidy `_ .. image:: https://img.shields.io/pypi/v/Mopidy.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy/ :alt: Latest PyPI version .. image:: https://img.shields.io/pypi/dm/Mopidy.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy/ :alt: Number of PyPI downloads .. image:: https://img.shields.io/travis/mopidy/mopidy/develop.svg?style=flat :target: https://travis-ci.org/mopidy/mopidy :alt: Travis CI build status .. image:: https://img.shields.io/coveralls/mopidy/mopidy/develop.svg?style=flat :target: https://coveralls.io/r/mopidy/mopidy?branch=develop :alt: Test coverage Mopidy-2.0.0/setup.cfg0000664000175000017500000000026312660436443015012 0ustar jodaljodal00000000000000[flake8] application-import-names = mopidy,tests exclude = .git,.tox,build,js,tmp ignore = E402 [wheel] universal = 1 [egg_info] tag_build = tag_date = 0 tag_svn_revision = 0 Mopidy-2.0.0/.mailmap0000664000175000017500000000310312660436420014601 0ustar jodaljodal00000000000000Thomas Adamcik Thomas Adamcik Thomas Adamcik Thomas Adacmik Kristian Klette Johannes Knutsen Johannes Knutsen John Bäckstrand David Caruso Adam Rigg Ernst Bammer Alli Witheford Alexandre Petitjean Alexandre Petitjean Javier Domingo Cansino Lasse Bigum Nick Steel Janez Troha Janez Troha Luke Giuliani Colin Montgomerie Nathan Harper Ignasi Fosch Christopher Schirner Laura Barber John Cass Ronald Zielaznicki Kyle Heyne Tom Roth Eric Jahn Loïck Bonniot Mopidy-2.0.0/tox.ini0000664000175000017500000000172512660436420014503 0ustar jodaljodal00000000000000[tox] envlist = py27, py27-tornado23, py27-tornado31, docs, flake8 [testenv] sitepackages = true commands = py.test \ --basetemp={envtmpdir} \ --cov=mopidy --cov-report=term-missing \ -n 4 \ {posargs} deps = mock pytest pytest-capturelog pytest-cov pytest-xdist responses [testenv:py27-tornado23] commands = py.test tests/http deps = {[testenv]deps} tornado==2.3 [testenv:py27-tornado31] commands = py.test tests/http deps = {[testenv]deps} tornado==3.1.1 [testenv:docs] deps = -r{toxinidir}/docs/requirements.txt changedir = docs commands = sphinx-build -b html -d {envtmpdir}/doctrees . {envtmpdir}/html [testenv:flake8] deps = flake8 flake8-import-order pep8-naming commands = flake8 --show-source --statistics mopidy tests [testenv:linkcheck] deps = -r{toxinidir}/docs/requirements.txt changedir = docs commands = sphinx-build -b linkcheck -d {envtmpdir}/doctrees . {envtmpdir}/html Mopidy-2.0.0/MANIFEST.in0000664000175000017500000000053412600333733014720 0ustar jodaljodal00000000000000include *.py include *.rst include *.txt include .mailmap include .travis.yml include AUTHORS include LICENSE include MANIFEST.in include tox.ini recursive-include docs * prune docs/_build recursive-include extra * recursive-include mopidy *.conf recursive-include mopidy/http/data * recursive-include tests *.py recursive-include tests/data * Mopidy-2.0.0/LICENSE0000644000175000017500000002613612441116637014200 0ustar jodaljodal00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. Mopidy-2.0.0/tests/0000775000175000017500000000000012660436443014332 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/test_exceptions.py0000664000175000017500000000312712575004517020125 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest from mopidy import exceptions class ExceptionsTest(unittest.TestCase): def test_exception_can_include_message_string(self): exc = exceptions.MopidyException('foo') self.assertEqual(exc.message, 'foo') self.assertEqual(str(exc), 'foo') def test_backend_error_is_a_mopidy_exception(self): self.assert_(issubclass( exceptions.BackendError, exceptions.MopidyException)) def test_extension_error_is_a_mopidy_exception(self): self.assert_(issubclass( exceptions.ExtensionError, exceptions.MopidyException)) def test_find_error_is_a_mopidy_exception(self): self.assert_(issubclass( exceptions.FindError, exceptions.MopidyException)) def test_find_error_can_store_an_errno(self): exc = exceptions.FindError('msg', errno=1234) self.assertEqual(exc.message, 'msg') self.assertEqual(exc.errno, 1234) def test_frontend_error_is_a_mopidy_exception(self): self.assert_(issubclass( exceptions.FrontendError, exceptions.MopidyException)) def test_mixer_error_is_a_mopidy_exception(self): self.assert_(issubclass( exceptions.MixerError, exceptions.MopidyException)) def test_scanner_error_is_a_mopidy_exception(self): self.assert_(issubclass( exceptions.ScannerError, exceptions.MopidyException)) def test_audio_error_is_a_mopidy_exception(self): self.assert_(issubclass( exceptions.AudioException, exceptions.MopidyException)) Mopidy-2.0.0/tests/backend/0000775000175000017500000000000012660436443015721 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/backend/__init__.py0000664000175000017500000000007112505224626020024 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals Mopidy-2.0.0/tests/backend/test_backend.py0000664000175000017500000000267612505224626020730 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest from mopidy import backend, models from tests import dummy_backend class LibraryTest(unittest.TestCase): def test_default_get_images_impl_falls_back_to_album_image(self): album = models.Album(images=['imageuri']) track = models.Track(uri='trackuri', album=album) library = dummy_backend.DummyLibraryProvider(backend=None) library.dummy_library.append(track) expected = {'trackuri': [models.Image(uri='imageuri')]} self.assertEqual(library.get_images(['trackuri']), expected) def test_default_get_images_impl_no_album_image(self): # default implementation now returns an empty list if no # images are found, though it's not required to track = models.Track(uri='trackuri') library = dummy_backend.DummyLibraryProvider(backend=None) library.dummy_library.append(track) expected = {'trackuri': []} self.assertEqual(library.get_images(['trackuri']), expected) class PlaylistsTest(unittest.TestCase): def setUp(self): # noqa: N802 self.provider = backend.PlaylistsProvider(backend=None) def test_as_list_default_impl(self): with self.assertRaises(NotImplementedError): self.provider.as_list() def test_get_items_default_impl(self): with self.assertRaises(NotImplementedError): self.provider.get_items('some uri') Mopidy-2.0.0/tests/backend/test_listener.py0000664000175000017500000000110712575004517021154 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest import mock from mopidy import backend class BackendListenerTest(unittest.TestCase): def setUp(self): # noqa: N802 self.listener = backend.BackendListener() def test_on_event_forwards_to_specific_handler(self): self.listener.playlists_loaded = mock.Mock() self.listener.on_event('playlists_loaded') self.listener.playlists_loaded.assert_called_with() def test_listener_has_default_impl_for_playlists_loaded(self): self.listener.playlists_loaded() Mopidy-2.0.0/tests/dummy_mixer.py0000664000175000017500000000130712505224626017240 0ustar jodaljodal00000000000000from __future__ import unicode_literals import pykka from mopidy import mixer def create_proxy(config=None): return DummyMixer.start(config=None).proxy() class DummyMixer(pykka.ThreadingActor, mixer.Mixer): def __init__(self, config): super(DummyMixer, self).__init__() self._volume = None self._mute = None def get_volume(self): return self._volume def set_volume(self, volume): self._volume = volume self.trigger_volume_changed(volume=volume) return True def get_mute(self): return self._mute def set_mute(self, mute): self._mute = mute self.trigger_mute_changed(mute=mute) return True Mopidy-2.0.0/tests/test_httpclient.py0000664000175000017500000000306512653464377020136 0ustar jodaljodal00000000000000from __future__ import unicode_literals import re import pytest from mopidy import httpclient @pytest.mark.parametrize("config,expected", [ ({}, None), ({'hostname': ''}, None), ({'hostname': 'proxy.lan'}, 'http://proxy.lan:80'), ({'scheme': None, 'hostname': 'proxy.lan'}, 'http://proxy.lan:80'), ({'scheme': 'https', 'hostname': 'proxy.lan'}, 'https://proxy.lan:80'), ({'username': 'user', 'hostname': 'proxy.lan'}, 'http://proxy.lan:80'), ({'password': 'pass', 'hostname': 'proxy.lan'}, 'http://proxy.lan:80'), ({'hostname': 'proxy.lan', 'port': 8080}, 'http://proxy.lan:8080'), ({'hostname': 'proxy.lan', 'port': -1}, 'http://proxy.lan:80'), ({'hostname': 'proxy.lan', 'port': None}, 'http://proxy.lan:80'), ({'hostname': 'proxy.lan', 'port': ''}, 'http://proxy.lan:80'), ({'username': 'user', 'password': 'pass', 'hostname': 'proxy.lan'}, 'http://user:pass@proxy.lan:80'), ]) def test_format_proxy(config, expected): assert httpclient.format_proxy(config) == expected def test_format_proxy_without_auth(): config = {'username': 'user', 'password': 'pass', 'hostname': 'proxy.lan'} formated_proxy = httpclient.format_proxy(config, auth=False) assert formated_proxy == 'http://proxy.lan:80' @pytest.mark.parametrize("name,expected", [ (None, r'^Mopidy/[^ ]+ CPython|/[^ ]+$'), ('Foo', r'^Foo Mopidy/[^ ]+ CPython|/[^ ]+$'), ('Foo/1.2.3', r'^Foo/1.2.3 Mopidy/[^ ]+ CPython|/[^ ]+$'), ]) def test_format_user_agent(name, expected): assert re.match(expected, httpclient.format_user_agent(name)) Mopidy-2.0.0/tests/test_help.py0000664000175000017500000000161312575004517016672 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import os import subprocess import sys import unittest import mopidy class HelpTest(unittest.TestCase): def test_help_has_mopidy_options(self): mopidy_dir = os.path.dirname(mopidy.__file__) args = [sys.executable, mopidy_dir, '--help'] process = subprocess.Popen( args, env={'PYTHONPATH': ':'.join([ os.path.join(mopidy_dir, '..'), os.environ.get('PYTHONPATH', '') ])}, stdout=subprocess.PIPE) output = process.communicate()[0] self.assertIn('--version', output) self.assertIn('--help', output) self.assertIn('--quiet', output) self.assertIn('--verbose', output) self.assertIn('--save-debug-log', output) self.assertIn('--config', output) self.assertIn('--option', output) Mopidy-2.0.0/tests/test_version.py0000664000175000017500000000045512647257461017442 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest from distutils.version import StrictVersion from mopidy import __version__ class VersionTest(unittest.TestCase): def test_current_version_is_parsable_as_a_strict_version_number(self): StrictVersion(__version__) Mopidy-2.0.0/tests/test_commands.py0000664000175000017500000004171412575004517017551 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import argparse import unittest import mock from mopidy import commands class ConfigOverrideTypeTest(unittest.TestCase): def test_valid_override(self): expected = (b'section', b'key', b'value') self.assertEqual( expected, commands.config_override_type(b'section/key=value')) self.assertEqual( expected, commands.config_override_type(b'section/key=value ')) self.assertEqual( expected, commands.config_override_type(b'section/key =value')) self.assertEqual( expected, commands.config_override_type(b'section /key=value')) def test_valid_override_is_bytes(self): section, key, value = commands.config_override_type( b'section/key=value') self.assertIsInstance(section, bytes) self.assertIsInstance(key, bytes) self.assertIsInstance(value, bytes) def test_empty_override(self): expected = ('section', 'key', '') self.assertEqual( expected, commands.config_override_type(b'section/key=')) self.assertEqual( expected, commands.config_override_type(b'section/key= ')) def test_invalid_override(self): with self.assertRaises(argparse.ArgumentTypeError): commands.config_override_type(b'section/key') with self.assertRaises(argparse.ArgumentTypeError): commands.config_override_type(b'section=') with self.assertRaises(argparse.ArgumentTypeError): commands.config_override_type(b'section') class CommandParsingTest(unittest.TestCase): def setUp(self): # noqa: N802 self.exit_patcher = mock.patch.object(commands.Command, 'exit') self.exit_mock = self.exit_patcher.start() self.exit_mock.side_effect = SystemExit def tearDown(self): # noqa: N802 self.exit_patcher.stop() def test_command_parsing_returns_namespace(self): cmd = commands.Command() self.assertIsInstance(cmd.parse([]), argparse.Namespace) def test_command_parsing_does_not_contain_args(self): cmd = commands.Command() result = cmd.parse([]) self.assertFalse(hasattr(result, '_args')) def test_unknown_options_bails(self): cmd = commands.Command() with self.assertRaises(SystemExit): cmd.parse(['--foobar']) def test_invalid_sub_command_bails(self): cmd = commands.Command() with self.assertRaises(SystemExit): cmd.parse(['foo']) def test_command_arguments(self): cmd = commands.Command() cmd.add_argument('--bar') result = cmd.parse(['--bar', 'baz']) self.assertEqual(result.bar, 'baz') def test_command_arguments_and_sub_command(self): child = commands.Command() child.add_argument('--baz') cmd = commands.Command() cmd.add_argument('--bar') cmd.add_child('foo', child) result = cmd.parse(['--bar', 'baz', 'foo']) self.assertEqual(result.bar, 'baz') self.assertEqual(result.baz, None) def test_subcommand_may_have_positional(self): child = commands.Command() child.add_argument('bar') cmd = commands.Command() cmd.add_child('foo', child) result = cmd.parse(['foo', 'baz']) self.assertEqual(result.bar, 'baz') def test_subcommand_may_have_remainder(self): child = commands.Command() child.add_argument('bar', nargs=argparse.REMAINDER) cmd = commands.Command() cmd.add_child('foo', child) result = cmd.parse(['foo', 'baz', 'bep', 'bop']) self.assertEqual(result.bar, ['baz', 'bep', 'bop']) def test_result_stores_choosen_command(self): child = commands.Command() cmd = commands.Command() cmd.add_child('foo', child) result = cmd.parse(['foo']) self.assertEqual(result.command, child) result = cmd.parse([]) self.assertEqual(result.command, cmd) child2 = commands.Command() cmd.add_child('bar', child2) subchild = commands.Command() child.add_child('baz', subchild) result = cmd.parse(['bar']) self.assertEqual(result.command, child2) result = cmd.parse(['foo', 'baz']) self.assertEqual(result.command, subchild) def test_invalid_type(self): cmd = commands.Command() cmd.add_argument('--bar', type=int) with self.assertRaises(SystemExit): cmd.parse(['--bar', b'zero'], prog='foo') self.exit_mock.assert_called_once_with( 1, "argument --bar: invalid int value: 'zero'", 'usage: foo [--bar BAR]') @mock.patch('sys.argv') def test_command_error_usage_prog(self, argv_mock): argv_mock.__getitem__.return_value = '/usr/bin/foo' cmd = commands.Command() cmd.add_argument('--bar', required=True) with self.assertRaises(SystemExit): cmd.parse([]) self.exit_mock.assert_called_once_with( mock.ANY, mock.ANY, 'usage: foo --bar BAR') self.exit_mock.reset_mock() with self.assertRaises(SystemExit): cmd.parse([], prog='baz') self.exit_mock.assert_called_once_with( mock.ANY, mock.ANY, 'usage: baz --bar BAR') def test_missing_required(self): cmd = commands.Command() cmd.add_argument('--bar', required=True) with self.assertRaises(SystemExit): cmd.parse([], prog='foo') self.exit_mock.assert_called_once_with( 1, 'argument --bar is required', 'usage: foo --bar BAR') def test_missing_positionals(self): cmd = commands.Command() cmd.add_argument('bar') with self.assertRaises(SystemExit): cmd.parse([], prog='foo') self.exit_mock.assert_called_once_with( 1, 'too few arguments', 'usage: foo bar') def test_missing_positionals_subcommand(self): child = commands.Command() child.add_argument('baz') cmd = commands.Command() cmd.add_child('bar', child) with self.assertRaises(SystemExit): cmd.parse(['bar'], prog='foo') self.exit_mock.assert_called_once_with( 1, 'too few arguments', 'usage: foo bar baz') def test_unknown_command(self): cmd = commands.Command() with self.assertRaises(SystemExit): cmd.parse(['--help'], prog='foo') self.exit_mock.assert_called_once_with( 1, 'unrecognized arguments: --help', 'usage: foo') def test_invalid_subcommand(self): cmd = commands.Command() cmd.add_child('baz', commands.Command()) with self.assertRaises(SystemExit): cmd.parse(['bar'], prog='foo') self.exit_mock.assert_called_once_with( 1, 'unrecognized command: bar', 'usage: foo') def test_set(self): cmd = commands.Command() cmd.set(foo='bar') result = cmd.parse([]) self.assertEqual(result.foo, 'bar') def test_set_propegate(self): child = commands.Command() cmd = commands.Command() cmd.set(foo='bar') cmd.add_child('command', child) result = cmd.parse(['command']) self.assertEqual(result.foo, 'bar') def test_innermost_set_wins(self): child = commands.Command() child.set(foo='bar', baz=1) cmd = commands.Command() cmd.set(foo='baz', baz=None) cmd.add_child('command', child) result = cmd.parse(['command']) self.assertEqual(result.foo, 'bar') self.assertEqual(result.baz, 1) def test_help_action_works(self): cmd = commands.Command() cmd.add_argument('-h', action='help') cmd.format_help = mock.Mock() with self.assertRaises(SystemExit): cmd.parse(['-h']) cmd.format_help.assert_called_once_with(mock.ANY) self.exit_mock.assert_called_once_with(0, cmd.format_help.return_value) class UsageTest(unittest.TestCase): @mock.patch('sys.argv') def test_prog_name_default_and_override(self, argv_mock): argv_mock.__getitem__.return_value = '/usr/bin/foo' cmd = commands.Command() self.assertEqual('usage: foo', cmd.format_usage().strip()) self.assertEqual('usage: baz', cmd.format_usage('baz').strip()) def test_basic_usage(self): cmd = commands.Command() self.assertEqual('usage: foo', cmd.format_usage('foo').strip()) cmd.add_argument('-h', '--help', action='store_true') self.assertEqual('usage: foo [-h]', cmd.format_usage('foo').strip()) cmd.add_argument('bar') self.assertEqual('usage: foo [-h] bar', cmd.format_usage('foo').strip()) def test_nested_usage(self): child = commands.Command() cmd = commands.Command() cmd.add_child('bar', child) self.assertEqual('usage: foo', cmd.format_usage('foo').strip()) self.assertEqual('usage: foo bar', cmd.format_usage('foo bar').strip()) cmd.add_argument('-h', '--help', action='store_true') self.assertEqual('usage: foo bar', child.format_usage('foo bar').strip()) child.add_argument('-h', '--help', action='store_true') self.assertEqual('usage: foo bar [-h]', child.format_usage('foo bar').strip()) class HelpTest(unittest.TestCase): @mock.patch('sys.argv') def test_prog_name_default_and_override(self, argv_mock): argv_mock.__getitem__.return_value = '/usr/bin/foo' cmd = commands.Command() self.assertEqual('usage: foo', cmd.format_help().strip()) self.assertEqual('usage: bar', cmd.format_help('bar').strip()) def test_command_without_documenation_or_options(self): cmd = commands.Command() self.assertEqual('usage: bar', cmd.format_help('bar').strip()) def test_command_with_option(self): cmd = commands.Command() cmd.add_argument('-h', '--help', action='store_true', help='show this message') expected = ('usage: foo [-h]\n\n' 'OPTIONS:\n\n' ' -h, --help show this message') self.assertEqual(expected, cmd.format_help('foo').strip()) def test_command_with_option_and_positional(self): cmd = commands.Command() cmd.add_argument('-h', '--help', action='store_true', help='show this message') cmd.add_argument('bar', help='some help text') expected = ('usage: foo [-h] bar\n\n' 'OPTIONS:\n\n' ' -h, --help show this message\n' ' bar some help text') self.assertEqual(expected, cmd.format_help('foo').strip()) def test_command_with_documentation(self): cmd = commands.Command() cmd.help = 'some text about everything this command does.' expected = ('usage: foo\n\n' 'some text about everything this command does.') self.assertEqual(expected, cmd.format_help('foo').strip()) def test_command_with_documentation_and_option(self): cmd = commands.Command() cmd.help = 'some text about everything this command does.' cmd.add_argument('-h', '--help', action='store_true', help='show this message') expected = ('usage: foo [-h]\n\n' 'some text about everything this command does.\n\n' 'OPTIONS:\n\n' ' -h, --help show this message') self.assertEqual(expected, cmd.format_help('foo').strip()) def test_subcommand_without_documentation_or_options(self): child = commands.Command() cmd = commands.Command() cmd.add_child('bar', child) self.assertEqual('usage: foo', cmd.format_help('foo').strip()) def test_subcommand_with_documentation_shown(self): child = commands.Command() child.help = 'some text about everything this command does.' cmd = commands.Command() cmd.add_child('bar', child) expected = ('usage: foo\n\n' 'COMMANDS:\n\n' 'bar\n\n' ' some text about everything this command does.') self.assertEqual(expected, cmd.format_help('foo').strip()) def test_subcommand_with_options_shown(self): child = commands.Command() child.add_argument('-h', '--help', action='store_true', help='show this message') cmd = commands.Command() cmd.add_child('bar', child) expected = ('usage: foo\n\n' 'COMMANDS:\n\n' 'bar [-h]\n\n' ' -h, --help show this message') self.assertEqual(expected, cmd.format_help('foo').strip()) def test_subcommand_with_positional_shown(self): child = commands.Command() child.add_argument('baz', help='the great and wonderful') cmd = commands.Command() cmd.add_child('bar', child) expected = ('usage: foo\n\n' 'COMMANDS:\n\n' 'bar baz\n\n' ' baz the great and wonderful') self.assertEqual(expected, cmd.format_help('foo').strip()) def test_subcommand_with_options_and_documentation(self): child = commands.Command() child.help = ' some text about everything this command does.' child.add_argument('-h', '--help', action='store_true', help='show this message') cmd = commands.Command() cmd.add_child('bar', child) expected = ('usage: foo\n\n' 'COMMANDS:\n\n' 'bar [-h]\n\n' ' some text about everything this command does.\n\n' ' -h, --help show this message') self.assertEqual(expected, cmd.format_help('foo').strip()) def test_nested_subcommands_with_options(self): subchild = commands.Command() subchild.add_argument('--test', help='the great and wonderful') child = commands.Command() child.add_child('baz', subchild) child.add_argument('-h', '--help', action='store_true', help='show this message') cmd = commands.Command() cmd.add_child('bar', child) expected = ('usage: foo\n\n' 'COMMANDS:\n\n' 'bar [-h]\n\n' ' -h, --help show this message\n\n' 'bar baz [--test TEST]\n\n' ' --test TEST the great and wonderful') self.assertEqual(expected, cmd.format_help('foo').strip()) def test_nested_subcommands_skipped_intermediate(self): subchild = commands.Command() subchild.add_argument('--test', help='the great and wonderful') child = commands.Command() child.add_child('baz', subchild) cmd = commands.Command() cmd.add_child('bar', child) expected = ('usage: foo\n\n' 'COMMANDS:\n\n' 'bar baz [--test TEST]\n\n' ' --test TEST the great and wonderful') self.assertEqual(expected, cmd.format_help('foo').strip()) def test_command_with_option_and_subcommand_with_option(self): child = commands.Command() child.add_argument('--test', help='the great and wonderful') cmd = commands.Command() cmd.add_argument('-h', '--help', action='store_true', help='show this message') cmd.add_child('bar', child) expected = ('usage: foo [-h]\n\n' 'OPTIONS:\n\n' ' -h, --help show this message\n\n' 'COMMANDS:\n\n' 'bar [--test TEST]\n\n' ' --test TEST the great and wonderful') self.assertEqual(expected, cmd.format_help('foo').strip()) def test_command_with_options_doc_and_subcommand_with_option_and_doc(self): child = commands.Command() child.help = 'some text about this sub-command.' child.add_argument('--test', help='the great and wonderful') cmd = commands.Command() cmd.help = 'some text about everything this command does.' cmd.add_argument('-h', '--help', action='store_true', help='show this message') cmd.add_child('bar', child) expected = ('usage: foo [-h]\n\n' 'some text about everything this command does.\n\n' 'OPTIONS:\n\n' ' -h, --help show this message\n\n' 'COMMANDS:\n\n' 'bar [--test TEST]\n\n' ' some text about this sub-command.\n\n' ' --test TEST the great and wonderful') self.assertEqual(expected, cmd.format_help('foo').strip()) class RunTest(unittest.TestCase): def test_default_implmentation_raises_error(self): with self.assertRaises(NotImplementedError): commands.Command().run() Mopidy-2.0.0/tests/audio/0000775000175000017500000000000012660436443015433 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/audio/test_utils.py0000664000175000017500000000125212660436420020177 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import pytest from mopidy.audio import utils from mopidy.internal.gi import Gst class TestCreateBuffer(object): def test_creates_buffer(self): buf = utils.create_buffer(b'123', timestamp=0, duration=1000000) assert isinstance(buf, Gst.Buffer) assert buf.pts == 0 assert buf.duration == 1000000 assert buf.get_size() == len(b'123') def test_fails_if_data_has_zero_length(self): with pytest.raises(ValueError) as excinfo: utils.create_buffer(b'', timestamp=0, duration=1000000) assert 'Cannot create buffer without data' in str(excinfo.value) Mopidy-2.0.0/tests/audio/test_actor.py0000664000175000017500000004776112660436420020166 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import threading import unittest import mock import pykka from mopidy import audio from mopidy.audio.constants import PlaybackState from mopidy.internal import path from mopidy.internal.gi import Gst from tests import dummy_audio, path_to_data_dir # We want to make sure both our real audio class and the fake one behave # correctly. So each test is first run against the real class, then repeated # against our dummy. class BaseTest(unittest.TestCase): config = { 'audio': { 'buffer_time': None, 'mixer': 'fakemixer track_max_volume=65536', 'mixer_track': None, 'mixer_volume': None, 'output': 'testoutput', 'visualizer': None, } } uris = [path.path_to_uri(path_to_data_dir('song1.wav')), path.path_to_uri(path_to_data_dir('song2.wav'))] audio_class = audio.Audio def setUp(self): # noqa: N802 config = { 'audio': { 'buffer_time': None, 'mixer': 'foomixer', 'mixer_volume': None, 'output': 'testoutput', 'visualizer': None, }, 'proxy': { 'hostname': '', }, } self.song_uri = path.path_to_uri(path_to_data_dir('song1.wav')) self.audio = self.audio_class.start(config=config, mixer=None).proxy() def tearDown(self): # noqa pykka.ActorRegistry.stop_all() def possibly_trigger_fake_playback_error(self, uri): pass def possibly_trigger_fake_about_to_finish(self): pass class DummyMixin(object): audio_class = dummy_audio.DummyAudio def possibly_trigger_fake_playback_error(self, uri): self.audio.trigger_fake_playback_failure(uri) def possibly_trigger_fake_about_to_finish(self): callback = self.audio.get_about_to_finish_callback().get() if callback: callback() class AudioTest(BaseTest): def test_start_playback_existing_file(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.assertTrue(self.audio.start_playback().get()) def test_start_playback_non_existing_file(self): self.possibly_trigger_fake_playback_error(self.uris[0] + 'bogus') self.audio.prepare_change() self.audio.set_uri(self.uris[0] + 'bogus') self.assertFalse(self.audio.start_playback().get()) def test_pause_playback_while_playing(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.audio.start_playback() self.assertTrue(self.audio.pause_playback().get()) def test_stop_playback_while_playing(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.audio.start_playback() self.assertTrue(self.audio.stop_playback().get()) @unittest.SkipTest def test_deliver_data(self): pass # TODO @unittest.SkipTest def test_end_of_data_stream(self): pass # TODO @unittest.SkipTest def test_set_mute(self): pass # TODO Probably needs a fakemixer with a mixer track @unittest.SkipTest def test_set_state_encapsulation(self): pass # TODO @unittest.SkipTest def test_set_position(self): pass # TODO @unittest.SkipTest def test_invalid_output_raises_error(self): pass # TODO class AudioDummyTest(DummyMixin, AudioTest): pass class DummyAudioListener(pykka.ThreadingActor, audio.AudioListener): def __init__(self): super(DummyAudioListener, self).__init__() self.events = [] self.waiters = {} def on_event(self, event, **kwargs): self.events.append((event, kwargs)) if event in self.waiters: self.waiters[event].set() def wait(self, event): self.waiters[event] = threading.Event() return self.waiters[event] def get_events(self): return self.events def clear_events(self): self.events = [] class AudioEventTest(BaseTest): def setUp(self): # noqa: N802 super(AudioEventTest, self).setUp() self.audio.enable_sync_handler().get() self.listener = DummyAudioListener.start().proxy() def tearDown(self): # noqa: N802 super(AudioEventTest, self).tearDown() def assertEvent(self, event, **kwargs): # noqa: N802 self.assertIn((event, kwargs), self.listener.get_events().get()) def assertNotEvent(self, event, **kwargs): # noqa: N802 self.assertNotIn((event, kwargs), self.listener.get_events().get()) # TODO: test without uri set, with bad uri and gapless... # TODO: playing->playing triggered by seek should be removed # TODO: codify expected state after EOS # TODO: consider returning a future or a threading event? def test_state_change_stopped_to_playing_event(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.audio.start_playback() self.audio.wait_for_state_change().get() self.assertEvent('state_changed', old_state=PlaybackState.STOPPED, new_state=PlaybackState.PLAYING, target_state=None) def test_state_change_stopped_to_paused_event(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.audio.pause_playback() self.audio.wait_for_state_change().get() self.assertEvent('state_changed', old_state=PlaybackState.STOPPED, new_state=PlaybackState.PAUSED, target_state=None) def test_state_change_paused_to_playing_event(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.audio.pause_playback() self.audio.wait_for_state_change() self.listener.clear_events() self.audio.start_playback() self.audio.wait_for_state_change().get() self.assertEvent('state_changed', old_state=PlaybackState.PAUSED, new_state=PlaybackState.PLAYING, target_state=None) def test_state_change_paused_to_stopped_event(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.audio.pause_playback() self.audio.wait_for_state_change() self.listener.clear_events() self.audio.stop_playback() self.audio.wait_for_state_change().get() self.assertEvent('state_changed', old_state=PlaybackState.PAUSED, new_state=PlaybackState.STOPPED, target_state=None) def test_state_change_playing_to_paused_event(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.audio.start_playback() self.audio.wait_for_state_change() self.listener.clear_events() self.audio.pause_playback() self.audio.wait_for_state_change().get() self.assertEvent('state_changed', old_state=PlaybackState.PLAYING, new_state=PlaybackState.PAUSED, target_state=None) def test_state_change_playing_to_stopped_event(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.audio.start_playback() self.audio.wait_for_state_change() self.listener.clear_events() self.audio.stop_playback() self.audio.wait_for_state_change().get() self.assertEvent('state_changed', old_state=PlaybackState.PLAYING, new_state=PlaybackState.STOPPED, target_state=None) def test_stream_changed_event_on_playing(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.listener.clear_events() self.audio.start_playback() # Since we are going from stopped to playing, the state change is # enough to ensure the stream changed. self.audio.wait_for_state_change().get() self.assertEvent('stream_changed', uri=self.uris[0]) def test_stream_changed_event_on_multiple_changes(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.listener.clear_events() self.audio.start_playback() self.audio.wait_for_state_change().get() self.assertEvent('stream_changed', uri=self.uris[0]) self.audio.prepare_change() self.audio.set_uri(self.uris[1]) self.audio.pause_playback() self.audio.wait_for_state_change().get() self.assertEvent('stream_changed', uri=self.uris[1]) def test_stream_changed_event_on_playing_to_paused(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.listener.clear_events() self.audio.start_playback() self.audio.wait_for_state_change().get() self.assertEvent('stream_changed', uri=self.uris[0]) self.listener.clear_events() self.audio.pause_playback() self.audio.wait_for_state_change().get() self.assertNotEvent('stream_changed', uri=self.uris[0]) def test_stream_changed_event_on_paused_to_stopped(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.audio.pause_playback() self.audio.wait_for_state_change() self.listener.clear_events() self.audio.stop_playback() self.audio.wait_for_state_change().get() self.assertEvent('stream_changed', uri=None) def test_position_changed_on_pause(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.audio.pause_playback() self.audio.wait_for_state_change() self.audio.wait_for_state_change().get() self.assertEvent('position_changed', position=0) def test_stream_changed_event_on_paused_to_playing(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.listener.clear_events() self.audio.pause_playback() self.audio.wait_for_state_change().get() self.assertEvent('stream_changed', uri=self.uris[0]) self.listener.clear_events() self.audio.start_playback() self.audio.wait_for_state_change().get() self.assertNotEvent('stream_changed', uri=self.uris[0]) def test_position_changed_on_play(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.audio.start_playback() self.audio.wait_for_state_change() self.audio.wait_for_state_change().get() self.assertEvent('position_changed', position=0) def test_position_changed_on_seek_while_stopped(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.audio.set_position(2000) self.audio.wait_for_state_change().get() self.assertNotEvent('position_changed', position=0) def test_position_changed_on_seek_after_play(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.audio.start_playback() self.audio.wait_for_state_change() self.listener.clear_events() self.audio.set_position(2000) self.audio.wait_for_state_change().get() self.assertEvent('position_changed', position=2000) def test_position_changed_on_seek_after_pause(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.audio.pause_playback() self.audio.wait_for_state_change() self.listener.clear_events() self.audio.set_position(2000) self.audio.wait_for_state_change().get() self.assertEvent('position_changed', position=2000) def test_tags_changed_on_playback(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.audio.start_playback() self.audio.wait_for_state_change().get() self.assertEvent('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 # completes. def test_stream_changed_event_on_paused(self): event = self.listener.wait('stream_changed').get() self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.audio.pause_playback().get() self.audio.wait_for_state_change().get() if not event.wait(timeout=1.0): self.fail('Stream changed not reached within deadline') self.assertEvent('stream_changed', uri=self.uris[0]) def test_reached_end_of_stream_event(self): event = self.listener.wait('reached_end_of_stream').get() self.audio.prepare_change() self.audio.set_uri(self.uris[0]) self.audio.start_playback() self.audio.wait_for_state_change().get() self.possibly_trigger_fake_about_to_finish() if not event.wait(timeout=1.0): self.fail('End of stream not reached within deadline') self.assertFalse(self.audio.get_current_tags().get()) def test_gapless(self): uris = self.uris[1:] event = self.listener.wait('reached_end_of_stream').get() def callback(): if uris: self.audio.set_uri(uris.pop()).get() 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() self.possibly_trigger_fake_about_to_finish() self.audio.wait_for_state_change().get() if not event.wait(timeout=1.0): self.fail('EOS not received') # Check that both uris got played self.assertEvent('stream_changed', uri=self.uris[0]) self.assertEvent('stream_changed', uri=self.uris[1]) # Check that events counts check out. keys = [k for k, v in self.listener.get_events().get()] 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 # TODO: this does not belong in this testcase def test_current_tags_are_blank_to_begin_with(self): self.assertFalse(self.audio.get_current_tags().get()) def test_current_tags_blank_after_end_of_stream(self): event = self.listener.wait('reached_end_of_stream').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 event.wait(timeout=1.0): self.fail('EOS not received') self.assertFalse(self.audio.get_current_tags().get()) def test_current_tags_stored(self): event = self.listener.wait('reached_end_of_stream').get() tags = [] def callback(): tags.append(self.audio.get_current_tags().get()) 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 event.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): """Exercise the AudioEventTest against our mock audio classes.""" # TODO: move to mixer tests... class MixerTest(BaseTest): @unittest.SkipTest def test_set_mute(self): for value in (True, False): self.assertTrue(self.audio.set_mute(value).get()) self.assertEqual(value, self.audio.get_mute().get()) @unittest.SkipTest def test_set_state_encapsulation(self): pass # TODO @unittest.SkipTest def test_set_position(self): pass # TODO @unittest.SkipTest def test_invalid_output_raises_error(self): pass # TODO class AudioStateTest(unittest.TestCase): def setUp(self): # noqa: N802 self.audio = audio.Audio(config=None, mixer=None) def test_state_starts_as_stopped(self): self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state) def test_state_does_not_change_when_in_gst_ready_state(self): self.audio._handler.on_playbin_state_changed( Gst.State.NULL, Gst.State.READY, Gst.State.VOID_PENDING) self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state) def test_state_changes_from_stopped_to_playing_on_play(self): self.audio._handler.on_playbin_state_changed( Gst.State.NULL, Gst.State.READY, Gst.State.PLAYING) self.audio._handler.on_playbin_state_changed( Gst.State.READY, Gst.State.PAUSED, Gst.State.PLAYING) self.audio._handler.on_playbin_state_changed( Gst.State.PAUSED, Gst.State.PLAYING, Gst.State.VOID_PENDING) self.assertEqual(audio.PlaybackState.PLAYING, self.audio.state) def test_state_changes_from_playing_to_paused_on_pause(self): self.audio.state = audio.PlaybackState.PLAYING self.audio._handler.on_playbin_state_changed( Gst.State.PLAYING, Gst.State.PAUSED, Gst.State.VOID_PENDING) self.assertEqual(audio.PlaybackState.PAUSED, self.audio.state) def test_state_changes_from_playing_to_stopped_on_stop(self): self.audio.state = audio.PlaybackState.PLAYING self.audio._handler.on_playbin_state_changed( Gst.State.PLAYING, Gst.State.PAUSED, Gst.State.NULL) self.audio._handler.on_playbin_state_changed( Gst.State.PAUSED, Gst.State.READY, Gst.State.NULL) # We never get the following call, so the logic must work without it # self.audio._handler.on_playbin_state_changed( # Gst.State.READY, Gst.State.NULL, Gst.State.VOID_PENDING) self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state) class AudioBufferingTest(unittest.TestCase): def setUp(self): # noqa: N802 self.audio = audio.Audio(config=None, mixer=None) self.audio._playbin = mock.Mock(spec=['set_state']) def test_pause_when_buffer_empty(self): playbin = self.audio._playbin self.audio.start_playback() playbin.set_state.assert_called_with(Gst.State.PLAYING) playbin.set_state.reset_mock() self.audio._handler.on_buffering(0) playbin.set_state.assert_called_with(Gst.State.PAUSED) self.assertTrue(self.audio._buffering) def test_stay_paused_when_buffering_finished(self): playbin = self.audio._playbin self.audio.pause_playback() playbin.set_state.assert_called_with(Gst.State.PAUSED) playbin.set_state.reset_mock() self.audio._handler.on_buffering(100) self.assertEqual(playbin.set_state.call_count, 0) self.assertFalse(self.audio._buffering) def test_change_to_paused_while_buffering(self): playbin = self.audio._playbin self.audio.start_playback() playbin.set_state.assert_called_with(Gst.State.PLAYING) playbin.set_state.reset_mock() self.audio._handler.on_buffering(0) playbin.set_state.assert_called_with(Gst.State.PAUSED) self.audio.pause_playback() playbin.set_state.reset_mock() self.audio._handler.on_buffering(100) self.assertEqual(playbin.set_state.call_count, 0) self.assertFalse(self.audio._buffering) def test_change_to_stopped_while_buffering(self): playbin = self.audio._playbin self.audio.start_playback() playbin.set_state.assert_called_with(Gst.State.PLAYING) playbin.set_state.reset_mock() self.audio._handler.on_buffering(0) playbin.set_state.assert_called_with(Gst.State.PAUSED) playbin.set_state.reset_mock() self.audio.stop_playback() playbin.set_state.assert_called_with(Gst.State.NULL) self.assertFalse(self.audio._buffering) Mopidy-2.0.0/tests/audio/test_tags.py0000664000175000017500000003007712660436420020004 0ustar jodaljodal00000000000000# encoding: utf-8 from __future__ import absolute_import, unicode_literals import unittest from mopidy import compat from mopidy.audio import tags from mopidy.internal.gi import GLib, GObject, Gst from mopidy.models import Album, Artist, Track class TestConvertTaglist(object): def make_taglist(self, tag, values): taglist = Gst.TagList.new_empty() for value in values: if isinstance(value, (GLib.Date, Gst.DateTime)): taglist.add_value(Gst.TagMergeMode.APPEND, tag, value) continue gobject_value = GObject.Value() if isinstance(value, bytes): gobject_value.init(GObject.TYPE_STRING) gobject_value.set_string(value) elif isinstance(value, int): gobject_value.init(GObject.TYPE_UINT) gobject_value.set_uint(value) gobject_value.init(GObject.TYPE_VALUE) gobject_value.set_value(value) else: raise TypeError taglist.add_value(Gst.TagMergeMode.APPEND, tag, gobject_value) return taglist def test_date_tag(self): date = GLib.Date.new_dmy(7, 1, 2014) taglist = self.make_taglist(Gst.TAG_DATE, [date]) result = tags.convert_taglist(taglist) assert isinstance(result[Gst.TAG_DATE][0], compat.text_type) assert result[Gst.TAG_DATE][0] == '2014-01-07' def test_date_time_tag(self): taglist = self.make_taglist(Gst.TAG_DATE_TIME, [ Gst.DateTime.new_from_iso8601_string(b'2014-01-07 14:13:12') ]) result = tags.convert_taglist(taglist) assert isinstance(result[Gst.TAG_DATE_TIME][0], compat.text_type) assert result[Gst.TAG_DATE_TIME][0] == '2014-01-07T14:13:12Z' def test_string_tag(self): taglist = self.make_taglist(Gst.TAG_ARTIST, [b'ABBA', b'ACDC']) result = tags.convert_taglist(taglist) assert isinstance(result[Gst.TAG_ARTIST][0], compat.text_type) assert result[Gst.TAG_ARTIST][0] == 'ABBA' assert isinstance(result[Gst.TAG_ARTIST][1], compat.text_type) assert result[Gst.TAG_ARTIST][1] == 'ACDC' def test_integer_tag(self): taglist = self.make_taglist(Gst.TAG_BITRATE, [17]) result = tags.convert_taglist(taglist) assert result[Gst.TAG_BITRATE][0] == 17 # 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: N802 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': ['2006-01-01'], 'container-format': ['ID3 tag'], 'genre': ['genre'], 'comment': ['comment'], 'musicbrainz-trackid': ['trackid'], 'musicbrainz-albumid': ['albumid'], 'musicbrainz-artistid': ['artistid'], 'musicbrainz-sortname': ['sortname'], 'musicbrainz-albumartistid': ['albumartistid'], 'bitrate': [1000], } artist = Artist(name='artist', musicbrainz_id='artistid', sortname='sortname') composer = Artist(name='composer') performer = Artist(name='performer') albumartist = Artist(name='albumartist', musicbrainz_id='albumartistid') album = Album(name='album', date='2006-01-01', num_tracks=2, num_discs=3, musicbrainz_id='albumid', artists=[albumartist]) self.track = Track(name='track', 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 = tags.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.replace(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.replace(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.replace(name=None)) def test_multiple_track_name(self): self.tags['title'] = ['name1', 'name2'] self.check(self.track.replace(name='name1; name2')) def test_missing_track_musicbrainz_id(self): del self.tags['musicbrainz-trackid'] self.check(self.track.replace(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.replace(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.replace(genre=None)) def test_multiple_track_genre(self): self.tags['genre'] = ['genre1', 'genre2'] self.check(self.track.replace(genre='genre1; genre2')) def test_missing_track_date(self): del self.tags['date'] self.check( self.track.replace(album=self.track.album.replace(date=None))) def test_multiple_track_date(self): self.tags['date'].append('2030-01-01') self.check(self.track) def test_datetime_instead_of_date(self): del self.tags['date'] self.tags['datetime'] = ['2006-01-01T14:13:12Z'] self.check(self.track) def test_missing_track_comment(self): del self.tags['comment'] self.check(self.track.replace(comment=None)) def test_multiple_track_comment(self): self.tags['comment'] = ['comment1', 'comment2'] self.check(self.track.replace(comment='comment1; comment2')) def test_missing_track_artist_name(self): del self.tags['artist'] self.check(self.track.replace(artists=[])) def test_multiple_track_artist_name(self): self.tags['artist'] = ['name1', 'name2'] artists = [Artist(name='name1'), Artist(name='name2')] self.check(self.track.replace(artists=artists)) def test_missing_track_artist_musicbrainz_id(self): del self.tags['musicbrainz-artistid'] artist = list(self.track.artists)[0].replace(musicbrainz_id=None) self.check(self.track.replace(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.replace(composers=[])) def test_multiple_track_composer_name(self): self.tags['composer'] = ['composer1', 'composer2'] composers = [Artist(name='composer1'), Artist(name='composer2')] self.check(self.track.replace(composers=composers)) def test_missing_track_performer_name(self): del self.tags['performer'] self.check(self.track.replace(performers=[])) def test_multiple_track_performe_name(self): self.tags['performer'] = ['performer1', 'performer2'] performers = [Artist(name='performer1'), Artist(name='performer2')] self.check(self.track.replace(performers=performers)) def test_missing_album_name(self): del self.tags['album'] self.check(self.track.replace(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.replace(musicbrainz_id=None, images=[]) self.check(self.track.replace(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.replace(num_tracks=None) self.check(self.track.replace(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.replace(num_discs=None) self.check(self.track.replace(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.replace(artists=[]) self.check(self.track.replace(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.replace(artists=artists) self.check(self.track.replace(album=album)) def test_missing_album_artist_musicbrainz_id(self): del self.tags['musicbrainz-albumartistid'] albumartist = list(self.track.album.artists)[0] albumartist = albumartist.replace(musicbrainz_id=None) album = self.track.album.replace(artists=[albumartist]) self.check(self.track.replace(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.replace(name='organization')) def test_multiple_organization_track_name(self): del self.tags['title'] self.tags['organization'] = ['organization1', 'organization2'] self.check(self.track.replace(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.replace(comment='location')) def test_multiple_location_track_comment(self): del self.tags['comment'] self.tags['location'] = ['location1', 'location2'] self.check(self.track.replace(comment='location1; location2')) def test_stream_copyright_track_comment(self): del self.tags['comment'] self.tags['copyright'] = ['copyright'] self.check(self.track.replace(comment='copyright')) def test_multiple_copyright_track_comment(self): del self.tags['comment'] self.tags['copyright'] = ['copyright1', 'copyright2'] self.check(self.track.replace(comment='copyright1; copyright2')) def test_sortname(self): self.tags['musicbrainz-sortname'] = ['another_sortname'] artist = Artist(name='artist', sortname='another_sortname', musicbrainz_id='artistid') self.check(self.track.replace(artists=[artist])) def test_missing_sortname(self): del self.tags['musicbrainz-sortname'] artist = Artist(name='artist', sortname=None, musicbrainz_id='artistid') self.check(self.track.replace(artists=[artist])) Mopidy-2.0.0/tests/audio/__init__.py0000664000175000017500000000000012613504521017521 0ustar jodaljodal00000000000000Mopidy-2.0.0/tests/audio/test_scan.py0000664000175000017500000000766412660436420020000 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import os import unittest from mopidy import exceptions from mopidy.audio import scan from mopidy.internal import path as path_lib from tests import path_to_data_dir class ScannerTest(unittest.TestCase): def setUp(self): # noqa: N802 self.errors = {} self.result = {} def find(self, path): media_dir = path_to_data_dir(path) result, errors = path_lib.find_mtimes(media_dir) for path in result: yield os.path.join(media_dir, path) def scan(self, paths): scanner = scan.Scanner() for path in paths: uri = path_lib.path_to_uri(path) key = uri[len('file://'):] try: self.result[key] = scanner.scan(uri) except exceptions.ScannerError as error: self.errors[key] = error def check(self, name, key, value): name = path_to_data_dir(name) self.assertEqual(self.result[name].tags[key], value) def check_if_missing_plugin(self): for path, result in self.result.items(): if not path.endswith('.mp3'): continue if not result.playable and result.mime == 'audio/mpeg': raise unittest.SkipTest('Missing MP3 support?') def test_tags_is_set(self): self.scan(self.find('scanner/simple')) self.assert_(self.result.values()[0].tags) def test_errors_is_not_set(self): self.scan(self.find('scanner/simple')) self.check_if_missing_plugin() self.assert_(not self.errors) def test_duration_is_set(self): self.scan(self.find('scanner/simple')) self.check_if_missing_plugin() ogg = path_to_data_dir('scanner/simple/song1.ogg') mp3 = path_to_data_dir('scanner/simple/song1.mp3') self.assertEqual(self.result[mp3].duration, 4680) self.assertEqual(self.result[ogg].duration, 4680) def test_artist_is_set(self): self.scan(self.find('scanner/simple')) self.check_if_missing_plugin() 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_if_missing_plugin() 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_if_missing_plugin() 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')) self.assert_(not self.errors) def test_other_media_is_ignored(self): self.scan(self.find('scanner/image')) self.assertFalse(self.result.values()[0].playable) def test_log_file_that_gst_thinks_is_mpeg_1_is_ignored(self): self.scan([path_to_data_dir('scanner/example.log')]) self.check_if_missing_plugin() log = path_to_data_dir('scanner/example.log') self.assertLess(self.result[log].duration, 100) def test_empty_wav_file(self): self.scan([path_to_data_dir('scanner/empty.wav')]) wav = path_to_data_dir('scanner/empty.wav') self.assertEqual(self.result[wav].duration, 0) def test_uri_list(self): path = path_to_data_dir('scanner/playlist.m3u') self.scan([path]) self.assertEqual(self.result[path].mime, 'text/uri-list') def test_text_plain(self): # GStreamer decode bin hardcodes bad handling of text plain :/ path = path_to_data_dir('scanner/plain.txt') self.scan([path]) self.assertIn(path, self.errors) @unittest.SkipTest def test_song_without_time_is_handeled(self): pass Mopidy-2.0.0/tests/audio/test_listener.py0000664000175000017500000000223212575004517020666 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest import mock from mopidy import audio class AudioListenerTest(unittest.TestCase): def setUp(self): # noqa: N802 self.listener = audio.AudioListener() def test_on_event_forwards_to_specific_handler(self): self.listener.state_changed = mock.Mock() self.listener.on_event( 'state_changed', old_state='stopped', new_state='playing', target_state=None) self.listener.state_changed.assert_called_with( old_state='stopped', new_state='playing', target_state=None) def test_listener_has_default_impl_for_reached_end_of_stream(self): self.listener.reached_end_of_stream() def test_listener_has_default_impl_for_state_changed(self): self.listener.state_changed(None, None, None) def test_listener_has_default_impl_for_stream_changed(self): self.listener.stream_changed(None) 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([]) Mopidy-2.0.0/tests/config/0000775000175000017500000000000012660436443015577 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/config/test_schemas.py0000664000175000017500000001022612575004517020632 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import logging import unittest import mock from mopidy.config import schemas, types from tests import any_unicode class ConfigSchemaTest(unittest.TestCase): def setUp(self): # noqa: N802 self.schema = schemas.ConfigSchema('test') self.schema['foo'] = mock.Mock() self.schema['bar'] = mock.Mock() self.schema['baz'] = mock.Mock() self.values = {'bar': '123', 'foo': '456', 'baz': '678'} def test_deserialize(self): self.schema.deserialize(self.values) def test_deserialize_with_missing_value(self): del self.values['foo'] result, errors = self.schema.deserialize(self.values) self.assertEqual({'foo': any_unicode}, errors) self.assertIsNone(result.pop('foo')) self.assertIsNotNone(result.pop('bar')) self.assertIsNotNone(result.pop('baz')) self.assertEqual({}, result) def test_deserialize_with_extra_value(self): self.values['extra'] = '123' result, errors = self.schema.deserialize(self.values) self.assertEqual({'extra': any_unicode}, errors) self.assertIsNotNone(result.pop('foo')) self.assertIsNotNone(result.pop('bar')) self.assertIsNotNone(result.pop('baz')) self.assertEqual({}, result) def test_deserialize_with_deserialization_error(self): self.schema['foo'].deserialize.side_effect = ValueError('failure') result, errors = self.schema.deserialize(self.values) self.assertEqual({'foo': 'failure'}, errors) self.assertIsNone(result.pop('foo')) self.assertIsNotNone(result.pop('bar')) self.assertIsNotNone(result.pop('baz')) self.assertEqual({}, result) def test_deserialize_with_multiple_deserialization_errors(self): self.schema['foo'].deserialize.side_effect = ValueError('failure') self.schema['bar'].deserialize.side_effect = ValueError('other') result, errors = self.schema.deserialize(self.values) self.assertEqual({'foo': 'failure', 'bar': 'other'}, errors) self.assertIsNone(result.pop('foo')) self.assertIsNone(result.pop('bar')) self.assertIsNotNone(result.pop('baz')) self.assertEqual({}, result) def test_deserialize_deserialization_unknown_and_missing_errors(self): self.values['extra'] = '123' self.schema['bar'].deserialize.side_effect = ValueError('failure') del self.values['baz'] result, errors = self.schema.deserialize(self.values) self.assertIn('unknown', errors['extra']) self.assertNotIn('foo', errors) self.assertIn('failure', errors['bar']) self.assertIn('not found', errors['baz']) self.assertNotIn('unknown', result) self.assertIn('foo', result) self.assertIsNone(result['bar']) self.assertIsNone(result['baz']) def test_deserialize_deprecated_value(self): self.schema['foo'] = types.Deprecated() result, errors = self.schema.deserialize(self.values) self.assertItemsEqual(['bar', 'baz'], result.keys()) self.assertNotIn('foo', errors) class MapConfigSchemaTest(unittest.TestCase): def test_conversion(self): schema = schemas.MapConfigSchema('test', types.LogLevel()) result, errors = schema.deserialize( {'foo.bar': 'DEBUG', 'baz': 'INFO'}) self.assertEqual(logging.DEBUG, result['foo.bar']) self.assertEqual(logging.INFO, result['baz']) class DidYouMeanTest(unittest.TestCase): def test_suggestions(self): choices = ('enabled', 'username', 'password', 'bitrate', 'timeout') suggestion = schemas._did_you_mean('bitrate', choices) self.assertEqual(suggestion, 'bitrate') suggestion = schemas._did_you_mean('bitrote', choices) self.assertEqual(suggestion, 'bitrate') suggestion = schemas._did_you_mean('Bitrot', choices) self.assertEqual(suggestion, 'bitrate') suggestion = schemas._did_you_mean('BTROT', choices) self.assertEqual(suggestion, 'bitrate') suggestion = schemas._did_you_mean('btro', choices) self.assertEqual(suggestion, None) Mopidy-2.0.0/tests/config/test_validator.py0000664000175000017500000000456012575004517021200 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest from mopidy.config import validators class ValidateChoiceTest(unittest.TestCase): def test_no_choices_passes(self): validators.validate_choice('foo', None) def test_valid_value_passes(self): validators.validate_choice('foo', ['foo', 'bar', 'baz']) validators.validate_choice(1, [1, 2, 3]) def test_empty_choices_fails(self): self.assertRaises(ValueError, validators.validate_choice, 'foo', []) def test_invalid_value_fails(self): words = ['foo', 'bar', 'baz'] self.assertRaises( ValueError, validators.validate_choice, 'foobar', words) self.assertRaises( ValueError, validators.validate_choice, 5, [1, 2, 3]) class ValidateMinimumTest(unittest.TestCase): def test_no_minimum_passes(self): validators.validate_minimum(10, None) def test_valid_value_passes(self): validators.validate_minimum(10, 5) def test_to_small_value_fails(self): self.assertRaises(ValueError, validators.validate_minimum, 10, 20) def test_to_small_value_fails_with_zero_as_minimum(self): self.assertRaises(ValueError, validators.validate_minimum, -1, 0) class ValidateMaximumTest(unittest.TestCase): def test_no_maximum_passes(self): validators.validate_maximum(5, None) def test_valid_value_passes(self): validators.validate_maximum(5, 10) def test_to_large_value_fails(self): self.assertRaises(ValueError, validators.validate_maximum, 10, 5) def test_to_large_value_fails_with_zero_as_maximum(self): self.assertRaises(ValueError, validators.validate_maximum, 5, 0) class ValidateRequiredTest(unittest.TestCase): def test_passes_when_false(self): validators.validate_required('foo', False) validators.validate_required('', False) validators.validate_required(' ', False) validators.validate_required([], False) def test_passes_when_required_and_set(self): validators.validate_required('foo', True) validators.validate_required(' foo ', True) validators.validate_required([1], True) def test_blocks_when_required_and_emtpy(self): self.assertRaises(ValueError, validators.validate_required, '', True) self.assertRaises(ValueError, validators.validate_required, [], True) Mopidy-2.0.0/tests/config/test_config.py0000664000175000017500000002657312575004517020470 0ustar jodaljodal00000000000000# encoding: utf-8 from __future__ import absolute_import, unicode_literals import unittest import mock from mopidy import config, ext from tests import path_to_data_dir class LoadConfigTest(unittest.TestCase): def test_load_nothing(self): self.assertEqual({}, config._load([], [], [])) def test_load_missing_file(self): file0 = path_to_data_dir('file0.conf') result = config._load([file0], [], []) self.assertEqual({}, result) @mock.patch('os.access') def test_load_nonreadable_file(self, access_mock): access_mock.return_value = False file1 = path_to_data_dir('file1.conf') result = config._load([file1], [], []) self.assertEqual({}, result) def test_load_single_default(self): default = b'[foo]\nbar = baz' expected = {'foo': {'bar': 'baz'}} result = config._load([], [default], []) self.assertEqual(expected, result) def test_unicode_default(self): default = '[foo]\nbar = æøå' expected = {'foo': {'bar': 'æøå'.encode('utf-8')}} result = config._load([], [default], []) self.assertEqual(expected, result) def test_load_defaults(self): default1 = b'[foo]\nbar = baz' default2 = b'[foo2]\n' expected = {'foo': {'bar': 'baz'}, 'foo2': {}} result = config._load([], [default1, default2], []) self.assertEqual(expected, result) def test_load_single_override(self): override = ('foo', 'bar', 'baz') expected = {'foo': {'bar': 'baz'}} result = config._load([], [], [override]) self.assertEqual(expected, result) def test_load_overrides(self): override1 = ('foo', 'bar', 'baz') override2 = ('foo2', 'bar', 'baz') expected = {'foo': {'bar': 'baz'}, 'foo2': {'bar': 'baz'}} result = config._load([], [], [override1, override2]) self.assertEqual(expected, result) def test_load_single_file(self): file1 = path_to_data_dir('file1.conf') expected = {'foo': {'bar': 'baz'}} result = config._load([file1], [], []) self.assertEqual(expected, result) def test_load_files(self): file1 = path_to_data_dir('file1.conf') file2 = path_to_data_dir('file2.conf') expected = {'foo': {'bar': 'baz'}, 'foo2': {'bar': 'baz'}} result = config._load([file1, file2], [], []) self.assertEqual(expected, result) def test_load_directory(self): directory = path_to_data_dir('conf1.d') expected = {'foo': {'bar': 'baz'}, 'foo2': {'bar': 'baz'}} result = config._load([directory], [], []) self.assertEqual(expected, result) def test_load_directory_only_conf_files(self): directory = path_to_data_dir('conf2.d') expected = {'foo': {'bar': 'baz'}} result = config._load([directory], [], []) self.assertEqual(expected, result) def test_load_file_with_utf8(self): expected = {'foo': {'bar': 'æøå'.encode('utf-8')}} result = config._load([path_to_data_dir('file3.conf')], [], []) self.assertEqual(expected, result) def test_load_file_with_error(self): expected = {'foo': {'bar': 'baz'}} result = config._load([path_to_data_dir('file4.conf')], [], []) self.assertEqual(expected, result) class ValidateTest(unittest.TestCase): def setUp(self): # noqa: N802 self.schema = config.ConfigSchema('foo') self.schema['bar'] = config.ConfigValue() def test_empty_config_no_schemas(self): conf, errors = config._validate({}, []) self.assertEqual({}, conf) self.assertEqual({}, errors) def test_config_no_schemas(self): raw_config = {'foo': {'bar': 'baz'}} conf, errors = config._validate(raw_config, []) self.assertEqual({}, conf) self.assertEqual({}, errors) def test_empty_config_single_schema(self): conf, errors = config._validate({}, [self.schema]) self.assertEqual({'foo': {'bar': None}}, conf) self.assertEqual({'foo': {'bar': 'config key not found.'}}, errors) def test_config_single_schema(self): raw_config = {'foo': {'bar': 'baz'}} conf, errors = config._validate(raw_config, [self.schema]) self.assertEqual({'foo': {'bar': 'baz'}}, conf) self.assertEqual({}, errors) def test_config_single_schema_config_error(self): raw_config = {'foo': {'bar': 'baz'}} self.schema['bar'] = mock.Mock() self.schema['bar'].deserialize.side_effect = ValueError('bad') conf, errors = config._validate(raw_config, [self.schema]) self.assertEqual({'foo': {'bar': None}}, conf) self.assertEqual({'foo': {'bar': 'bad'}}, errors) # TODO: add more tests INPUT_CONFIG = """# comments before first section should work [section] anything goes ; after the [] block it seems. ; this is a valid comment this-should-equal-baz = baz ; as this is a comment this-should-equal-everything = baz # as this is not a comment # this is also a comment ; and the next line should be a blank comment. ; # foo # = should all be treated as a comment.""" PROCESSED_CONFIG = """[__COMMENTS__] __HASH0__ = comments before first section should work __BLANK1__ = [section] __SECTION2__ = anything goes __INLINE3__ = after the [] block it seems. __SEMICOLON4__ = this is a valid comment this-should-equal-baz = baz __INLINE5__ = as this is a comment this-should-equal-everything = baz # as this is not a comment __BLANK6__ = __HASH7__ = this is also a comment __INLINE8__ = and the next line should be a blank comment. __SEMICOLON9__ = __HASH10__ = foo # = should all be treated as a comment.""" class PreProcessorTest(unittest.TestCase): maxDiff = None # Show entire diff. def test_empty_config(self): result = config._preprocess('') self.assertEqual(result, '[__COMMENTS__]') def test_plain_section(self): result = config._preprocess('[section]\nfoo = bar') self.assertEqual(result, '[__COMMENTS__]\n' '[section]\n' 'foo = bar') def test_initial_comments(self): result = config._preprocess('; foobar') self.assertEqual(result, '[__COMMENTS__]\n' '__SEMICOLON0__ = foobar') result = config._preprocess('# foobar') self.assertEqual(result, '[__COMMENTS__]\n' '__HASH0__ = foobar') result = config._preprocess('; foo\n# bar') self.assertEqual(result, '[__COMMENTS__]\n' '__SEMICOLON0__ = foo\n' '__HASH1__ = bar') def test_initial_comment_inline_handling(self): result = config._preprocess('; foo ; bar ; baz') self.assertEqual(result, '[__COMMENTS__]\n' '__SEMICOLON0__ = foo\n' '__INLINE1__ = bar\n' '__INLINE2__ = baz') def test_inline_semicolon_comment(self): result = config._preprocess('[section]\nfoo = bar ; baz') self.assertEqual(result, '[__COMMENTS__]\n' '[section]\n' 'foo = bar\n' '__INLINE0__ = baz') def test_no_inline_hash_comment(self): result = config._preprocess('[section]\nfoo = bar # baz') self.assertEqual(result, '[__COMMENTS__]\n' '[section]\n' 'foo = bar # baz') def test_section_extra_text(self): result = config._preprocess('[section] foobar') self.assertEqual(result, '[__COMMENTS__]\n' '[section]\n' '__SECTION0__ = foobar') def test_section_extra_text_inline_semicolon(self): result = config._preprocess('[section] foobar ; baz') self.assertEqual(result, '[__COMMENTS__]\n' '[section]\n' '__SECTION0__ = foobar\n' '__INLINE1__ = baz') def test_conversion(self): """Tests all of the above cases at once.""" result = config._preprocess(INPUT_CONFIG) self.assertEqual(result, PROCESSED_CONFIG) class PostProcessorTest(unittest.TestCase): maxDiff = None # Show entire diff. def test_empty_config(self): result = config._postprocess('[__COMMENTS__]') self.assertEqual(result, '') def test_plain_section(self): result = config._postprocess('[__COMMENTS__]\n' '[section]\n' 'foo = bar') self.assertEqual(result, '[section]\nfoo = bar') def test_initial_comments(self): result = config._postprocess('[__COMMENTS__]\n' '__SEMICOLON0__ = foobar') self.assertEqual(result, '; foobar') result = config._postprocess('[__COMMENTS__]\n' '__HASH0__ = foobar') self.assertEqual(result, '# foobar') result = config._postprocess('[__COMMENTS__]\n' '__SEMICOLON0__ = foo\n' '__HASH1__ = bar') self.assertEqual(result, '; foo\n# bar') def test_initial_comment_inline_handling(self): result = config._postprocess('[__COMMENTS__]\n' '__SEMICOLON0__ = foo\n' '__INLINE1__ = bar\n' '__INLINE2__ = baz') self.assertEqual(result, '; foo ; bar ; baz') def test_inline_semicolon_comment(self): result = config._postprocess('[__COMMENTS__]\n' '[section]\n' 'foo = bar\n' '__INLINE0__ = baz') self.assertEqual(result, '[section]\nfoo = bar ; baz') def test_no_inline_hash_comment(self): result = config._preprocess('[section]\nfoo = bar # baz') self.assertEqual(result, '[__COMMENTS__]\n' '[section]\n' 'foo = bar # baz') def test_section_extra_text(self): result = config._postprocess('[__COMMENTS__]\n' '[section]\n' '__SECTION0__ = foobar') self.assertEqual(result, '[section] foobar') def test_section_extra_text_inline_semicolon(self): result = config._postprocess('[__COMMENTS__]\n' '[section]\n' '__SECTION0__ = foobar\n' '__INLINE1__ = baz') self.assertEqual(result, '[section] foobar ; baz') def test_conversion(self): result = config._postprocess(PROCESSED_CONFIG) self.assertEqual(result, INPUT_CONFIG) def test_format_initial(): extension = ext.Extension() extension.ext_name = 'foo' extension.get_default_config = lambda: None extensions_data = [ ext.ExtensionData( extension=extension, entry_point=None, config_schema=None, config_defaults=None, command=None, ), ] result = config.format_initial(extensions_data) assert '# For further information' in result assert '[foo]\n' in result Mopidy-2.0.0/tests/config/__init__.py0000644000175000017500000000000012441116637017671 0ustar jodaljodal00000000000000Mopidy-2.0.0/tests/config/test_defaults.py0000664000175000017500000000162012575004517021014 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mopidy import config def test_core_schema_has_cache_dir(): assert 'cache_dir' in config._core_schema assert isinstance(config._core_schema['cache_dir'], config.Path) def test_core_schema_has_config_dir(): assert 'config_dir' in config._core_schema assert isinstance(config._core_schema['config_dir'], config.Path) def test_core_schema_has_data_dir(): assert 'data_dir' in config._core_schema assert isinstance(config._core_schema['data_dir'], config.Path) def test_core_schema_has_max_tracklist_length(): assert 'max_tracklist_length' in config._core_schema max_tracklist_length_schema = config._core_schema['max_tracklist_length'] assert isinstance(max_tracklist_length_schema, config.Integer) assert max_tracklist_length_schema._minimum == 1 assert max_tracklist_length_schema._maximum == 10000 Mopidy-2.0.0/tests/config/test_types.py0000664000175000017500000003531712614502604020355 0ustar jodaljodal00000000000000# encoding: utf-8 from __future__ import absolute_import, unicode_literals import logging import socket import unittest import mock from mopidy import compat from mopidy.config import types # TODO: DecodeTest and EncodeTest class ConfigValueTest(unittest.TestCase): def test_deserialize_passes_through(self): value = types.ConfigValue() sentinel = object() self.assertEqual(sentinel, value.deserialize(sentinel)) def test_serialize_conversion_to_string(self): value = types.ConfigValue() self.assertIsInstance(value.serialize(object()), bytes) def test_serialize_none(self): value = types.ConfigValue() result = value.serialize(None) self.assertIsInstance(result, bytes) self.assertEqual(b'', result) def test_serialize_supports_display(self): value = types.ConfigValue() self.assertIsInstance(value.serialize(object(), display=True), bytes) class DeprecatedTest(unittest.TestCase): def test_deserialize_returns_deprecated_value(self): self.assertIsInstance(types.Deprecated().deserialize(b'foobar'), types.DeprecatedValue) def test_serialize_returns_deprecated_value(self): self.assertIsInstance(types.Deprecated().serialize('foobar'), types.DeprecatedValue) class StringTest(unittest.TestCase): def test_deserialize_conversion_success(self): value = types.String() self.assertEqual('foo', value.deserialize(b' foo ')) self.assertIsInstance(value.deserialize(b'foo'), compat.text_type) def test_deserialize_decodes_utf8(self): value = types.String() result = value.deserialize('æøå'.encode('utf-8')) self.assertEqual('æøå', result) def test_deserialize_does_not_double_encode_unicode(self): value = types.String() result = value.deserialize('æøå') self.assertEqual('æøå', result) def test_deserialize_handles_escapes(self): value = types.String(optional=True) result = value.deserialize(b'a\\t\\nb') self.assertEqual('a\t\nb', result) def test_deserialize_enforces_choices(self): value = types.String(choices=['foo', 'bar', 'baz']) self.assertEqual('foo', value.deserialize(b'foo')) self.assertRaises(ValueError, value.deserialize, b'foobar') def test_deserialize_enforces_required(self): value = types.String() self.assertRaises(ValueError, value.deserialize, b'') def test_deserialize_respects_optional(self): value = types.String(optional=True) self.assertIsNone(value.deserialize(b'')) self.assertIsNone(value.deserialize(b' ')) def test_deserialize_decode_failure(self): value = types.String() incorrectly_encoded_bytes = u'æøå'.encode('iso-8859-1') self.assertRaises( ValueError, value.deserialize, incorrectly_encoded_bytes) def test_serialize_encodes_utf8(self): value = types.String() result = value.serialize('æøå') self.assertIsInstance(result, bytes) self.assertEqual('æøå'.encode('utf-8'), result) def test_serialize_does_not_encode_bytes(self): value = types.String() result = value.serialize('æøå'.encode('utf-8')) self.assertIsInstance(result, bytes) self.assertEqual('æøå'.encode('utf-8'), result) def test_serialize_handles_escapes(self): value = types.String() result = value.serialize('a\n\tb') self.assertIsInstance(result, bytes) self.assertEqual(r'a\n\tb'.encode('utf-8'), result) def test_serialize_none(self): value = types.String() result = value.serialize(None) self.assertIsInstance(result, bytes) self.assertEqual(b'', result) def test_deserialize_enforces_choices_optional(self): value = types.String(optional=True, choices=['foo', 'bar', 'baz']) self.assertEqual(None, value.deserialize(b'')) self.assertRaises(ValueError, value.deserialize, b'foobar') class SecretTest(unittest.TestCase): def test_deserialize_decodes_utf8(self): value = types.Secret() result = value.deserialize('æøå'.encode('utf-8')) self.assertIsInstance(result, compat.text_type) self.assertEqual('æøå', result) def test_deserialize_enforces_required(self): value = types.Secret() self.assertRaises(ValueError, value.deserialize, b'') def test_deserialize_respects_optional(self): value = types.Secret(optional=True) self.assertIsNone(value.deserialize(b'')) self.assertIsNone(value.deserialize(b' ')) def test_serialize_none(self): value = types.Secret() result = value.serialize(None) self.assertIsInstance(result, bytes) self.assertEqual(b'', result) def test_serialize_for_display_masks_value(self): value = types.Secret() result = value.serialize('s3cret', display=True) self.assertIsInstance(result, bytes) self.assertEqual(b'********', result) def test_serialize_none_for_display(self): value = types.Secret() result = value.serialize(None, display=True) self.assertIsInstance(result, bytes) self.assertEqual(b'', result) class IntegerTest(unittest.TestCase): def test_deserialize_conversion_success(self): value = types.Integer() self.assertEqual(123, value.deserialize('123')) self.assertEqual(0, value.deserialize('0')) self.assertEqual(-10, value.deserialize('-10')) def test_deserialize_conversion_failure(self): value = types.Integer() self.assertRaises(ValueError, value.deserialize, 'asd') self.assertRaises(ValueError, value.deserialize, '3.14') self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_deserialize_enforces_choices(self): value = types.Integer(choices=[1, 2, 3]) self.assertEqual(3, value.deserialize('3')) self.assertRaises(ValueError, value.deserialize, '5') def test_deserialize_enforces_minimum(self): value = types.Integer(minimum=10) self.assertEqual(15, value.deserialize('15')) self.assertRaises(ValueError, value.deserialize, '5') def test_deserialize_enforces_maximum(self): value = types.Integer(maximum=10) self.assertEqual(5, value.deserialize('5')) self.assertRaises(ValueError, value.deserialize, '15') def test_deserialize_respects_optional(self): value = types.Integer(optional=True) self.assertEqual(None, value.deserialize('')) class BooleanTest(unittest.TestCase): def test_deserialize_conversion_success(self): value = types.Boolean() for true in ('1', 'yes', 'true', 'on'): self.assertIs(value.deserialize(true), True) self.assertIs(value.deserialize(true.upper()), True) self.assertIs(value.deserialize(true.capitalize()), True) for false in ('0', 'no', 'false', 'off'): self.assertIs(value.deserialize(false), False) self.assertIs(value.deserialize(false.upper()), False) self.assertIs(value.deserialize(false.capitalize()), False) def test_deserialize_conversion_failure(self): value = types.Boolean() self.assertRaises(ValueError, value.deserialize, 'nope') self.assertRaises(ValueError, value.deserialize, 'sure') self.assertRaises(ValueError, value.deserialize, '') def test_serialize_true(self): value = types.Boolean() result = value.serialize(True) self.assertEqual(b'true', result) self.assertIsInstance(result, bytes) def test_serialize_false(self): value = types.Boolean() result = value.serialize(False) self.assertEqual(b'false', result) self.assertIsInstance(result, bytes) def test_deserialize_respects_optional(self): value = types.Boolean(optional=True) self.assertEqual(None, value.deserialize('')) # TODO: test None or other invalid values into serialize? class ListTest(unittest.TestCase): # TODO: add test_deserialize_ignores_blank # TODO: add test_serialize_ignores_blank # TODO: add test_deserialize_handles_escapes def test_deserialize_conversion_success(self): value = types.List() expected = ('foo', 'bar', 'baz') self.assertEqual(expected, value.deserialize(b'foo, bar ,baz ')) expected = ('foo,bar', 'bar', 'baz') self.assertEqual(expected, value.deserialize(b' foo,bar\nbar\nbaz')) def test_deserialize_creates_tuples(self): value = types.List(optional=True) self.assertIsInstance(value.deserialize(b'foo,bar,baz'), tuple) self.assertIsInstance(value.deserialize(b''), tuple) def test_deserialize_decodes_utf8(self): value = types.List() result = value.deserialize('æ, ø, å'.encode('utf-8')) self.assertEqual(('æ', 'ø', 'å'), result) result = value.deserialize('æ\nø\nå'.encode('utf-8')) self.assertEqual(('æ', 'ø', 'å'), result) def test_deserialize_does_not_double_encode_unicode(self): value = types.List() result = value.deserialize('æ, ø, å') self.assertEqual(('æ', 'ø', 'å'), result) result = value.deserialize('æ\nø\nå') self.assertEqual(('æ', 'ø', 'å'), result) def test_deserialize_enforces_required(self): value = types.List() self.assertRaises(ValueError, value.deserialize, b'') def test_deserialize_respects_optional(self): value = types.List(optional=True) self.assertEqual(tuple(), value.deserialize(b'')) def test_serialize(self): value = types.List() result = value.serialize(('foo', 'bar', 'baz')) self.assertIsInstance(result, bytes) self.assertRegexpMatches(result, r'foo\n\s*bar\n\s*baz') def test_serialize_none(self): value = types.List() result = value.serialize(None) self.assertIsInstance(result, bytes) self.assertEqual(result, '') class LogLevelTest(unittest.TestCase): levels = { 'critical': logging.CRITICAL, 'error': logging.ERROR, 'warning': logging.WARNING, 'info': logging.INFO, 'debug': logging.DEBUG, 'all': logging.NOTSET, } def test_deserialize_conversion_success(self): value = types.LogLevel() for name, level in self.levels.items(): self.assertEqual(level, value.deserialize(name)) self.assertEqual(level, value.deserialize(name.upper())) self.assertEqual(level, value.deserialize(name.capitalize())) def test_deserialize_conversion_failure(self): value = types.LogLevel() self.assertRaises(ValueError, value.deserialize, 'nope') self.assertRaises(ValueError, value.deserialize, 'sure') self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_serialize(self): value = types.LogLevel() for name, level in self.levels.items(): self.assertEqual(name, value.serialize(level)) self.assertEqual(b'', value.serialize(1337)) class HostnameTest(unittest.TestCase): @mock.patch('socket.getaddrinfo') def test_deserialize_conversion_success(self, getaddrinfo_mock): value = types.Hostname() value.deserialize('example.com') getaddrinfo_mock.assert_called_once_with('example.com', None) @mock.patch('socket.getaddrinfo') def test_deserialize_conversion_failure(self, getaddrinfo_mock): value = types.Hostname() getaddrinfo_mock.side_effect = socket.error self.assertRaises(ValueError, value.deserialize, 'example.com') @mock.patch('socket.getaddrinfo') def test_deserialize_enforces_required(self, getaddrinfo_mock): value = types.Hostname() self.assertRaises(ValueError, value.deserialize, '') self.assertEqual(0, getaddrinfo_mock.call_count) @mock.patch('socket.getaddrinfo') def test_deserialize_respects_optional(self, getaddrinfo_mock): value = types.Hostname(optional=True) self.assertIsNone(value.deserialize('')) self.assertIsNone(value.deserialize(' ')) self.assertEqual(0, getaddrinfo_mock.call_count) class PortTest(unittest.TestCase): def test_valid_ports(self): value = types.Port() self.assertEqual(0, value.deserialize('0')) self.assertEqual(1, value.deserialize('1')) self.assertEqual(80, value.deserialize('80')) self.assertEqual(6600, value.deserialize('6600')) self.assertEqual(65535, value.deserialize('65535')) def test_invalid_ports(self): value = types.Port() self.assertRaises(ValueError, value.deserialize, '65536') self.assertRaises(ValueError, value.deserialize, '100000') self.assertRaises(ValueError, value.deserialize, '-1') self.assertRaises(ValueError, value.deserialize, '') class ExpandedPathTest(unittest.TestCase): def test_is_bytes(self): self.assertIsInstance(types.ExpandedPath(b'/tmp', b'foo'), bytes) def test_defaults_to_expanded(self): original = b'~' expanded = b'expanded_path' self.assertEqual(expanded, types.ExpandedPath(original, expanded)) @mock.patch('mopidy.internal.path.expand_path') def test_orginal_stores_unexpanded(self, expand_path_mock): original = b'~' expanded = b'expanded_path' result = types.ExpandedPath(original, expanded) self.assertEqual(original, result.original) class PathTest(unittest.TestCase): def test_deserialize_conversion_success(self): result = types.Path().deserialize(b'/foo') self.assertEqual('/foo', result) self.assertIsInstance(result, types.ExpandedPath) self.assertIsInstance(result, bytes) def test_deserialize_enforces_required(self): value = types.Path() self.assertRaises(ValueError, value.deserialize, b'') def test_deserialize_respects_optional(self): value = types.Path(optional=True) self.assertIsNone(value.deserialize(b'')) self.assertIsNone(value.deserialize(b' ')) def test_serialize_uses_original(self): path = types.ExpandedPath(b'original_path', b'expanded_path') value = types.Path() self.assertEqual('expanded_path', path) self.assertEqual('original_path', value.serialize(path)) def test_serialize_plain_string(self): value = types.Path() self.assertEqual('path', value.serialize(b'path')) def test_serialize_unicode_string(self): value = types.Path() self.assertRaises(ValueError, value.serialize, 'æøå') Mopidy-2.0.0/tests/models/0000775000175000017500000000000012660436443015615 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/models/test_legacy.py0000664000175000017500000001246412575004517020477 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest from mopidy.models import ImmutableObject class Model(ImmutableObject): uri = None name = None models = frozenset() def __init__(self, *args, **kwargs): self.__dict__['models'] = frozenset(kwargs.pop('models', None) or []) super(Model, self).__init__(self, *args, **kwargs) class SubModel(ImmutableObject): uri = None name = None class GenericCopyTest(unittest.TestCase): def compare(self, orig, other): self.assertEqual(orig, other) self.assertNotEqual(id(orig), id(other)) def test_copying_model(self): model = Model() self.compare(model, model.replace()) def test_copying_model_with_basic_values(self): model = Model(name='foo', uri='bar') other = model.replace(name='baz') self.assertEqual('baz', other.name) self.assertEqual('bar', other.uri) def test_copying_model_with_missing_values(self): model = Model(uri='bar') other = model.replace(name='baz') self.assertEqual('baz', other.name) self.assertEqual('bar', other.uri) def test_copying_model_with_private_internal_value(self): model = Model(models=[SubModel(name=123)]) other = model.replace(models=[SubModel(name=345)]) self.assertIn(SubModel(name=345), other.models) def test_copying_model_with_invalid_key(self): with self.assertRaises(TypeError): Model().replace(invalid_key=True) def test_copying_model_to_remove(self): model = Model(name='foo').replace(name=None) self.assertEqual(model, Model()) class ModelTest(unittest.TestCase): def test_uri(self): uri = 'an_uri' model = Model(uri=uri) self.assertEqual(model.uri, uri) with self.assertRaises(AttributeError): model.uri = None def test_name(self): name = 'a name' model = Model(name=name) self.assertEqual(model.name, name) with self.assertRaises(AttributeError): model.name = None def test_submodels(self): models = [SubModel(name=123), SubModel(name=456)] model = Model(models=models) self.assertEqual(set(model.models), set(models)) with self.assertRaises(AttributeError): model.models = None def test_models_none(self): self.assertEqual(set(), Model(models=None).models) def test_invalid_kwarg(self): with self.assertRaises(TypeError): Model(foo='baz') def test_repr_without_models(self): self.assertEqual( "Model(name=u'name', uri=u'uri')", repr(Model(uri='uri', name='name'))) def test_repr_with_models(self): self.assertEqual( "Model(models=[SubModel(name=123)], name=u'name', uri=u'uri')", repr(Model(uri='uri', name='name', models=[SubModel(name=123)]))) def test_serialize_without_models(self): self.assertDictEqual( {'__model__': 'Model', 'uri': 'uri', 'name': 'name'}, Model(uri='uri', name='name').serialize()) def test_serialize_with_models(self): submodel = SubModel(name=123) self.assertDictEqual( {'__model__': 'Model', 'uri': 'uri', 'name': 'name', 'models': [submodel.serialize()]}, Model(uri='uri', name='name', models=[submodel]).serialize()) def test_eq_uri(self): model1 = Model(uri='uri1') model2 = Model(uri='uri1') self.assertEqual(model1, model2) self.assertEqual(hash(model1), hash(model2)) def test_eq_name(self): model1 = Model(name='name1') model2 = Model(name='name1') self.assertEqual(model1, model2) self.assertEqual(hash(model1), hash(model2)) def test_eq_models(self): models = [SubModel()] model1 = Model(models=models) model2 = Model(models=models) self.assertEqual(model1, model2) self.assertEqual(hash(model1), hash(model2)) def test_eq_models_order(self): submodel1 = SubModel(name='name1') submodel2 = SubModel(name='name2') model1 = Model(models=[submodel1, submodel2]) model2 = Model(models=[submodel2, submodel1]) self.assertEqual(model1, model2) self.assertEqual(hash(model1), hash(model2)) def test_eq_none(self): self.assertNotEqual(Model(), None) def test_eq_other(self): self.assertNotEqual(Model(), 'other') def test_ne_uri(self): model1 = Model(uri='uri1') model2 = Model(uri='uri2') self.assertNotEqual(model1, model2) self.assertNotEqual(hash(model1), hash(model2)) def test_ne_name(self): model1 = Model(name='name1') model2 = Model(name='name2') self.assertNotEqual(model1, model2) self.assertNotEqual(hash(model1), hash(model2)) def test_ne_models(self): model1 = Model(models=[SubModel(name='name1')]) model2 = Model(models=[SubModel(name='name2')]) self.assertNotEqual(model1, model2) self.assertNotEqual(hash(model1), hash(model2)) def test_ignores_values_with_default_value_none(self): model1 = Model(name='name1') model2 = Model(name='name1', uri=None) self.assertEqual(model1, model2) self.assertEqual(hash(model1), hash(model2)) Mopidy-2.0.0/tests/models/test_fields.py0000664000175000017500000001506312575004517020477 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest from mopidy.models.fields import * # noqa: F403 def create_instance(field): """Create an instance of a dummy class for testing fields.""" class Dummy(object): attr = field attr._name = 'attr' return Dummy() class FieldDescriptorTest(unittest.TestCase): def test_raw_field_accesible_through_class(self): field = Field() instance = create_instance(field) self.assertEqual(field, instance.__class__.attr) def test_field_knows_its_name(self): instance = create_instance(Field()) self.assertEqual('attr', instance.__class__.attr._name) def test_field_has_none_as_default(self): instance = create_instance(Field()) self.assertIsNone(instance.attr) def test_field_does_not_store_default(self): instance = create_instance(Field()) self.assertFalse(hasattr(instance, '_attr')) def test_field_assigment_and_retrival(self): instance = create_instance(Field()) instance.attr = 1234 self.assertEqual(1234, instance.attr) def test_field_can_be_reassigned(self): instance = create_instance(Field()) instance.attr = 1234 instance.attr = 5678 self.assertEqual(5678, instance.attr) def test_field_can_be_deleted(self): instance = create_instance(Field()) instance.attr = 1234 del instance.attr self.assertEqual(None, instance.attr) self.assertFalse(hasattr(instance, '_attr')) def test_field_can_be_set_to_none(self): instance = create_instance(Field()) instance.attr = 1234 instance.attr = None self.assertEqual(None, instance.attr) self.assertFalse(hasattr(instance, '_attr')) def test_field_can_be_set_default(self): default = object() instance = create_instance(Field(default=default)) instance.attr = 1234 instance.attr = default self.assertEqual(default, instance.attr) self.assertFalse(hasattr(instance, '_attr')) class FieldTest(unittest.TestCase): def test_default_handling(self): instance = create_instance(Field(default=1234)) self.assertEqual(1234, instance.attr) def test_type_checking(self): instance = create_instance(Field(type=set)) instance.attr = set() with self.assertRaises(TypeError): instance.attr = 1234 def test_choices_checking(self): instance = create_instance(Field(choices=(1, 2, 3))) instance.attr = 1 with self.assertRaises(TypeError): instance.attr = 4 def test_default_respects_type_check(self): with self.assertRaises(TypeError): create_instance(Field(type=int, default='123')) def test_default_respects_choices_check(self): with self.assertRaises(TypeError): create_instance(Field(choices=(1, 2, 3), default=5)) class StringTest(unittest.TestCase): def test_default_handling(self): instance = create_instance(String(default='abc')) self.assertEqual('abc', instance.attr) def test_native_str_allowed(self): instance = create_instance(String()) instance.attr = str('abc') self.assertEqual('abc', instance.attr) def test_bytes_allowed(self): instance = create_instance(String()) instance.attr = b'abc' self.assertEqual(b'abc', instance.attr) def test_unicode_allowed(self): instance = create_instance(String()) instance.attr = u'abc' self.assertEqual(u'abc', instance.attr) def test_other_disallowed(self): instance = create_instance(String()) with self.assertRaises(TypeError): instance.attr = 1234 def test_empty_string(self): instance = create_instance(String()) instance.attr = '' self.assertEqual('', instance.attr) class IntegerTest(unittest.TestCase): def test_default_handling(self): instance = create_instance(Integer(default=1234)) self.assertEqual(1234, instance.attr) def test_int_allowed(self): instance = create_instance(Integer()) instance.attr = int(123) self.assertEqual(123, instance.attr) def test_long_allowed(self): instance = create_instance(Integer()) instance.attr = long(123) self.assertEqual(123, instance.attr) def test_float_disallowed(self): instance = create_instance(Integer()) with self.assertRaises(TypeError): instance.attr = 123.0 def test_numeric_string_disallowed(self): instance = create_instance(Integer()) with self.assertRaises(TypeError): instance.attr = '123' def test_other_disallowed(self): instance = create_instance(String()) with self.assertRaises(TypeError): instance.attr = tuple() def test_min_validation(self): instance = create_instance(Integer(min=0)) instance.attr = 0 self.assertEqual(0, instance.attr) with self.assertRaises(ValueError): instance.attr = -1 def test_max_validation(self): instance = create_instance(Integer(max=10)) instance.attr = 10 self.assertEqual(10, instance.attr) with self.assertRaises(ValueError): instance.attr = 11 class CollectionTest(unittest.TestCase): def test_container_instance_is_default(self): instance = create_instance(Collection(type=int, container=frozenset)) self.assertEqual(frozenset(), instance.attr) def test_empty_collection(self): instance = create_instance(Collection(type=int, container=frozenset)) instance.attr = [] self.assertEqual(frozenset(), instance.attr) def test_collection_gets_stored_in_container(self): instance = create_instance(Collection(type=int, container=frozenset)) instance.attr = [1, 2, 3] self.assertEqual(frozenset([1, 2, 3]), instance.attr) def test_collection_with_wrong_type(self): instance = create_instance(Collection(type=int, container=frozenset)) with self.assertRaises(TypeError): instance.attr = [1, '2', 3] def test_collection_with_string(self): instance = create_instance(Collection(type=int, container=frozenset)) with self.assertRaises(TypeError): instance.attr = '123' def test_strings_should_not_be_considered_a_collection(self): instance = create_instance(Collection(type=str, container=tuple)) with self.assertRaises(TypeError): instance.attr = b'123' Mopidy-2.0.0/tests/models/test_models.py0000664000175000017500000011752512575004517020522 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import json import unittest from mopidy.models import ( Album, Artist, Image, ModelJSONEncoder, Playlist, Ref, SearchResult, TlTrack, Track, model_json_decoder) class InheritanceTest(unittest.TestCase): def test_weakref_and_slots_play_nice_in_subclass(self): # Check that the following does not happen: # TypeError: Error when calling the metaclass bases # __weakref__ slot disallowed: either we already got one... class Foo(Track): pass def test_sub_class_can_have_its_own_slots(self): # Needed for things like SpotifyTrack in mopidy-spotify 1.x class Foo(Track): __slots__ = ('_foo',) f = Foo() f._foo = 123 def test_sub_class_can_be_initialized(self): # Fails with following error if fields are not handled across classes. # TypeError: __init__() got an unexpected keyword argument "type" # Essentially this is testing that sub-classes take parent _fields into # account. class Foo(Ref): pass Foo.directory() class CachingTest(unittest.TestCase): def test_same_instance(self): self.assertIs(Track(), Track()) def test_same_instance_with_values(self): self.assertIs(Track(uri='test'), Track(uri='test')) def test_different_instance_with_different_values(self): self.assertIsNot(Track(uri='test1'), Track(uri='test2')) def test_different_instance_with_replace(self): t = Track(uri='test1') self.assertIsNot(t, t.replace(uri='test2')) class GenericReplaceTest(unittest.TestCase): def compare(self, orig, other): self.assertEqual(orig, other) self.assertEqual(id(orig), id(other)) def test_replace_track(self): track = Track() self.compare(track, track.replace()) def test_replace_artist(self): artist = Artist() self.compare(artist, artist.replace()) def test_replace_album(self): album = Album() self.compare(album, album.replace()) def test_replace_playlist(self): playlist = Playlist() self.compare(playlist, playlist.replace()) def test_replace_track_with_basic_values(self): track = Track(name='foo', uri='bar') other = track.replace(name='baz') self.assertEqual('baz', other.name) self.assertEqual('bar', other.uri) def test_replace_track_with_missing_values(self): track = Track(uri='bar') other = track.replace(name='baz') self.assertEqual('baz', other.name) self.assertEqual('bar', other.uri) def test_replace_track_with_private_internal_value(self): artist1 = Artist(name='foo') artist2 = Artist(name='bar') track = Track(artists=[artist1]) other = track.replace(artists=[artist2]) self.assertIn(artist2, other.artists) def test_replace_track_with_invalid_key(self): with self.assertRaises(TypeError): Track().replace(invalid_key=True) def test_replace_track_to_remove(self): track = Track(name='foo').replace(name=None) self.assertFalse(hasattr(track, '_name')) class RefTest(unittest.TestCase): def test_uri(self): uri = 'an_uri' ref = Ref(uri=uri) self.assertEqual(ref.uri, uri) with self.assertRaises(AttributeError): ref.uri = None def test_name(self): name = 'a name' ref = Ref(name=name) self.assertEqual(ref.name, name) with self.assertRaises(AttributeError): ref.name = None # TODO: add these for the more of the models? def test_del_name(self): ref = Ref(name='foo') with self.assertRaises(AttributeError): del ref.name def test_invalid_kwarg(self): with self.assertRaises(TypeError): Ref(foo='baz') def test_repr_without_results(self): self.assertEqual( "Ref(name=u'foo', type='artist', uri='uri')", repr(Ref(uri='uri', name='foo', type='artist'))) def test_serialize_without_results(self): self.assertDictEqual( {'__model__': 'Ref', 'uri': 'uri'}, Ref(uri='uri').serialize()) def test_to_json_and_back(self): ref1 = Ref(uri='uri') serialized = json.dumps(ref1, cls=ModelJSONEncoder) ref2 = json.loads(serialized, object_hook=model_json_decoder) self.assertEqual(ref1, ref2) def test_type_constants(self): self.assertEqual(Ref.ALBUM, 'album') self.assertEqual(Ref.ARTIST, 'artist') self.assertEqual(Ref.DIRECTORY, 'directory') self.assertEqual(Ref.PLAYLIST, 'playlist') self.assertEqual(Ref.TRACK, 'track') def test_album_constructor(self): ref = Ref.album(uri='foo', name='bar') self.assertEqual(ref.uri, 'foo') self.assertEqual(ref.name, 'bar') self.assertEqual(ref.type, Ref.ALBUM) def test_artist_constructor(self): ref = Ref.artist(uri='foo', name='bar') self.assertEqual(ref.uri, 'foo') self.assertEqual(ref.name, 'bar') self.assertEqual(ref.type, Ref.ARTIST) def test_directory_constructor(self): ref = Ref.directory(uri='foo', name='bar') self.assertEqual(ref.uri, 'foo') self.assertEqual(ref.name, 'bar') self.assertEqual(ref.type, Ref.DIRECTORY) def test_playlist_constructor(self): ref = Ref.playlist(uri='foo', name='bar') self.assertEqual(ref.uri, 'foo') self.assertEqual(ref.name, 'bar') self.assertEqual(ref.type, Ref.PLAYLIST) def test_track_constructor(self): ref = Ref.track(uri='foo', name='bar') self.assertEqual(ref.uri, 'foo') self.assertEqual(ref.name, 'bar') self.assertEqual(ref.type, Ref.TRACK) class ImageTest(unittest.TestCase): def test_uri(self): uri = 'an_uri' image = Image(uri=uri) self.assertEqual(image.uri, uri) with self.assertRaises(AttributeError): image.uri = None def test_width(self): image = Image(width=100) self.assertEqual(image.width, 100) with self.assertRaises(AttributeError): image.width = None def test_height(self): image = Image(height=100) self.assertEqual(image.height, 100) with self.assertRaises(AttributeError): image.height = None def test_invalid_kwarg(self): with self.assertRaises(TypeError): Image(foo='baz') class ArtistTest(unittest.TestCase): def test_uri(self): uri = 'an_uri' artist = Artist(uri=uri) self.assertEqual(artist.uri, uri) with self.assertRaises(AttributeError): artist.uri = None def test_name(self): name = 'a name' artist = Artist(name=name) self.assertEqual(artist.name, name) with self.assertRaises(AttributeError): artist.name = None def test_musicbrainz_id(self): mb_id = 'mb-id' artist = Artist(musicbrainz_id=mb_id) self.assertEqual(artist.musicbrainz_id, mb_id) with self.assertRaises(AttributeError): artist.musicbrainz_id = None def test_invalid_kwarg(self): with self.assertRaises(TypeError): Artist(foo='baz') def test_invalid_kwarg_with_name_matching_method(self): with self.assertRaises(TypeError): Artist(replace='baz') with self.assertRaises(TypeError): Artist(serialize='baz') def test_repr(self): self.assertEqual( "Artist(name=u'name', uri='uri')", repr(Artist(uri='uri', name='name'))) def test_serialize(self): self.assertDictEqual( {'__model__': 'Artist', 'uri': 'uri', 'name': 'name'}, Artist(uri='uri', name='name').serialize()) def test_serialize_falsy_values(self): self.assertDictEqual( {'__model__': 'Artist', 'uri': '', 'name': ''}, Artist(uri='', name='').serialize()) def test_to_json_and_back(self): artist1 = Artist(uri='uri', name='name') serialized = json.dumps(artist1, cls=ModelJSONEncoder) artist2 = json.loads(serialized, object_hook=model_json_decoder) self.assertEqual(artist1, artist2) def test_to_json_and_back_with_unknown_field(self): artist = Artist(uri='uri', name='name').serialize() artist['foo'] = 'foo' serialized = json.dumps(artist) with self.assertRaises(TypeError): json.loads(serialized, object_hook=model_json_decoder) def test_to_json_and_back_with_field_matching_method(self): artist = Artist(uri='uri', name='name').serialize() artist['copy'] = 'foo' serialized = json.dumps(artist) with self.assertRaises(TypeError): json.loads(serialized, object_hook=model_json_decoder) def test_to_json_and_back_with_field_matching_internal_field(self): artist = Artist(uri='uri', name='name').serialize() artist['__mro__'] = 'foo' serialized = json.dumps(artist) with self.assertRaises(TypeError): json.loads(serialized, object_hook=model_json_decoder) def test_eq_name(self): artist1 = Artist(name='name') artist2 = Artist(name='name') self.assertEqual(artist1, artist2) self.assertEqual(hash(artist1), hash(artist2)) def test_eq_uri(self): artist1 = Artist(uri='uri') artist2 = Artist(uri='uri') self.assertEqual(artist1, artist2) self.assertEqual(hash(artist1), hash(artist2)) def test_eq_musibrainz_id(self): artist1 = Artist(musicbrainz_id='id') artist2 = Artist(musicbrainz_id='id') self.assertEqual(artist1, artist2) self.assertEqual(hash(artist1), hash(artist2)) def test_eq(self): artist1 = Artist(uri='uri', name='name', musicbrainz_id='id') artist2 = Artist(uri='uri', name='name', musicbrainz_id='id') self.assertEqual(artist1, artist2) self.assertEqual(hash(artist1), hash(artist2)) def test_eq_none(self): self.assertNotEqual(Artist(), None) def test_eq_other(self): self.assertNotEqual(Artist(), 'other') def test_ne_name(self): artist1 = Artist(name='name1') artist2 = Artist(name='name2') self.assertNotEqual(artist1, artist2) self.assertNotEqual(hash(artist1), hash(artist2)) def test_ne_uri(self): artist1 = Artist(uri='uri1') artist2 = Artist(uri='uri2') self.assertNotEqual(artist1, artist2) self.assertNotEqual(hash(artist1), hash(artist2)) def test_ne_musicbrainz_id(self): artist1 = Artist(musicbrainz_id='id1') artist2 = Artist(musicbrainz_id='id2') self.assertNotEqual(artist1, artist2) self.assertNotEqual(hash(artist1), hash(artist2)) def test_ne(self): artist1 = Artist(uri='uri1', name='name1', musicbrainz_id='id1') artist2 = Artist(uri='uri2', name='name2', musicbrainz_id='id2') self.assertNotEqual(artist1, artist2) self.assertNotEqual(hash(artist1), hash(artist2)) class AlbumTest(unittest.TestCase): def test_uri(self): uri = 'an_uri' album = Album(uri=uri) self.assertEqual(album.uri, uri) with self.assertRaises(AttributeError): album.uri = None def test_name(self): name = 'a name' album = Album(name=name) self.assertEqual(album.name, name) with self.assertRaises(AttributeError): album.name = None def test_artists(self): artist = Artist() album = Album(artists=[artist]) self.assertIn(artist, album.artists) with self.assertRaises(AttributeError): album.artists = None def test_artists_none(self): self.assertEqual(set(), Album(artists=None).artists) def test_num_tracks(self): num_tracks = 11 album = Album(num_tracks=num_tracks) self.assertEqual(album.num_tracks, num_tracks) with self.assertRaises(AttributeError): album.num_tracks = None def test_num_discs(self): num_discs = 2 album = Album(num_discs=num_discs) self.assertEqual(album.num_discs, num_discs) with self.assertRaises(AttributeError): album.num_discs = None def test_date(self): date = '1977-01-01' album = Album(date=date) self.assertEqual(album.date, date) with self.assertRaises(AttributeError): album.date = None def test_musicbrainz_id(self): mb_id = 'mb-id' album = Album(musicbrainz_id=mb_id) self.assertEqual(album.musicbrainz_id, mb_id) with self.assertRaises(AttributeError): album.musicbrainz_id = None def test_images(self): image = 'data:foobar' album = Album(images=[image]) self.assertIn(image, album.images) with self.assertRaises(AttributeError): album.images = None def test_images_none(self): self.assertEqual(set(), Album(images=None).images) def test_invalid_kwarg(self): with self.assertRaises(TypeError): Album(foo='baz') def test_repr_without_artists(self): self.assertEqual( "Album(name=u'name', uri='uri')", repr(Album(uri='uri', name='name'))) def test_repr_with_artists(self): self.assertEqual( "Album(artists=[Artist(name=u'foo')], name=u'name', uri='uri')", repr(Album(uri='uri', name='name', artists=[Artist(name='foo')]))) def test_serialize_without_artists(self): self.assertDictEqual( {'__model__': 'Album', 'uri': 'uri', 'name': 'name'}, Album(uri='uri', name='name').serialize()) def test_serialize_with_artists(self): artist = Artist(name='foo') self.assertDictEqual( {'__model__': 'Album', 'uri': 'uri', 'name': 'name', 'artists': [artist.serialize()]}, Album(uri='uri', name='name', artists=[artist]).serialize()) def test_serialize_with_images(self): image = 'data:foobar' self.assertDictEqual( {'__model__': 'Album', 'uri': 'uri', 'name': 'name', 'images': [image]}, Album(uri='uri', name='name', images=[image]).serialize()) def test_to_json_and_back(self): album1 = Album(uri='uri', name='name', artists=[Artist(name='foo')]) serialized = json.dumps(album1, cls=ModelJSONEncoder) album2 = json.loads(serialized, object_hook=model_json_decoder) self.assertEqual(album1, album2) def test_eq_name(self): album1 = Album(name='name') album2 = Album(name='name') self.assertEqual(album1, album2) self.assertEqual(hash(album1), hash(album2)) def test_eq_uri(self): album1 = Album(uri='uri') album2 = Album(uri='uri') self.assertEqual(album1, album2) self.assertEqual(hash(album1), hash(album2)) def test_eq_artists(self): artists = [Artist()] album1 = Album(artists=artists) album2 = Album(artists=artists) self.assertEqual(album1, album2) self.assertEqual(hash(album1), hash(album2)) def test_eq_artists_order(self): artist1 = Artist(name='name1') artist2 = Artist(name='name2') album1 = Album(artists=[artist1, artist2]) album2 = Album(artists=[artist2, artist1]) self.assertEqual(album1, album2) self.assertEqual(hash(album1), hash(album2)) def test_eq_num_tracks(self): album1 = Album(num_tracks=2) album2 = Album(num_tracks=2) self.assertEqual(album1, album2) self.assertEqual(hash(album1), hash(album2)) def test_eq_date(self): date = '1977-01-01' album1 = Album(date=date) album2 = Album(date=date) self.assertEqual(album1, album2) self.assertEqual(hash(album1), hash(album2)) def test_eq_musibrainz_id(self): album1 = Album(musicbrainz_id='id') album2 = Album(musicbrainz_id='id') self.assertEqual(album1, album2) self.assertEqual(hash(album1), hash(album2)) def test_eq(self): artists = [Artist()] album1 = Album( name='name', uri='uri', artists=artists, num_tracks=2, musicbrainz_id='id') album2 = Album( name='name', uri='uri', artists=artists, num_tracks=2, musicbrainz_id='id') self.assertEqual(album1, album2) self.assertEqual(hash(album1), hash(album2)) def test_eq_none(self): self.assertNotEqual(Album(), None) def test_eq_other(self): self.assertNotEqual(Album(), 'other') def test_ne_name(self): album1 = Album(name='name1') album2 = Album(name='name2') self.assertNotEqual(album1, album2) self.assertNotEqual(hash(album1), hash(album2)) def test_ne_uri(self): album1 = Album(uri='uri1') album2 = Album(uri='uri2') self.assertNotEqual(album1, album2) self.assertNotEqual(hash(album1), hash(album2)) def test_ne_artists(self): album1 = Album(artists=[Artist(name='name1')]) album2 = Album(artists=[Artist(name='name2')]) self.assertNotEqual(album1, album2) self.assertNotEqual(hash(album1), hash(album2)) def test_ne_num_tracks(self): album1 = Album(num_tracks=1) album2 = Album(num_tracks=2) self.assertNotEqual(album1, album2) self.assertNotEqual(hash(album1), hash(album2)) def test_ne_date(self): album1 = Album(date='1977-01-01') album2 = Album(date='1977-01-02') self.assertNotEqual(album1, album2) self.assertNotEqual(hash(album1), hash(album2)) def test_ne_musicbrainz_id(self): album1 = Album(musicbrainz_id='id1') album2 = Album(musicbrainz_id='id2') self.assertNotEqual(album1, album2) self.assertNotEqual(hash(album1), hash(album2)) def test_ne(self): album1 = Album( name='name1', uri='uri1', artists=[Artist(name='name1')], num_tracks=1, musicbrainz_id='id1') album2 = Album( name='name2', uri='uri2', artists=[Artist(name='name2')], num_tracks=2, musicbrainz_id='id2') self.assertNotEqual(album1, album2) self.assertNotEqual(hash(album1), hash(album2)) class TrackTest(unittest.TestCase): def test_uri(self): uri = 'an_uri' track = Track(uri=uri) self.assertEqual(track.uri, uri) with self.assertRaises(AttributeError): track.uri = None def test_name(self): name = 'a name' track = Track(name=name) self.assertEqual(track.name, name) with self.assertRaises(AttributeError): track.name = None def test_artists(self): artists = [Artist(name='name1'), Artist(name='name2')] track = Track(artists=artists) self.assertEqual(set(track.artists), set(artists)) with self.assertRaises(AttributeError): track.artists = None def test_artists_none(self): self.assertEqual(set(), Track(artists=None).artists) def test_composers(self): artists = [Artist(name='name1'), Artist(name='name2')] track = Track(composers=artists) self.assertEqual(set(track.composers), set(artists)) with self.assertRaises(AttributeError): track.composers = None def test_composers_none(self): self.assertEqual(set(), Track(composers=None).composers) def test_performers(self): artists = [Artist(name='name1'), Artist(name='name2')] track = Track(performers=artists) self.assertEqual(set(track.performers), set(artists)) with self.assertRaises(AttributeError): track.performers = None def test_performers_none(self): self.assertEqual(set(), Track(performers=None).performers) def test_album(self): album = Album() track = Track(album=album) self.assertEqual(track.album, album) with self.assertRaises(AttributeError): track.album = None def test_track_no(self): track_no = 7 track = Track(track_no=track_no) self.assertEqual(track.track_no, track_no) with self.assertRaises(AttributeError): track.track_no = None def test_disc_no(self): disc_no = 2 track = Track(disc_no=disc_no) self.assertEqual(track.disc_no, disc_no) with self.assertRaises(AttributeError): track.disc_no = None def test_date(self): date = '1977-01-01' track = Track(date=date) self.assertEqual(track.date, date) with self.assertRaises(AttributeError): track.date = None def test_length(self): length = 137000 track = Track(length=length) self.assertEqual(track.length, length) with self.assertRaises(AttributeError): track.length = None def test_bitrate(self): bitrate = 160 track = Track(bitrate=bitrate) self.assertEqual(track.bitrate, bitrate) with self.assertRaises(AttributeError): track.bitrate = None def test_musicbrainz_id(self): mb_id = 'mb-id' track = Track(musicbrainz_id=mb_id) self.assertEqual(track.musicbrainz_id, mb_id) with self.assertRaises(AttributeError): track.musicbrainz_id = None def test_invalid_kwarg(self): with self.assertRaises(TypeError): Track(foo='baz') def test_repr_without_artists(self): self.assertEqual( "Track(name=u'name', uri='uri')", repr(Track(uri='uri', name='name'))) def test_repr_with_artists(self): self.assertEqual( "Track(artists=[Artist(name=u'foo')], name=u'name', uri='uri')", repr(Track(uri='uri', name='name', artists=[Artist(name='foo')]))) def test_serialize_without_artists(self): self.assertDictEqual( {'__model__': 'Track', 'uri': 'uri', 'name': 'name'}, Track(uri='uri', name='name').serialize()) def test_serialize_with_artists(self): artist = Artist(name='foo') self.assertDictEqual( {'__model__': 'Track', 'uri': 'uri', 'name': 'name', 'artists': [artist.serialize()]}, Track(uri='uri', name='name', artists=[artist]).serialize()) def test_serialize_with_album(self): album = Album(name='foo') self.assertDictEqual( {'__model__': 'Track', 'uri': 'uri', 'name': 'name', 'album': album.serialize()}, Track(uri='uri', name='name', album=album).serialize()) def test_to_json_and_back(self): track1 = Track( uri='uri', name='name', album=Album(name='foo'), artists=[Artist(name='foo')]) serialized = json.dumps(track1, cls=ModelJSONEncoder) track2 = json.loads(serialized, object_hook=model_json_decoder) self.assertEqual(track1, track2) def test_eq_uri(self): track1 = Track(uri='uri1') track2 = Track(uri='uri1') self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) def test_eq_name(self): track1 = Track(name='name1') track2 = Track(name='name1') self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) def test_eq_artists(self): artists = [Artist()] track1 = Track(artists=artists) track2 = Track(artists=artists) self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) def test_eq_artists_order(self): artist1 = Artist(name='name1') artist2 = Artist(name='name2') track1 = Track(artists=[artist1, artist2]) track2 = Track(artists=[artist2, artist1]) self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) def test_eq_album(self): album = Album() track1 = Track(album=album) track2 = Track(album=album) self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) def test_eq_track_no(self): track1 = Track(track_no=1) track2 = Track(track_no=1) self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) def test_eq_date(self): date = '1977-01-01' track1 = Track(date=date) track2 = Track(date=date) self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) def test_eq_length(self): track1 = Track(length=100) track2 = Track(length=100) self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) def test_eq_bitrate(self): track1 = Track(bitrate=100) track2 = Track(bitrate=100) self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) def test_eq_musibrainz_id(self): track1 = Track(musicbrainz_id='id') track2 = Track(musicbrainz_id='id') self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) def test_eq(self): date = '1977-01-01' artists = [Artist()] album = Album() track1 = Track( uri='uri', name='name', artists=artists, album=album, track_no=1, date=date, length=100, bitrate=100, musicbrainz_id='id') track2 = Track( uri='uri', name='name', artists=artists, album=album, track_no=1, date=date, length=100, bitrate=100, musicbrainz_id='id') self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) def test_eq_none(self): self.assertNotEqual(Track(), None) def test_eq_other(self): self.assertNotEqual(Track(), 'other') def test_ne_uri(self): track1 = Track(uri='uri1') track2 = Track(uri='uri2') self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) def test_ne_name(self): track1 = Track(name='name1') track2 = Track(name='name2') self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) def test_ne_artists(self): track1 = Track(artists=[Artist(name='name1')]) track2 = Track(artists=[Artist(name='name2')]) self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) def test_ne_album(self): track1 = Track(album=Album(name='name1')) track2 = Track(album=Album(name='name2')) self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) def test_ne_track_no(self): track1 = Track(track_no=1) track2 = Track(track_no=2) self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) def test_ne_date(self): track1 = Track(date='1977-01-01') track2 = Track(date='1977-01-02') self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) def test_ne_length(self): track1 = Track(length=100) track2 = Track(length=200) self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) def test_ne_bitrate(self): track1 = Track(bitrate=100) track2 = Track(bitrate=200) self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) def test_ne_musicbrainz_id(self): track1 = Track(musicbrainz_id='id1') track2 = Track(musicbrainz_id='id2') self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) def test_ne(self): track1 = Track( uri='uri1', name='name1', artists=[Artist(name='name1')], album=Album(name='name1'), track_no=1, date='1977-01-01', length=100, bitrate=100, musicbrainz_id='id1') track2 = Track( uri='uri2', name='name2', artists=[Artist(name='name2')], album=Album(name='name2'), track_no=2, date='1977-01-02', length=200, bitrate=200, musicbrainz_id='id2') self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) def test_ignores_values_with_default_value_none(self): track1 = Track(name='name1') track2 = Track(name='name1', album=None) self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) def test_replace_can_reset_to_default_value(self): track1 = Track(name='name1') track2 = Track(name='name1', album=Album()).replace(album=None) self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) class TlTrackTest(unittest.TestCase): def test_tlid(self): tlid = 123 tl_track = TlTrack(tlid=tlid) self.assertEqual(tl_track.tlid, tlid) with self.assertRaises(AttributeError): tl_track.tlid = None def test_track(self): track = Track() tl_track = TlTrack(track=track) self.assertEqual(tl_track.track, track) with self.assertRaises(AttributeError): tl_track.track = None def test_invalid_kwarg(self): with self.assertRaises(TypeError): TlTrack(foo='baz') def test_positional_args(self): tlid = 123 track = Track() tl_track = TlTrack(tlid, track) self.assertEqual(tl_track.tlid, tlid) self.assertEqual(tl_track.track, track) def test_iteration(self): tlid = 123 track = Track() tl_track = TlTrack(tlid, track) (tlid2, track2) = tl_track self.assertEqual(tlid2, tlid) self.assertEqual(track2, track) def test_repr(self): self.assertEqual( "TlTrack(tlid=123, track=Track(uri='uri'))", repr(TlTrack(tlid=123, track=Track(uri='uri')))) def test_serialize(self): track = Track(uri='uri', name='name') self.assertDictEqual( {'__model__': 'TlTrack', 'tlid': 123, 'track': track.serialize()}, TlTrack(tlid=123, track=track).serialize()) def test_to_json_and_back(self): tl_track1 = TlTrack(tlid=123, track=Track(uri='uri', name='name')) serialized = json.dumps(tl_track1, cls=ModelJSONEncoder) tl_track2 = json.loads(serialized, object_hook=model_json_decoder) self.assertEqual(tl_track1, tl_track2) def test_eq(self): tlid = 123 track = Track() tl_track1 = TlTrack(tlid=tlid, track=track) tl_track2 = TlTrack(tlid=tlid, track=track) self.assertEqual(tl_track1, tl_track2) self.assertEqual(hash(tl_track1), hash(tl_track2)) def test_eq_none(self): self.assertNotEqual(TlTrack(), None) def test_eq_other(self): self.assertNotEqual(TlTrack(), 'other') def test_ne_tlid(self): tl_track1 = TlTrack(tlid=123) tl_track2 = TlTrack(tlid=321) self.assertNotEqual(tl_track1, tl_track2) self.assertNotEqual(hash(tl_track1), hash(tl_track2)) def test_ne_track(self): tl_track1 = TlTrack(track=Track(uri='a')) tl_track2 = TlTrack(track=Track(uri='b')) self.assertNotEqual(tl_track1, tl_track2) self.assertNotEqual(hash(tl_track1), hash(tl_track2)) class PlaylistTest(unittest.TestCase): def test_uri(self): uri = 'an_uri' playlist = Playlist(uri=uri) self.assertEqual(playlist.uri, uri) with self.assertRaises(AttributeError): playlist.uri = None def test_name(self): name = 'a name' playlist = Playlist(name=name) self.assertEqual(playlist.name, name) with self.assertRaises(AttributeError): playlist.name = None def test_tracks(self): tracks = [Track(), Track(), Track()] playlist = Playlist(tracks=tracks) self.assertEqual(list(playlist.tracks), tracks) with self.assertRaises(AttributeError): playlist.tracks = None def test_length(self): tracks = [Track(), Track(), Track()] playlist = Playlist(tracks=tracks) self.assertEqual(playlist.length, 3) def test_last_modified(self): last_modified = 1390942873000 playlist = Playlist(last_modified=last_modified) self.assertEqual(playlist.last_modified, last_modified) with self.assertRaises(AttributeError): playlist.last_modified = None def test_with_new_uri(self): tracks = [Track()] last_modified = 1390942873000 playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) new_playlist = playlist.replace(uri='another uri') self.assertEqual(new_playlist.uri, 'another uri') self.assertEqual(new_playlist.name, 'a name') self.assertEqual(list(new_playlist.tracks), tracks) self.assertEqual(new_playlist.last_modified, last_modified) def test_with_new_name(self): tracks = [Track()] last_modified = 1390942873000 playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) new_playlist = playlist.replace(name='another name') self.assertEqual(new_playlist.uri, 'an uri') self.assertEqual(new_playlist.name, 'another name') self.assertEqual(list(new_playlist.tracks), tracks) self.assertEqual(new_playlist.last_modified, last_modified) def test_with_new_tracks(self): tracks = [Track()] last_modified = 1390942873000 playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) new_tracks = [Track(), Track()] new_playlist = playlist.replace(tracks=new_tracks) self.assertEqual(new_playlist.uri, 'an uri') self.assertEqual(new_playlist.name, 'a name') self.assertEqual(list(new_playlist.tracks), new_tracks) self.assertEqual(new_playlist.last_modified, last_modified) def test_with_new_last_modified(self): tracks = [Track()] last_modified = 1390942873000 new_last_modified = last_modified + 1000 playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) new_playlist = playlist.replace(last_modified=new_last_modified) self.assertEqual(new_playlist.uri, 'an uri') self.assertEqual(new_playlist.name, 'a name') self.assertEqual(list(new_playlist.tracks), tracks) self.assertEqual(new_playlist.last_modified, new_last_modified) def test_invalid_kwarg(self): with self.assertRaises(TypeError): Playlist(foo='baz') def test_repr_without_tracks(self): self.assertEqual( "Playlist(name=u'name', uri='uri')", repr(Playlist(uri='uri', name='name'))) def test_repr_with_tracks(self): self.assertEqual( "Playlist(name=u'name', tracks=[Track(name=u'foo')], uri='uri')", repr(Playlist(uri='uri', name='name', tracks=[Track(name='foo')]))) def test_serialize_without_tracks(self): self.assertDictEqual( {'__model__': 'Playlist', 'uri': 'uri', 'name': 'name'}, Playlist(uri='uri', name='name').serialize()) def test_serialize_with_tracks(self): track = Track(name='foo') self.assertDictEqual( {'__model__': 'Playlist', 'uri': 'uri', 'name': 'name', 'tracks': [track.serialize()]}, Playlist(uri='uri', name='name', tracks=[track]).serialize()) def test_to_json_and_back(self): playlist1 = Playlist(uri='uri', name='name') serialized = json.dumps(playlist1, cls=ModelJSONEncoder) playlist2 = json.loads(serialized, object_hook=model_json_decoder) self.assertEqual(playlist1, playlist2) def test_eq_name(self): playlist1 = Playlist(name='name') playlist2 = Playlist(name='name') self.assertEqual(playlist1, playlist2) self.assertEqual(hash(playlist1), hash(playlist2)) def test_eq_uri(self): playlist1 = Playlist(uri='uri') playlist2 = Playlist(uri='uri') self.assertEqual(playlist1, playlist2) self.assertEqual(hash(playlist1), hash(playlist2)) def test_eq_tracks(self): tracks = [Track()] playlist1 = Playlist(tracks=tracks) playlist2 = Playlist(tracks=tracks) self.assertEqual(playlist1, playlist2) self.assertEqual(hash(playlist1), hash(playlist2)) def test_eq_last_modified(self): playlist1 = Playlist(last_modified=1) playlist2 = Playlist(last_modified=1) self.assertEqual(playlist1, playlist2) self.assertEqual(hash(playlist1), hash(playlist2)) def test_eq(self): tracks = [Track()] playlist1 = Playlist( uri='uri', name='name', tracks=tracks, last_modified=1) playlist2 = Playlist( uri='uri', name='name', tracks=tracks, last_modified=1) self.assertEqual(playlist1, playlist2) self.assertEqual(hash(playlist1), hash(playlist2)) def test_eq_none(self): self.assertNotEqual(Playlist(), None) def test_eq_other(self): self.assertNotEqual(Playlist(), 'other') def test_ne_name(self): playlist1 = Playlist(name='name1') playlist2 = Playlist(name='name2') self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) def test_ne_uri(self): playlist1 = Playlist(uri='uri1') playlist2 = Playlist(uri='uri2') self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) def test_ne_tracks(self): playlist1 = Playlist(tracks=[Track(uri='uri1')]) playlist2 = Playlist(tracks=[Track(uri='uri2')]) self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) def test_ne_last_modified(self): playlist1 = Playlist(last_modified=1) playlist2 = Playlist(last_modified=2) self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) def test_ne(self): playlist1 = Playlist( uri='uri1', name='name1', tracks=[Track(uri='uri1')], last_modified=1) playlist2 = Playlist( uri='uri2', name='name2', tracks=[Track(uri='uri2')], last_modified=2) self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) class SearchResultTest(unittest.TestCase): def test_uri(self): uri = 'an_uri' result = SearchResult(uri=uri) self.assertEqual(result.uri, uri) with self.assertRaises(AttributeError): result.uri = None def test_tracks(self): tracks = [Track(), Track(), Track()] result = SearchResult(tracks=tracks) self.assertEqual(list(result.tracks), tracks) with self.assertRaises(AttributeError): result.tracks = None def test_artists(self): artists = [Artist(), Artist(), Artist()] result = SearchResult(artists=artists) self.assertEqual(list(result.artists), artists) with self.assertRaises(AttributeError): result.artists = None def test_albums(self): albums = [Album(), Album(), Album()] result = SearchResult(albums=albums) self.assertEqual(list(result.albums), albums) with self.assertRaises(AttributeError): result.albums = None def test_invalid_kwarg(self): with self.assertRaises(TypeError): SearchResult(foo='baz') def test_repr_without_results(self): self.assertEqual( "SearchResult(uri='uri')", repr(SearchResult(uri='uri'))) def test_serialize_without_results(self): self.assertDictEqual( {'__model__': 'SearchResult', 'uri': 'uri'}, SearchResult(uri='uri').serialize()) Mopidy-2.0.0/tests/stream/0000775000175000017500000000000012660436443015625 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/stream/__init__.py0000664000175000017500000000000012505224626017720 0ustar jodaljodal00000000000000Mopidy-2.0.0/tests/stream/test_library.py0000664000175000017500000000316412660436420020701 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import mock import pytest from mopidy.internal import path from mopidy.models import Track from mopidy.stream import actor from tests import path_to_data_dir @pytest.fixture def config(): return { 'proxy': {}, 'stream': { 'timeout': 1000, 'metadata_blacklist': [], 'protocols': ['file'], }, 'file': { 'enabled': False }, } @pytest.fixture def audio(): return mock.Mock() @pytest.fixture def track_uri(): return path.path_to_uri(path_to_data_dir('song1.wav')) def test_lookup_ignores_unknown_scheme(audio, config): backend = actor.StreamBackend(audio=audio, config=config) assert backend.library.lookup('http://example.com') == [] def test_lookup_respects_blacklist(audio, config, track_uri): config['stream']['metadata_blacklist'].append(track_uri) backend = actor.StreamBackend(audio=audio, config=config) assert backend.library.lookup(track_uri) == [Track(uri=track_uri)] def test_lookup_respects_blacklist_globbing(audio, config, track_uri): blacklist_glob = path.path_to_uri(path_to_data_dir('')) + '*' config['stream']['metadata_blacklist'].append(blacklist_glob) backend = actor.StreamBackend(audio=audio, config=config) assert backend.library.lookup(track_uri) == [Track(uri=track_uri)] def test_lookup_converts_uri_metadata_to_track(audio, config, track_uri): backend = actor.StreamBackend(audio=audio, config=config) result = backend.library.lookup(track_uri) assert result == [Track(length=4406, uri=track_uri)] Mopidy-2.0.0/tests/stream/test_playback.py0000664000175000017500000001541612660436420021026 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import mock import pytest import requests.exceptions import responses from mopidy import exceptions from mopidy.audio import scan from mopidy.stream import actor TIMEOUT = 1000 PLAYLIST_URI = 'http://example.com/listen.m3u' STREAM_URI = 'http://example.com/stream.mp3' BODY = """ #EXTM3U http://example.com/stream.mp3 http://foo.bar/baz """.strip() @pytest.fixture def config(): return { 'proxy': {}, 'stream': { 'timeout': TIMEOUT, 'metadata_blacklist': [], 'protocols': ['http'], }, 'file': { 'enabled': False }, } @pytest.fixture def audio(): return mock.Mock() @pytest.yield_fixture def scanner(): patcher = mock.patch.object(scan, 'Scanner') yield patcher.start()() patcher.stop() @pytest.fixture def backend(audio, config, scanner): return actor.StreamBackend(audio=audio, config=config) @pytest.fixture def provider(backend): return backend.playback class TestTranslateURI(object): @responses.activate def test_audio_stream_returns_same_uri(self, scanner, provider): scanner.scan.side_effect = [ # Set playable to False to test detection by mimetype mock.Mock(mime='audio/mpeg', playable=False), ] result = provider.translate_uri(STREAM_URI) scanner.scan.assert_called_once_with(STREAM_URI, timeout=mock.ANY) assert result == STREAM_URI @responses.activate def test_playable_ogg_stream_is_not_considered_a_playlist( self, scanner, provider): scanner.scan.side_effect = [ # Set playable to True to ignore detection as possible playlist mock.Mock(mime='application/ogg', playable=True), ] result = provider.translate_uri(STREAM_URI) scanner.scan.assert_called_once_with(STREAM_URI, timeout=mock.ANY) assert result == STREAM_URI @responses.activate def test_text_playlist_with_mpeg_stream( self, scanner, provider, caplog): scanner.scan.side_effect = [ # Scanning playlist mock.Mock(mime='text/foo', playable=False), # Scanning stream mock.Mock(mime='audio/mpeg', playable=True), ] responses.add( responses.GET, PLAYLIST_URI, body=BODY, content_type='audio/x-mpegurl') result = provider.translate_uri(PLAYLIST_URI) assert scanner.scan.mock_calls == [ mock.call(PLAYLIST_URI, timeout=mock.ANY), mock.call(STREAM_URI, timeout=mock.ANY), ] assert result == STREAM_URI # Check logging to ensure debuggability assert 'Unwrapping stream from URI: %s' % PLAYLIST_URI assert 'Parsed playlist (%s)' % PLAYLIST_URI in caplog.text() assert 'Unwrapping stream from URI: %s' % STREAM_URI assert ( 'Unwrapped potential audio/mpeg stream: %s' % STREAM_URI in caplog.text()) # Check proper Requests session setup assert responses.calls[0].request.headers['User-Agent'].startswith( 'Mopidy-Stream/') @responses.activate def test_xml_playlist_with_mpeg_stream(self, scanner, provider): scanner.scan.side_effect = [ # Scanning playlist mock.Mock(mime='application/xspf+xml', playable=False), # Scanning stream mock.Mock(mime='audio/mpeg', playable=True), ] responses.add( responses.GET, PLAYLIST_URI, body=BODY, content_type='application/xspf+xml') result = provider.translate_uri(PLAYLIST_URI) assert scanner.scan.mock_calls == [ mock.call(PLAYLIST_URI, timeout=mock.ANY), mock.call(STREAM_URI, timeout=mock.ANY), ] assert result == STREAM_URI @responses.activate def test_scan_fails_but_playlist_parsing_succeeds( self, scanner, provider, caplog): scanner.scan.side_effect = [ # Scanning playlist exceptions.ScannerError('some failure'), # Scanning stream mock.Mock(mime='audio/mpeg', playable=True), ] responses.add( responses.GET, PLAYLIST_URI, body=BODY, content_type='audio/x-mpegurl') result = provider.translate_uri(PLAYLIST_URI) assert 'Unwrapping stream from URI: %s' % PLAYLIST_URI assert ( 'GStreamer failed scanning URI (%s)' % PLAYLIST_URI in caplog.text()) assert 'Parsed playlist (%s)' % PLAYLIST_URI in caplog.text() assert ( 'Unwrapped potential audio/mpeg stream: %s' % STREAM_URI in caplog.text()) assert result == STREAM_URI @responses.activate def test_scan_fails_and_playlist_parsing_fails( self, scanner, provider, caplog): scanner.scan.side_effect = exceptions.ScannerError('some failure') responses.add( responses.GET, STREAM_URI, body=b'some audio data', content_type='audio/mpeg') result = provider.translate_uri(STREAM_URI) assert 'Unwrapping stream from URI: %s' % STREAM_URI assert ( 'GStreamer failed scanning URI (%s)' % STREAM_URI in caplog.text()) assert ( 'Failed parsing URI (%s) as playlist; found potential stream.' % STREAM_URI in caplog.text()) assert result == STREAM_URI @responses.activate def test_failed_download_returns_none(self, scanner, provider, caplog): scanner.scan.side_effect = [ mock.Mock(mime='text/foo', playable=False) ] responses.add( responses.GET, PLAYLIST_URI, body=requests.exceptions.HTTPError('Kaboom')) result = provider.translate_uri(PLAYLIST_URI) assert result is None assert ( 'Unwrapping stream from URI (%s) failed: ' 'error downloading URI' % PLAYLIST_URI) in caplog.text() @responses.activate def test_playlist_references_itself(self, scanner, provider, caplog): scanner.scan.side_effect = [ mock.Mock(mime='text/foo', playable=False) ] responses.add( responses.GET, PLAYLIST_URI, body=BODY.replace(STREAM_URI, PLAYLIST_URI), content_type='audio/x-mpegurl') result = provider.translate_uri(PLAYLIST_URI) assert 'Unwrapping stream from URI: %s' % PLAYLIST_URI in caplog.text() assert ( 'Parsed playlist (%s) and found new URI: %s' % (PLAYLIST_URI, PLAYLIST_URI)) in caplog.text() assert ( 'Unwrapping stream from URI (%s) failed: ' 'playlist referenced itself' % PLAYLIST_URI) in caplog.text() assert result is None Mopidy-2.0.0/tests/__init__.py0000664000175000017500000000147612660436420016446 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import os from mopidy import compat def path_to_data_dir(name): if not isinstance(name, bytes): name = name.encode('utf-8') path = os.path.dirname(__file__) path = os.path.join(path, b'data') path = os.path.abspath(path) return os.path.join(path, name) class IsA(object): def __init__(self, klass): self.klass = klass def __eq__(self, rhs): try: return isinstance(rhs, self.klass) except TypeError: return type(rhs) == type(self.klass) # flake8: noqa def __ne__(self, rhs): return not self.__eq__(rhs) def __repr__(self): return str(self.klass) any_int = IsA(compat.integer_types) any_str = IsA(compat.string_types) any_unicode = IsA(compat.text_type) Mopidy-2.0.0/tests/local/0000775000175000017500000000000012660436443015424 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/local/test_tracklist.py0000664000175000017500000003203212660436420021030 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import random import unittest import pykka from mopidy import core from mopidy.core import PlaybackState from mopidy.internal import deprecation from mopidy.local import actor from mopidy.models import Playlist, Track from tests import dummy_audio, path_to_data_dir from tests.local import generate_song, populate_tracklist class LocalTracklistProviderTest(unittest.TestCase): config = { 'core': { 'data_dir': path_to_data_dir(''), 'max_tracklist_length': 10000 }, 'local': { 'media_dir': path_to_data_dir(''), 'playlists_dir': b'', 'library': 'json', } } tracks = [ Track(uri=generate_song(i), length=4464) for i in range(1, 4)] def run(self, result=None): with deprecation.ignore('core.tracklist.add:tracks_arg'): return super(LocalTracklistProviderTest, self).run(result) def setUp(self): # noqa: N802 self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() self.core = core.Core.start(audio=self.audio, backends=[self.backend], config=self.config).proxy() self.controller = self.core.tracklist self.playback = self.core.playback assert len(self.tracks) == 3, 'Need three tracks to run tests.' def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def assert_state_is(self, state): self.assertEqual(self.playback.get_state().get(), state) def assert_current_track_is(self, track): self.assertEqual(self.playback.get_current_track().get(), track) def test_length(self): self.assertEqual(0, len(self.controller.get_tl_tracks().get())) self.assertEqual(0, self.controller.get_length().get()) self.controller.add(self.tracks) self.assertEqual(3, len(self.controller.get_tl_tracks().get())) self.assertEqual(3, self.controller.get_length().get()) def test_add(self): for track in self.tracks: added = self.controller.add([track]).get() tracks = self.controller.get_tracks().get() tl_tracks = self.controller.get_tl_tracks().get() self.assertEqual(track, tracks[-1]) self.assertEqual(added[0], tl_tracks[-1]) self.assertEqual(track, added[0].track) def test_add_at_position(self): for track in self.tracks[:-1]: added = self.controller.add([track], 0).get() tracks = self.controller.get_tracks().get() tl_tracks = self.controller.get_tl_tracks().get() self.assertEqual(track, tracks[0]) self.assertEqual(added[0], tl_tracks[0]) self.assertEqual(track, added[0].track) @populate_tracklist def test_add_at_position_outside_of_playlist(self): for track in self.tracks: added = self.controller.add([track], len(self.tracks) + 2).get() tracks = self.controller.get_tracks().get() tl_tracks = self.controller.get_tl_tracks().get() self.assertEqual(track, tracks[-1]) self.assertEqual(added[0], tl_tracks[-1]) self.assertEqual(track, added[0].track) @populate_tracklist def test_filter_by_tlid(self): tl_track = self.controller.get_tl_tracks().get()[1] result = self.controller.filter({'tlid': [tl_track.tlid]}).get() self.assertEqual([tl_track], result) @populate_tracklist def test_filter_by_uri(self): tl_track = self.controller.get_tl_tracks().get()[1] result = self.controller.filter({'uri': [tl_track.track.uri]}).get() self.assertEqual([tl_track], result) @populate_tracklist def test_filter_by_uri_returns_nothing_for_invalid_uri(self): self.assertEqual([], self.controller.filter({'uri': ['foobar']}).get()) def test_filter_by_uri_returns_single_match(self): t = Track(uri='a') self.controller.add([Track(uri='z'), t, Track(uri='y')]) result = self.controller.filter({'uri': ['a']}).get() self.assertEqual(t, result[0].track) def test_filter_by_uri_returns_multiple_matches(self): track = Track(uri='a') self.controller.add([Track(uri='z'), track, track]) tl_tracks = self.controller.filter({'uri': ['a']}).get() self.assertEqual(track, tl_tracks[0].track) self.assertEqual(track, tl_tracks[1].track) def test_filter_by_uri_returns_nothing_if_no_match(self): self.controller.playlist = Playlist( tracks=[Track(uri='z'), Track(uri='y')]) self.assertEqual([], self.controller.filter({'uri': ['a']}).get()) def test_filter_by_multiple_criteria_returns_elements_matching_all(self): t1 = Track(uri='a', name='x') t2 = Track(uri='b', name='x') t3 = Track(uri='b', name='y') self.controller.add([t1, t2, t3]) result1 = self.controller.filter({'uri': ['a'], 'name': ['x']}).get() self.assertEqual(t1, result1[0].track) result2 = self.controller.filter({'uri': ['b'], 'name': ['x']}).get() self.assertEqual(t2, result2[0].track) result3 = self.controller.filter({'uri': ['b'], 'name': ['y']}).get() self.assertEqual(t3, result3[0].track) def test_filter_by_criteria_that_is_not_present_in_all_elements(self): track1 = Track() track2 = Track(uri='b') track3 = Track() self.controller.add([track1, track2, track3]) result = self.controller.filter({'uri': ['b']}).get() self.assertEqual(track2, result[0].track) @populate_tracklist def test_clear(self): self.controller.clear().get() self.assertEqual(len(self.controller.get_tracks().get()), 0) def test_clear_empty_playlist(self): self.controller.clear().get() self.assertEqual(len(self.controller.get_tracks().get()), 0) @populate_tracklist def test_clear_when_playing(self): self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) self.controller.clear().get() self.assert_state_is(PlaybackState.STOPPED) def test_add_appends_to_the_tracklist(self): self.controller.add([Track(uri='a'), Track(uri='b')]) tracks = self.controller.get_tracks().get() self.assertEqual(len(tracks), 2) self.controller.add([Track(uri='c'), Track(uri='d')]) tracks = self.controller.get_tracks().get() self.assertEqual(len(tracks), 4) self.assertEqual(tracks[0].uri, 'a') self.assertEqual(tracks[1].uri, 'b') self.assertEqual(tracks[2].uri, 'c') self.assertEqual(tracks[3].uri, 'd') def test_add_does_not_reset_version(self): version = self.controller.get_version().get() self.controller.add([]) self.assertEqual(self.controller.get_version().get(), version) @populate_tracklist def test_add_preserves_playing_state(self): self.playback.play().get() track = self.playback.get_current_track().get() tracks = self.controller.get_tracks().get() self.controller.add(tracks[1:2]).get() self.assert_state_is(PlaybackState.PLAYING) self.assert_current_track_is(track) @populate_tracklist def test_add_preserves_stopped_state(self): tracks = self.controller.get_tracks().get() self.controller.add(tracks[1:2]).get() self.assert_state_is(PlaybackState.STOPPED) self.assert_current_track_is(None) @populate_tracklist def test_add_returns_the_tl_tracks_that_was_added(self): tracks = self.controller.get_tracks().get() added = self.controller.add(tracks[1:2]).get() tracks = self.controller.get_tracks().get() self.assertEqual(added[0].track, tracks[1]) @populate_tracklist def test_move_single(self): self.controller.move(0, 0, 2) tracks = self.controller.get_tracks().get() self.assertEqual(tracks[2], self.tracks[0]) @populate_tracklist def test_move_group(self): self.controller.move(0, 2, 1) tracks = self.controller.get_tracks().get() self.assertEqual(tracks[1], self.tracks[0]) self.assertEqual(tracks[2], self.tracks[1]) @populate_tracklist def test_moving_track_outside_of_playlist(self): num_tracks = len(self.controller.get_tracks().get()) with self.assertRaises(AssertionError): self.controller.move(0, 0, num_tracks + 5).get() @populate_tracklist def test_move_group_outside_of_playlist(self): num_tracks = len(self.controller.get_tracks().get()) with self.assertRaises(AssertionError): self.controller.move(0, 2, num_tracks + 5).get() @populate_tracklist def test_move_group_out_of_range(self): num_tracks = len(self.controller.get_tracks().get()) with self.assertRaises(AssertionError): self.controller.move(num_tracks + 2, num_tracks + 3, 0).get() @populate_tracklist def test_move_group_invalid_group(self): with self.assertRaises(AssertionError): self.controller.move(2, 1, 0).get() def test_tracks_attribute_is_immutable(self): tracks1 = self.controller.tracks.get() tracks2 = self.controller.tracks.get() self.assertNotEqual(id(tracks1), id(tracks2)) @populate_tracklist def test_remove(self): track1 = self.controller.get_tracks().get()[1] track2 = self.controller.get_tracks().get()[2] version = self.controller.get_version().get() self.controller.remove({'uri': [track1.uri]}) self.assertLess(version, self.controller.get_version().get()) self.assertNotIn(track1, self.controller.get_tracks().get()) self.assertEqual(track2, self.controller.get_tracks().get()[1]) @populate_tracklist def test_removing_track_that_does_not_exist_does_nothing(self): self.controller.remove({'uri': ['/nonexistant']}).get() def test_removing_from_empty_playlist_does_nothing(self): self.controller.remove({'uri': ['/nonexistant']}).get() @populate_tracklist def test_remove_lists(self): version = self.controller.get_version().get() tracks = self.controller.get_tracks().get() track0 = tracks[0] track1 = tracks[1] track2 = tracks[2] self.controller.remove({'uri': [track0.uri, track2.uri]}) tracks = self.controller.get_tracks().get() self.assertLess(version, self.controller.get_version().get()) self.assertNotIn(track0, tracks) self.assertNotIn(track2, tracks) self.assertEqual(track1, tracks[0]) @populate_tracklist def test_shuffle(self): random.seed(1) self.controller.shuffle() shuffled_tracks = self.controller.get_tracks().get() self.assertNotEqual(self.tracks, shuffled_tracks) self.assertEqual(set(self.tracks), set(shuffled_tracks)) @populate_tracklist def test_shuffle_subset(self): random.seed(1) self.controller.shuffle(1, 3) shuffled_tracks = self.controller.get_tracks().get() self.assertNotEqual(self.tracks, shuffled_tracks) self.assertEqual(self.tracks[0], shuffled_tracks[0]) self.assertEqual(set(self.tracks), set(shuffled_tracks)) @populate_tracklist def test_shuffle_invalid_subset(self): with self.assertRaises(AssertionError): self.controller.shuffle(3, 1).get() @populate_tracklist def test_shuffle_superset(self): num_tracks = len(self.controller.get_tracks().get()) with self.assertRaises(AssertionError): self.controller.shuffle(1, num_tracks + 5).get() @populate_tracklist def test_shuffle_open_subset(self): random.seed(1) self.controller.shuffle(1) shuffled_tracks = self.controller.get_tracks().get() self.assertNotEqual(self.tracks, shuffled_tracks) self.assertEqual(self.tracks[0], shuffled_tracks[0]) self.assertEqual(set(self.tracks), set(shuffled_tracks)) @populate_tracklist def test_slice_returns_a_subset_of_tracks(self): track_slice = self.controller.slice(1, 3).get() self.assertEqual(2, len(track_slice)) self.assertEqual(self.tracks[1], track_slice[0].track) self.assertEqual(self.tracks[2], track_slice[1].track) @populate_tracklist def test_slice_returns_empty_list_if_indexes_outside_tracks_list(self): self.assertEqual(0, len(self.controller.slice(7, 8).get())) self.assertEqual(0, len(self.controller.slice(-1, 1).get())) def test_version_does_not_change_when_adding_nothing(self): version = self.controller.get_version().get() self.controller.add([]) self.assertEqual(version, self.controller.get_version().get()) def test_version_increases_when_adding_something(self): version = self.controller.get_version().get() self.controller.add([Track()]) self.assertLess(version, self.controller.get_version().get()) Mopidy-2.0.0/tests/local/test_translator.py0000664000175000017500000000653012660436420021225 0ustar jodaljodal00000000000000# encoding: utf-8 from __future__ import unicode_literals import pytest from mopidy import compat from mopidy.local import translator @pytest.mark.parametrize('local_uri,file_uri', [ ('local:directory:A/B', 'file:///home/alice/Music/A/B'), ('local:directory:A%20B', 'file:///home/alice/Music/A%20B'), ('local:directory:A+B', 'file:///home/alice/Music/A%2BB'), ( 'local:directory:%C3%A6%C3%B8%C3%A5', 'file:///home/alice/Music/%C3%A6%C3%B8%C3%A5'), ('local:track:A/B.mp3', 'file:///home/alice/Music/A/B.mp3'), ('local:track:A%20B.mp3', 'file:///home/alice/Music/A%20B.mp3'), ('local:track:A+B.mp3', 'file:///home/alice/Music/A%2BB.mp3'), ( 'local:track:%C3%A6%C3%B8%C3%A5.mp3', 'file:///home/alice/Music/%C3%A6%C3%B8%C3%A5.mp3'), ]) def test_local_uri_to_file_uri(local_uri, file_uri): media_dir = b'/home/alice/Music' assert translator.local_uri_to_file_uri(local_uri, media_dir) == file_uri @pytest.mark.parametrize('uri', [ 'A/B', 'local:foo:A/B', ]) def test_local_uri_to_file_uri_errors(uri): media_dir = b'/home/alice/Music' with pytest.raises(ValueError): translator.local_uri_to_file_uri(uri, media_dir) @pytest.mark.parametrize('uri,path', [ ('local:directory:A/B', b'/home/alice/Music/A/B'), ('local:directory:A%20B', b'/home/alice/Music/A B'), ('local:directory:A+B', b'/home/alice/Music/A+B'), ( 'local:directory:%C3%A6%C3%B8%C3%A5', b'/home/alice/Music/\xc3\xa6\xc3\xb8\xc3\xa5'), ('local:track:A/B.mp3', b'/home/alice/Music/A/B.mp3'), ('local:track:A%20B.mp3', b'/home/alice/Music/A B.mp3'), ('local:track:A+B.mp3', b'/home/alice/Music/A+B.mp3'), ( 'local:track:%C3%A6%C3%B8%C3%A5.mp3', b'/home/alice/Music/\xc3\xa6\xc3\xb8\xc3\xa5.mp3'), ]) def test_local_uri_to_path(uri, path): media_dir = b'/home/alice/Music' assert translator.local_uri_to_path(uri, media_dir) == path # Legacy version to keep old versions of Mopidy-Local-Sqlite working assert translator.local_track_uri_to_path(uri, media_dir) == path @pytest.mark.parametrize('uri', [ 'A/B', 'local:foo:A/B', ]) def test_local_uri_to_path_errors(uri): media_dir = b'/home/alice/Music' with pytest.raises(ValueError): translator.local_uri_to_path(uri, media_dir) @pytest.mark.parametrize('path,uri', [ ('/foo', 'file:///foo'), (b'/foo', 'file:///foo'), ('/æøå', 'file:///%C3%A6%C3%B8%C3%A5'), (b'/\x00\x01\x02', 'file:///%00%01%02'), ]) def test_path_to_file_uri(path, uri): assert translator.path_to_file_uri(path) == uri @pytest.mark.parametrize('path,uri', [ ('foo', 'local:track:foo'), (b'foo', 'local:track:foo'), ('æøå', 'local:track:%C3%A6%C3%B8%C3%A5'), (b'\x00\x01\x02', 'local:track:%00%01%02'), ]) def test_path_to_local_track_uri(path, uri): result = translator.path_to_local_track_uri(path) assert isinstance(result, compat.text_type) assert result == uri @pytest.mark.parametrize('path,uri', [ ('foo', 'local:directory:foo'), (b'foo', 'local:directory:foo'), ('æøå', 'local:directory:%C3%A6%C3%B8%C3%A5'), (b'\x00\x01\x02', 'local:directory:%00%01%02'), ]) def test_path_to_local_directory_uri(path, uri): result = translator.path_to_local_directory_uri(path) assert isinstance(result, compat.text_type) assert result == uri Mopidy-2.0.0/tests/local/__init__.py0000664000175000017500000000072312575004517017535 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mopidy.internal import deprecation def generate_song(i): return 'local:track:song%s.wav' % i def populate_tracklist(func): def wrapper(self): with deprecation.ignore('core.tracklist.add:tracks_arg'): self.tl_tracks = self.core.tracklist.add(self.tracks) return func(self) wrapper.__name__ = func.__name__ wrapper.__doc__ = func.__doc__ return wrapper Mopidy-2.0.0/tests/local/test_json.py0000664000175000017500000000600512575504731020010 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest from mopidy.local import json from mopidy.models import Ref, Track from tests import path_to_data_dir class BrowseCacheTest(unittest.TestCase): maxDiff = None def setUp(self): # noqa: N802 self.uris = ['local:track:foo/bar/song1', 'local:track:foo/bar/song2', 'local:track:foo/baz/song3', 'local:track:foo/song4', 'local:track:song5'] self.cache = json._BrowseCache(self.uris) def test_lookup_root(self): expected = [Ref.directory(uri='local:directory:foo', name='foo'), Ref.track(uri='local:track:song5', name='song5')] self.assertEqual(expected, self.cache.lookup('local:directory')) def test_lookup_foo(self): expected = [Ref.directory(uri='local:directory:foo/bar', name='bar'), Ref.directory(uri='local:directory:foo/baz', name='baz'), Ref.track(uri=self.uris[3], name='song4')] result = self.cache.lookup('local:directory:foo') self.assertEqual(expected, result) def test_lookup_foo_bar(self): expected = [Ref.track(uri=self.uris[0], name='song1'), Ref.track(uri=self.uris[1], name='song2')] self.assertEqual( expected, self.cache.lookup('local:directory:foo/bar')) def test_lookup_foo_baz(self): result = self.cache.lookup('local:directory:foo/unknown') self.assertEqual([], result) class JsonLibraryTest(unittest.TestCase): config = { 'core': { 'data_dir': path_to_data_dir(''), }, 'local': { 'media_dir': path_to_data_dir(''), 'library': 'json', }, } def setUp(self): # noqa: N802 self.library = json.JsonLibrary(self.config) def _create_tracks(self, count): for i in range(count): self.library.add(Track(uri='local:track:%d' % i)) def test_search_should_default_limit_results(self): self._create_tracks(101) result = self.library.search() result_exact = self.library.search(exact=True) self.assertEqual(len(result.tracks), 100) self.assertEqual(len(result_exact.tracks), 100) def test_search_should_limit_results(self): self._create_tracks(100) result = self.library.search(limit=35) result_exact = self.library.search(exact=True, limit=35) self.assertEqual(len(result.tracks), 35) self.assertEqual(len(result_exact.tracks), 35) def test_search_should_offset_results(self): self._create_tracks(200) expected = self.library.search(limit=110).tracks[10:] expected_exact = self.library.search(exact=True, limit=110).tracks[10:] result = self.library.search(offset=10).tracks result_exact = self.library.search(offset=10, exact=True).tracks self.assertEqual(expected, result) self.assertEqual(expected_exact, result_exact) Mopidy-2.0.0/tests/local/test_library.py0000664000175000017500000005523512575504731020514 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import os import shutil import tempfile import unittest import mock import pykka from mopidy import core, exceptions from mopidy.local import actor, json from mopidy.models import Album, Artist, Image, Track from tests import path_to_data_dir # TODO: update tests to only use backend, not core. we need a seperate # core test that does this integration test. class LocalLibraryProviderTest(unittest.TestCase): artists = [ Artist(name='artist1'), Artist(name='artist2'), Artist(name='artist3'), Artist(name='artist4'), Artist(name='artist5'), Artist(name='artist6'), Artist(), ] albums = [ Album(name='album1', artists=[artists[0]]), Album(name='album2', artists=[artists[1]]), Album(name='album3', artists=[artists[2]]), Album(name='album4'), Album(artists=[artists[-1]]), ] tracks = [ Track( uri='local:track:path1', name='track1', artists=[artists[0]], album=albums[0], date='2001-02-03', length=4000, track_no=1), Track( uri='local:track:path2', name='track2', artists=[artists[1]], album=albums[1], date='2002', length=4000, track_no=2), Track( uri='local:track:path3', name='track3', artists=[artists[3]], album=albums[2], date='2003', length=4000, track_no=3), Track( uri='local:track:path4', name='track4', artists=[artists[2]], album=albums[3], date='2004', length=60000, track_no=4, comment='This is a fantastic track'), Track( uri='local:track:path5', name='track5', genre='genre1', album=albums[3], length=4000, composers=[artists[4]]), Track( uri='local:track:path6', name='track6', genre='genre2', album=albums[3], length=4000, performers=[artists[5]]), Track(uri='local:track:nameless', album=albums[-1]), ] config = { 'core': { 'data_dir': path_to_data_dir(''), }, 'local': { 'media_dir': path_to_data_dir(''), 'library': 'json', }, } def setUp(self): # noqa: N802 actor.LocalBackend.libraries = [json.JsonLibrary] self.backend = actor.LocalBackend.start( config=self.config, audio=None).proxy() self.core = core.Core(backends=[self.backend]) self.library = self.core.library def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() actor.LocalBackend.libraries = [] def find_exact(self, **query): # TODO: remove this helper? return self.library.search(query=query, exact=True) def search(self, **query): # TODO: remove this helper? return self.library.search(query=query) def test_refresh(self): self.library.refresh() @unittest.SkipTest def test_refresh_uri(self): pass def test_refresh_missing_uri(self): # Verifies that https://github.com/mopidy/mopidy/issues/500 # has been fixed. tmpdir = tempfile.mkdtemp() try: tmpdir_local = os.path.join(tmpdir, 'local') shutil.copytree(path_to_data_dir('local'), tmpdir_local) config = { 'core': { 'data_dir': tmpdir, }, 'local': self.config['local'], } backend = actor.LocalBackend(config=config, audio=None) # Sanity check that value is in the library result = backend.library.lookup(self.tracks[0].uri) self.assertEqual(result, self.tracks[0:1]) # Clear and refresh. tmplib = os.path.join(tmpdir_local, 'library.json.gz') open(tmplib, 'w').close() backend.library.refresh() # Now it should be gone. result = backend.library.lookup(self.tracks[0].uri) self.assertEqual(result, []) finally: shutil.rmtree(tmpdir) @unittest.SkipTest def test_browse(self): pass # TODO def test_lookup(self): uri = self.tracks[0].uri result = self.library.lookup(uris=[uri]) self.assertEqual(result[uri], self.tracks[0:1]) def test_lookup_unknown_track(self): tracks = self.library.lookup(uris=['fake:/uri']) self.assertEqual(tracks, {'fake:/uri': []}) # test backward compatibility with local libraries returning a # single Track @mock.patch.object(json.JsonLibrary, 'lookup') def test_lookup_return_single_track(self, mock_lookup): backend = actor.LocalBackend(config=self.config, audio=None) mock_lookup.return_value = self.tracks[0] tracks = backend.library.lookup(self.tracks[0].uri) mock_lookup.assert_called_with(self.tracks[0].uri) self.assertEqual(tracks, self.tracks[0:1]) mock_lookup.return_value = None tracks = backend.library.lookup('fake uri') mock_lookup.assert_called_with('fake uri') self.assertEqual(tracks, []) # TODO: move to search_test module def test_find_exact_no_hits(self): result = self.find_exact(track_name=['unknown track']) self.assertEqual(list(result[0].tracks), []) result = self.find_exact(artist=['unknown artist']) self.assertEqual(list(result[0].tracks), []) result = self.find_exact(albumartist=['unknown albumartist']) self.assertEqual(list(result[0].tracks), []) result = self.find_exact(composer=['unknown composer']) self.assertEqual(list(result[0].tracks), []) result = self.find_exact(performer=['unknown performer']) self.assertEqual(list(result[0].tracks), []) result = self.find_exact(album=['unknown album']) self.assertEqual(list(result[0].tracks), []) result = self.find_exact(date=['1990']) self.assertEqual(list(result[0].tracks), []) result = self.find_exact(genre=['unknown genre']) self.assertEqual(list(result[0].tracks), []) result = self.find_exact(track_no=['9']) self.assertEqual(list(result[0].tracks), []) result = self.find_exact(track_no=['no_match']) self.assertEqual(list(result[0].tracks), []) result = self.find_exact(comment=['fake comment']) self.assertEqual(list(result[0].tracks), []) result = self.find_exact(uri=['fake uri']) self.assertEqual(list(result[0].tracks), []) result = self.find_exact(any=['unknown any']) self.assertEqual(list(result[0].tracks), []) def test_find_exact_uri(self): track_1_uri = 'local:track:path1' result = self.find_exact(uri=track_1_uri) self.assertEqual(list(result[0].tracks), self.tracks[:1]) track_2_uri = 'local:track:path2' result = self.find_exact(uri=track_2_uri) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_track_name(self): result = self.find_exact(track_name=['track1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.find_exact(track_name=['track2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_artist(self): result = self.find_exact(artist=['artist1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.find_exact(artist=['artist2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) result = self.find_exact(artist=['artist3']) self.assertEqual(list(result[0].tracks), self.tracks[3:4]) def test_find_exact_composer(self): result = self.find_exact(composer=['artist5']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) result = self.find_exact(composer=['artist6']) self.assertEqual(list(result[0].tracks), []) def test_find_exact_performer(self): result = self.find_exact(performer=['artist6']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) result = self.find_exact(performer=['artist5']) self.assertEqual(list(result[0].tracks), []) def test_find_exact_album(self): result = self.find_exact(album=['album1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.find_exact(album=['album2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_albumartist(self): # Artist is both track artist and album artist result = self.find_exact(albumartist=['artist1']) self.assertEqual(list(result[0].tracks), [self.tracks[0]]) # Artist is both track and album artist result = self.find_exact(albumartist=['artist2']) self.assertEqual(list(result[0].tracks), [self.tracks[1]]) # Artist is just album artist result = self.find_exact(albumartist=['artist3']) self.assertEqual(list(result[0].tracks), [self.tracks[2]]) def test_find_exact_track_no(self): result = self.find_exact(track_no=['1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.find_exact(track_no=['2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_genre(self): result = self.find_exact(genre=['genre1']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) result = self.find_exact(genre=['genre2']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) def test_find_exact_date(self): result = self.find_exact(date=['2001']) self.assertEqual(list(result[0].tracks), []) result = self.find_exact(date=['2001-02-03']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.find_exact(date=['2002']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_comment(self): result = self.find_exact( comment=['This is a fantastic track']) self.assertEqual(list(result[0].tracks), self.tracks[3:4]) result = self.find_exact( comment=['This is a fantastic']) self.assertEqual(list(result[0].tracks), []) def test_find_exact_any(self): # Matches on track artist result = self.find_exact(any=['artist1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.find_exact(any=['artist2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) # Matches on track name result = self.find_exact(any=['track1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.find_exact(any=['track2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) # Matches on track album result = self.find_exact(any=['album1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) # Matches on track album artists result = self.find_exact(any=['artist3']) self.assertEqual(len(result[0].tracks), 2) self.assertIn(self.tracks[2], result[0].tracks) self.assertIn(self.tracks[3], result[0].tracks) # Matches on track composer result = self.find_exact(any=['artist5']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) # Matches on track performer result = self.find_exact(any=['artist6']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) # Matches on track genre result = self.find_exact(any=['genre1']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) result = self.find_exact(any=['genre2']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) # Matches on track date result = self.find_exact(any=['2002']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) # Matches on track comment result = self.find_exact( any=['This is a fantastic track']) self.assertEqual(list(result[0].tracks), self.tracks[3:4]) # Matches on URI result = self.find_exact(any=['local:track:path1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) # TODO: This is really just a test of the query validation code now, # as this code path never even makes it to the local backend. def test_find_exact_wrong_type(self): with self.assertRaises(exceptions.ValidationError): self.find_exact(wrong=['test']) def test_find_exact_with_empty_query(self): with self.assertRaises(exceptions.ValidationError): self.find_exact(artist=['']) with self.assertRaises(exceptions.ValidationError): self.find_exact(albumartist=['']) with self.assertRaises(exceptions.ValidationError): self.find_exact(track_name=['']) with self.assertRaises(exceptions.ValidationError): self.find_exact(composer=['']) with self.assertRaises(exceptions.ValidationError): self.find_exact(performer=['']) with self.assertRaises(exceptions.ValidationError): self.find_exact(album=['']) with self.assertRaises(exceptions.ValidationError): self.find_exact(track_no=['']) with self.assertRaises(exceptions.ValidationError): self.find_exact(genre=['']) with self.assertRaises(exceptions.ValidationError): self.find_exact(date=['']) with self.assertRaises(exceptions.ValidationError): self.find_exact(comment=['']) with self.assertRaises(exceptions.ValidationError): self.find_exact(any=['']) def test_search_no_hits(self): result = self.search(track_name=['unknown track']) self.assertEqual(list(result[0].tracks), []) result = self.search(artist=['unknown artist']) self.assertEqual(list(result[0].tracks), []) result = self.search(albumartist=['unknown albumartist']) self.assertEqual(list(result[0].tracks), []) result = self.search(composer=['unknown composer']) self.assertEqual(list(result[0].tracks), []) result = self.search(performer=['unknown performer']) self.assertEqual(list(result[0].tracks), []) result = self.search(album=['unknown album']) self.assertEqual(list(result[0].tracks), []) result = self.search(track_no=['9']) self.assertEqual(list(result[0].tracks), []) result = self.search(track_no=['no_match']) self.assertEqual(list(result[0].tracks), []) result = self.search(genre=['unknown genre']) self.assertEqual(list(result[0].tracks), []) result = self.search(date=['unknown date']) self.assertEqual(list(result[0].tracks), []) result = self.search(comment=['unknown comment']) self.assertEqual(list(result[0].tracks), []) result = self.search(uri=['unknown uri']) self.assertEqual(list(result[0].tracks), []) result = self.search(any=['unknown anything']) self.assertEqual(list(result[0].tracks), []) def test_search_uri(self): result = self.search(uri=['TH1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.search(uri=['TH2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_track_name(self): result = self.search(track_name=['Rack1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.search(track_name=['Rack2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_artist(self): result = self.search(artist=['Tist1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.search(artist=['Tist2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_albumartist(self): # Artist is both track artist and album artist result = self.search(albumartist=['Tist1']) self.assertEqual(list(result[0].tracks), [self.tracks[0]]) # Artist is both track artist and album artist result = self.search(albumartist=['Tist2']) self.assertEqual(list(result[0].tracks), [self.tracks[1]]) # Artist is just album artist result = self.search(albumartist=['Tist3']) self.assertEqual(list(result[0].tracks), [self.tracks[2]]) def test_search_composer(self): result = self.search(composer=['Tist5']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) def test_search_performer(self): result = self.search(performer=['Tist6']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) def test_search_album(self): result = self.search(album=['Bum1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.search(album=['Bum2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_genre(self): result = self.search(genre=['Enre1']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) result = self.search(genre=['Enre2']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) def test_search_date(self): result = self.search(date=['2001']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.search(date=['2001-02-03']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.search(date=['2001-02-04']) self.assertEqual(list(result[0].tracks), []) result = self.search(date=['2002']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_track_no(self): result = self.search(track_no=['1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.search(track_no=['2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_comment(self): result = self.search(comment=['fantastic']) self.assertEqual(list(result[0].tracks), self.tracks[3:4]) result = self.search(comment=['antasti']) self.assertEqual(list(result[0].tracks), self.tracks[3:4]) def test_search_any(self): # Matches on track artist result = self.search(any=['Tist1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) # Matches on track composer result = self.search(any=['Tist5']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) # Matches on track performer result = self.search(any=['Tist6']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) # Matches on track result = self.search(any=['Rack1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.search(any=['Rack2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) # Matches on track album result = self.search(any=['Bum1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) # Matches on track album artists result = self.search(any=['Tist3']) self.assertEqual(len(result[0].tracks), 2) self.assertIn(self.tracks[2], result[0].tracks) self.assertIn(self.tracks[3], result[0].tracks) # Matches on track genre result = self.search(any=['Enre1']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) result = self.search(any=['Enre2']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) # Matches on track comment result = self.search(any=['fanta']) self.assertEqual(list(result[0].tracks), self.tracks[3:4]) result = self.search(any=['is a fan']) self.assertEqual(list(result[0].tracks), self.tracks[3:4]) # Matches on URI result = self.search(any=['TH1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) def test_search_wrong_type(self): with self.assertRaises(exceptions.ValidationError): self.search(wrong=['test']) def test_search_with_empty_query(self): with self.assertRaises(exceptions.ValidationError): self.search(artist=['']) with self.assertRaises(exceptions.ValidationError): self.search(albumartist=['']) with self.assertRaises(exceptions.ValidationError): self.search(composer=['']) with self.assertRaises(exceptions.ValidationError): self.search(performer=['']) with self.assertRaises(exceptions.ValidationError): self.search(track_name=['']) with self.assertRaises(exceptions.ValidationError): self.search(album=['']) with self.assertRaises(exceptions.ValidationError): self.search(genre=['']) with self.assertRaises(exceptions.ValidationError): self.search(date=['']) with self.assertRaises(exceptions.ValidationError): self.search(comment=['']) with self.assertRaises(exceptions.ValidationError): self.search(uri=['']) with self.assertRaises(exceptions.ValidationError): self.search(any=['']) def test_default_get_images_impl_no_images(self): result = self.library.get_images([track.uri for track in self.tracks]) self.assertEqual(result, {track.uri: tuple() for track in self.tracks}) @mock.patch.object(json.JsonLibrary, 'lookup') def test_default_get_images_impl_album_images(self, mock_lookup): library = actor.LocalBackend(config=self.config, audio=None).library image = Image(uri='imageuri') album = Album(images=[image.uri]) track = Track(uri='trackuri', album=album) mock_lookup.return_value = [track] result = library.get_images([track.uri]) self.assertEqual(result, {track.uri: [image]}) @mock.patch.object(json.JsonLibrary, 'lookup') def test_default_get_images_impl_single_track(self, mock_lookup): library = actor.LocalBackend(config=self.config, audio=None).library image = Image(uri='imageuri') album = Album(images=[image.uri]) track = Track(uri='trackuri', album=album) mock_lookup.return_value = track result = library.get_images([track.uri]) self.assertEqual(result, {track.uri: [image]}) @mock.patch.object(json.JsonLibrary, 'get_images') def test_local_library_get_images(self, mock_get_images): library = actor.LocalBackend(config=self.config, audio=None).library image = Image(uri='imageuri') track = Track(uri='trackuri') mock_get_images.return_value = {track.uri: [image]} result = library.get_images([track.uri]) self.assertEqual(result, {track.uri: [image]}) Mopidy-2.0.0/tests/local/test_playback.py0000664000175000017500000011207112660436420020620 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import time import unittest import mock import pykka from mopidy import core from mopidy.core import PlaybackState from mopidy.internal import deprecation from mopidy.local import actor from mopidy.models import TlTrack, Track from tests import dummy_audio, path_to_data_dir from tests.local import generate_song, populate_tracklist # TODO Test 'playlist repeat', e.g. repeat=1,single=0 class LocalPlaybackProviderTest(unittest.TestCase): config = { 'core': { 'data_dir': path_to_data_dir(''), 'max_tracklist_length': 10000, }, 'local': { 'media_dir': path_to_data_dir(''), 'library': 'json', } } # We need four tracks so that our shuffled track tests behave nicely with # reversed as a fake shuffle. Ensuring that shuffled order is [4,3,2,1] and # normal order [1,2,3,4] which means next_track != next_track_with_random tracks = [ Track(uri=generate_song(i), length=4464) for i in (1, 2, 3, 4)] def add_track(self, uri): track = Track(uri=uri, length=4464) self.tracklist.add([track]) def trigger_about_to_finish(self): # Flush any queued core calls. self.playback.get_current_tl_track().get() callback = self.audio.get_about_to_finish_callback().get() callback() def run(self, result=None): with deprecation.ignore('core.tracklist.add:tracks_arg'): return super(LocalPlaybackProviderTest, self).run(result) def setUp(self): # noqa: N802 self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() self.core = core.Core.start(audio=self.audio, backends=[self.backend], config=self.config).proxy() self.playback = self.core.playback self.tracklist = self.core.tracklist assert len(self.tracks) >= 3, \ 'Need at least three tracks to run tests.' assert self.tracks[0].length >= 2000, \ 'First song needs to be at least 2000 miliseconds' def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def assert_state_is(self, state): self.assertEqual(self.playback.get_state().get(), state) def assert_current_track_is(self, track): self.assertEqual(self.playback.get_current_track().get(), track) def assert_current_track_is_not(self, track): self.assertNotEqual(self.playback.get_current_track().get(), track) def assert_current_track_index_is(self, index): tl_track = self.playback.get_current_tl_track().get() self.assertEqual(self.tracklist.index(tl_track).get(), index) def assert_next_tl_track_is(self, tl_track): current = self.playback.get_current_tl_track().get() self.assertEqual(self.tracklist.next_track(current).get(), tl_track) def assert_next_tl_track_is_not(self, tl_track): current = self.playback.get_current_tl_track().get() self.assertNotEqual(self.tracklist.next_track(current).get(), tl_track) def assert_previous_tl_track_is(self, tl_track): current = self.playback.get_current_tl_track().get() previous = self.tracklist.previous_track(current).get() self.assertEqual(previous, tl_track) def assert_eot_tl_track_is(self, tl_track): current = self.playback.get_current_tl_track().get() self.assertEqual(self.tracklist.eot_track(current).get(), tl_track) def assert_eot_tl_track_is_not(self, tl_track): current = self.playback.get_current_tl_track().get() self.assertNotEqual(self.tracklist.eot_track(current).get(), tl_track) def test_uri_scheme(self): self.assertNotIn('file', self.core.uri_schemes.get()) self.assertIn('local', self.core.uri_schemes.get()) def test_play_mp3(self): self.add_track('local:track:blank.mp3') self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) def test_play_ogg(self): self.add_track('local:track:blank.ogg') self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) def test_play_flac(self): self.add_track('local:track:blank.flac') self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) def test_play_uri_with_non_ascii_bytes(self): # Regression test: If trying to do .split(u':') on a bytestring, the # string will be decoded from ASCII to Unicode, which will crash on # non-ASCII strings, like the bytestring the following URI decodes to. self.add_track('local:track:12%20Doin%E2%80%99%20It%20Right.flac') self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) def test_initial_state_is_stopped(self): self.assert_state_is(PlaybackState.STOPPED) def test_play_with_empty_playlist(self): self.assert_state_is(PlaybackState.STOPPED) self.playback.play().get() self.assert_state_is(PlaybackState.STOPPED) def test_play_with_empty_playlist_return_value(self): self.assertEqual(self.playback.play().get(), None) @populate_tracklist def test_play_state(self): self.assert_state_is(PlaybackState.STOPPED) self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_play_return_value(self): self.assertEqual(self.playback.play().get(), None) @populate_tracklist def test_play_track_state(self): self.assert_state_is(PlaybackState.STOPPED) self.playback.play(self.tl_tracks.get()[-1]).get() self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_play_track_return_value(self): self.assertIsNone(self.playback.play(self.tl_tracks.get()[-1]).get()) @populate_tracklist def test_play_when_playing(self): self.playback.play().get() track = self.playback.get_current_track().get() self.playback.play().get() self.assert_current_track_is(track) @populate_tracklist def test_play_when_paused(self): self.playback.play().get() track = self.playback.get_current_track().get() self.playback.pause().get() self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) self.assert_current_track_is(track) @populate_tracklist def test_play_when_paused_after_next(self): self.playback.play().get() self.playback.next().get() self.playback.next().get() track = self.playback.get_current_track().get() self.playback.pause().get() self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) self.assert_current_track_is(track) @populate_tracklist def test_play_sets_current_track(self): self.playback.play().get() self.assert_current_track_is(self.tracks[0]) @populate_tracklist def test_play_track_sets_current_track(self): self.playback.play(self.tl_tracks.get()[-1]).get() self.assert_current_track_is(self.tracks[-1]) @populate_tracklist def test_play_skips_to_next_track_on_failure(self): # If backend's play() returns False, it is a failure. uri = self.backend.playback.translate_uri(self.tracks[0].uri).get() self.audio.trigger_fake_playback_failure(uri) self.playback.play().get() self.assert_current_track_is_not(self.tracks[0]) self.assert_current_track_is(self.tracks[1]) @populate_tracklist def test_current_track_after_completed_playlist(self): self.playback.play(self.tl_tracks.get()[-1]).get() self.trigger_about_to_finish() # EOS should have triggered self.assert_state_is(PlaybackState.STOPPED) self.assert_current_track_is(None) self.playback.play(self.tl_tracks.get()[-1]).get() self.playback.next().get() self.assert_state_is(PlaybackState.STOPPED) self.assert_current_track_is(None) @populate_tracklist def test_previous(self): self.playback.play().get() self.playback.next().get() self.playback.previous().get() self.assert_current_track_is(self.tracks[0]) @populate_tracklist def test_previous_more(self): self.playback.play().get() # At track 0 self.playback.next().get() # At track 1 self.playback.next().get() # At track 2 self.playback.previous().get() # At track 1 self.assert_current_track_is(self.tracks[1]) @populate_tracklist def test_previous_return_value(self): self.playback.play().get() self.playback.next().get() self.assertIsNone(self.playback.previous().get()) @populate_tracklist def test_previous_does_not_trigger_playback(self): self.playback.play().get() self.playback.next().get() self.playback.stop() self.playback.previous().get() self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_previous_at_start_of_playlist(self): self.playback.previous().get() self.assert_state_is(PlaybackState.STOPPED) self.assert_current_track_is(None) def test_previous_for_empty_playlist(self): self.playback.previous().get() self.assert_state_is(PlaybackState.STOPPED) self.assert_current_track_is(None) @populate_tracklist def test_previous_skips_to_previous_track_on_failure(self): # If backend's play() returns False, it is a failure. uri = self.backend.playback.translate_uri(self.tracks[1].uri).get() self.audio.trigger_fake_playback_failure(uri) self.playback.play(self.tl_tracks.get()[2]).get() self.assert_current_track_is(self.tracks[2]) self.playback.previous().get() self.assert_current_track_is_not(self.tracks[1]) self.assert_current_track_is(self.tracks[0]) @populate_tracklist def test_next(self): self.playback.play().get() old_track = self.playback.get_current_track().get() old_position = self.tracklist.index().get() self.playback.next().get() self.assertEqual(self.tracklist.index().get(), old_position + 1) self.assert_current_track_is_not(old_track) @populate_tracklist def test_next_return_value(self): self.playback.play().get() self.assertEqual(self.playback.next().get(), None) @populate_tracklist def test_next_does_not_trigger_playback(self): self.playback.next().get() self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_next_at_end_of_playlist(self): self.playback.play().get() for i, track in enumerate(self.tracks): self.assert_state_is(PlaybackState.PLAYING) self.assert_current_track_is(track) self.assertEqual(self.tracklist.index().get(), i) self.playback.next() self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_next_until_end_of_playlist_and_play_from_start(self): self.playback.play().get() for _ in self.tracks: self.playback.next().get() self.assert_current_track_is(None) self.assert_state_is(PlaybackState.STOPPED) self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) self.assert_current_track_is(self.tracks[0]) def test_next_for_empty_playlist(self): self.playback.next().get() self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_next_skips_to_next_track_on_failure(self): # If backend's play() returns False, it is a failure. uri = self.backend.playback.translate_uri(self.tracks[1].uri).get() self.audio.trigger_fake_playback_failure(uri) self.playback.play().get() self.assert_current_track_is(self.tracks[0]) self.playback.next().get() self.assert_current_track_is_not(self.tracks[1]) self.assert_current_track_is(self.tracks[2]) @populate_tracklist def test_next_track_before_play(self): self.assert_next_tl_track_is(self.tl_tracks.get()[0]) @populate_tracklist def test_next_track_during_play(self): self.playback.play().get() self.assert_next_tl_track_is(self.tl_tracks.get()[1]) @populate_tracklist def test_next_track_after_previous(self): self.playback.play().get() self.playback.next().get() self.playback.previous().get() self.assert_next_tl_track_is(self.tl_tracks.get()[1]) def test_next_track_empty_playlist(self): self.assert_next_tl_track_is(None) @populate_tracklist def test_next_track_at_end_of_playlist(self): self.playback.play().get() for _ in self.tl_tracks.get()[1:]: self.playback.next().get() self.assert_next_tl_track_is(None) @populate_tracklist def test_next_track_at_end_of_playlist_with_repeat(self): self.tracklist.repeat = True self.playback.play().get() for _ in self.tracks[1:]: self.playback.next().get() self.assert_next_tl_track_is(self.tl_tracks.get()[0]) @populate_tracklist @mock.patch('random.shuffle') def test_next_track_with_random(self, shuffle_mock): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.random = True self.assert_next_tl_track_is(self.tl_tracks.get()[-1]) @populate_tracklist def test_next_with_consume(self): self.tracklist.consume = True self.playback.play().get() self.playback.next().get() self.assertNotIn(self.tracks[0], self.tracklist.get_tracks().get()) @populate_tracklist def test_next_with_single_and_repeat(self): self.tracklist.single = True self.tracklist.repeat = True self.playback.play().get() self.assert_current_track_is(self.tracks[0]) self.playback.next().get() self.assert_current_track_is(self.tracks[1]) @populate_tracklist @mock.patch('random.shuffle') def test_next_with_random(self, shuffle_mock): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.random = True self.playback.play().get() self.assert_current_track_is(self.tracks[-1]) self.playback.next().get() self.assert_current_track_is(self.tracks[-2]) @populate_tracklist @mock.patch('random.shuffle') def test_next_track_with_random_after_append_playlist(self, shuffle_mock): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.random = True current_tl_track = self.playback.get_current_tl_track().get() expected_tl_track = self.tl_tracks.get()[-1] next_tl_track = self.tracklist.next_track(current_tl_track).get() # Baseline checking that first next_track is last tl track per our fake # shuffle. self.assertEqual(next_tl_track, expected_tl_track) self.tracklist.add(self.tracks[:1]) old_next_tl_track = next_tl_track expected_tl_track = self.tracklist.tl_tracks.get()[-1] next_tl_track = self.tracklist.next_track(current_tl_track).get() # Verify that first next track has changed since we added to the # playlist. self.assertEqual(next_tl_track, expected_tl_track) self.assertNotEqual(next_tl_track, old_next_tl_track) @populate_tracklist def test_end_of_track(self): self.playback.play().get() old_track = self.playback.get_current_track().get() old_position = self.tracklist.index().get() self.trigger_about_to_finish() new_track = self.playback.get_current_track().get() self.assertEqual(self.tracklist.index().get(), old_position + 1) self.assertNotEqual(new_track.uri, old_track.uri) @populate_tracklist def test_end_of_track_return_value(self): self.playback.play().get() self.assertEqual(self.trigger_about_to_finish(), None) @populate_tracklist def test_end_of_track_does_not_trigger_playback(self): self.trigger_about_to_finish() self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_end_of_track_at_end_of_playlist(self): self.playback.play().get() for i, track in enumerate(self.tracks): self.assert_state_is(PlaybackState.PLAYING) self.assert_current_track_is(track) self.assertEqual(self.tracklist.index().get(), i) self.trigger_about_to_finish() self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_end_of_track_until_end_of_playlist_and_play_from_start(self): self.playback.play().get() for _ in self.tracks: self.trigger_about_to_finish() self.assertEqual(self.playback.get_current_track().get(), None) self.assert_state_is(PlaybackState.STOPPED) self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) self.assert_current_track_is(self.tracks[0]) def test_end_of_track_for_empty_playlist(self): self.trigger_about_to_finish() self.assert_state_is(PlaybackState.STOPPED) # TODO: On about to finish does not handle skipping to next track yet. @unittest.expectedFailure @populate_tracklist def test_end_of_track_skips_to_next_track_on_failure(self): # If backend's play() returns False, it is a failure. return_values = [True, False, True] self.backend.playback.play = lambda: return_values.pop() self.playback.play().get() self.assert_current_track_is(self.tracks[0]) self.trigger_about_to_finish() self.assert_current_track_is_not(self.tracks[1]) self.assert_current_track_is(self.tracks[2]) @populate_tracklist def test_end_of_track_track_before_play(self): self.assert_next_tl_track_is(self.tl_tracks.get()[0]) @populate_tracklist def test_end_of_track_track_during_play(self): self.playback.play().get() self.assert_next_tl_track_is(self.tl_tracks.get()[1]) @populate_tracklist def test_about_to_finish_after_previous(self): self.playback.play().get() self.trigger_about_to_finish() self.playback.previous().get() self.assert_next_tl_track_is(self.tl_tracks.get()[1]) def test_end_of_track_track_empty_playlist(self): self.assert_next_tl_track_is(None) @populate_tracklist def test_end_of_track_track_at_end_of_playlist(self): self.playback.play().get() for _ in self.tracks[1:]: self.trigger_about_to_finish() self.assert_next_tl_track_is(None) @populate_tracklist def test_end_of_track_track_at_end_of_playlist_with_repeat(self): self.tracklist.repeat = True self.playback.play().get() for _ in self.tracks[1:]: self.trigger_about_to_finish() self.assert_next_tl_track_is(self.tl_tracks.get()[0]) @populate_tracklist @mock.patch('random.shuffle') def test_end_of_track_track_with_random(self, shuffle_mock): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.random = True self.assert_next_tl_track_is(self.tl_tracks.get()[-1]) @populate_tracklist def test_end_of_track_with_consume(self): self.tracklist.consume = True self.playback.play().get() self.trigger_about_to_finish() self.assertNotIn(self.tracks[0], self.tracklist.get_tracks().get()) @populate_tracklist @mock.patch('random.shuffle') def test_end_of_track_with_random(self, shuffle_mock): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.random = True self.playback.play().get() self.assert_current_track_is(self.tracks[-1]) self.trigger_about_to_finish() self.assert_current_track_is(self.tracks[-2]) @populate_tracklist @mock.patch('random.shuffle') def test_end_of_track_track_with_random_after_append_playlist( self, shuffle_mock): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.random = True current_tl_track = self.playback.get_current_tl_track().get() expected_tl_track = self.tracklist.get_tl_tracks().get()[-1] eot_tl_track = self.tracklist.eot_track(current_tl_track).get() # Baseline checking that first eot_track is last tl track per our fake # shuffle. self.assertEqual(eot_tl_track, expected_tl_track) self.tracklist.add(self.tracks[:1]) old_eot_tl_track = eot_tl_track expected_tl_track = self.tracklist.get_tl_tracks().get()[-1] eot_tl_track = self.tracklist.eot_track(current_tl_track).get() # Verify that first next track has changed since we added to the # playlist. self.assertEqual(eot_tl_track, expected_tl_track) self.assertNotEqual(eot_tl_track, old_eot_tl_track) @populate_tracklist def test_previous_track_before_play(self): self.assert_previous_tl_track_is(None) @populate_tracklist def test_previous_track_after_play(self): self.playback.play().get() self.assert_previous_tl_track_is(None) @populate_tracklist def test_previous_track_after_next(self): self.playback.play().get() self.playback.next().get() self.assert_previous_tl_track_is(self.tl_tracks.get()[0]) @populate_tracklist def test_previous_track_after_previous(self): self.playback.play().get() # At track 0 self.playback.next().get() # At track 1 self.playback.next().get() # At track 2 self.playback.previous().get() # At track 1 self.assert_previous_tl_track_is(self.tl_tracks.get()[0]) def test_previous_track_empty_playlist(self): self.assert_previous_tl_track_is(None) @populate_tracklist def test_previous_track_with_consume(self): self.tracklist.consume = True for _ in self.tracks: self.playback.next() current = self.playback.get_current_tl_track().get() self.assert_previous_tl_track_is(current) @populate_tracklist def test_previous_track_with_random(self): self.tracklist.random = True for _ in self.tracks: self.playback.next() current = self.playback.get_current_tl_track().get() self.assert_previous_tl_track_is(current) @populate_tracklist def test_initial_current_track(self): self.assert_current_track_is(None) @populate_tracklist def test_current_track_during_play(self): self.playback.play().get() self.assert_current_track_is(self.tracks[0]) @populate_tracklist def test_current_track_after_next(self): self.playback.play() self.playback.next().get() self.assert_current_track_is(self.tracks[1]) @populate_tracklist def test_initial_tracklist_position(self): self.assertEqual(self.tracklist.index().get(), None) @populate_tracklist def test_tracklist_position_during_play(self): self.playback.play().get() self.assert_current_track_index_is(0) @populate_tracklist def test_tracklist_position_after_next(self): self.playback.play().get() self.playback.next().get() self.assert_current_track_index_is(1) @populate_tracklist def test_tracklist_position_at_end_of_playlist(self): self.playback.play(self.tl_tracks.get()[-1]).get() self.trigger_about_to_finish() # EOS should have triggered self.assert_current_track_index_is(None) @mock.patch('mopidy.core.playback.PlaybackController._on_tracklist_change') def test_on_tracklist_change_gets_called(self, change_mock): self.tracklist.add([Track()]).get() change_mock.assert_called_once_with() @populate_tracklist def test_on_tracklist_change_when_playing(self): self.playback.play().get() current_track = self.playback.get_current_track().get() self.tracklist.add([self.tracks[2]]) self.assert_state_is(PlaybackState.PLAYING) self.assert_current_track_is(current_track) @populate_tracklist def test_on_tracklist_change_when_stopped(self): self.tracklist.add([self.tracks[2]]) self.assert_state_is(PlaybackState.STOPPED) self.assert_current_track_is(None) @populate_tracklist def test_on_tracklist_change_when_paused(self): self.playback.play().get() self.playback.pause() current_track = self.playback.get_current_track().get() self.tracklist.add([self.tracks[2]]) self.assert_state_is(PlaybackState.PAUSED) self.assert_current_track_is(current_track) @populate_tracklist def test_pause_when_stopped(self): self.playback.pause() self.assert_state_is(PlaybackState.PAUSED) @populate_tracklist def test_pause_when_playing(self): self.playback.play().get() self.playback.pause() self.assert_state_is(PlaybackState.PAUSED) @populate_tracklist def test_pause_when_paused(self): self.playback.play().get() self.playback.pause() self.playback.pause() self.assert_state_is(PlaybackState.PAUSED) @populate_tracklist def test_pause_return_value(self): self.playback.play().get() self.assertIsNone(self.playback.pause().get()) @populate_tracklist def test_resume_when_stopped(self): self.playback.resume() self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_resume_when_playing(self): self.playback.play().get() self.playback.resume() self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_resume_when_paused(self): self.playback.play().get() self.playback.pause() self.playback.resume() self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_resume_return_value(self): self.playback.play().get() self.playback.pause() self.assertIsNone(self.playback.resume().get()) @unittest.SkipTest # Uses sleep and might not work with LocalBackend @populate_tracklist def test_resume_continues_from_right_position(self): self.playback.play().get() time.sleep(0.2) self.playback.pause() self.playback.resume() self.assertNotEqual(self.playback.time_position, 0) @populate_tracklist def test_seek_when_stopped(self): result = self.playback.seek(1000) self.assert_(result, 'Seek return value was %s' % result) @populate_tracklist def test_seek_when_stopped_updates_position(self): self.playback.seek(1000).get() position = self.playback.time_position self.assertGreaterEqual(position, 990) def test_seek_on_empty_playlist(self): self.assertFalse(self.playback.seek(0).get()) def test_seek_on_empty_playlist_updates_position(self): self.playback.seek(0).get() self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_seek_when_stopped_triggers_play(self): self.playback.seek(0).get() self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_seek_when_playing(self): self.playback.play().get() result = self.playback.seek(self.tracks[0].length - 1000) self.assert_(result, 'Seek return value was %s' % result) @populate_tracklist def test_seek_when_playing_updates_position(self): length = self.tracks[0].length self.playback.play().get() self.playback.seek(length - 1000).get() position = self.playback.get_time_position().get() self.assertGreaterEqual(position, length - 1010) @populate_tracklist def test_seek_when_paused(self): self.playback.play().get() self.playback.pause() result = self.playback.seek(self.tracks[0].length - 1000) self.assert_(result, 'Seek return value was %s' % result) self.assert_state_is(PlaybackState.PAUSED) @populate_tracklist def test_seek_when_paused_updates_position(self): length = self.tracks[0].length self.playback.play().get() self.playback.pause() self.playback.seek(length - 1000) position = self.playback.get_time_position().get() self.assertGreaterEqual(position, length - 1010) @unittest.SkipTest @populate_tracklist def test_seek_beyond_end_of_song(self): # FIXME need to decide return value self.playback.play().get() result = self.playback.seek(self.tracks[0].length * 100) self.assert_(not result, 'Seek return value was %s' % result) @populate_tracklist def test_seek_beyond_end_of_song_jumps_to_next_song(self): self.playback.play().get() self.playback.seek(self.tracks[0].length * 100).get() self.assert_current_track_is(self.tracks[1]) @populate_tracklist def test_seek_beyond_end_of_song_for_last_track(self): self.playback.play(self.tl_tracks.get()[-1]).get() self.playback.seek(self.tracks[-1].length * 100) self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_stop_when_stopped(self): self.playback.stop() self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_stop_when_playing(self): self.playback.play().get() self.playback.stop() self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_stop_when_paused(self): self.playback.play().get() self.playback.pause() self.playback.stop() self.assert_state_is(PlaybackState.STOPPED) def test_stop_return_value(self): self.playback.play().get() self.assertIsNone(self.playback.stop().get()) def test_time_position_when_stopped(self): self.assertEqual(self.playback.get_time_position().get(), 0) @populate_tracklist def test_time_position_when_stopped_with_playlist(self): self.assertEqual(self.playback.get_time_position().get(), 0) @unittest.SkipTest # Uses sleep and does might not work with LocalBackend @populate_tracklist def test_time_position_when_playing(self): self.playback.play().get() first = self.playback.time_position time.sleep(1) second = self.playback.time_position self.assertGreater(second, first) @populate_tracklist def test_time_position_when_paused(self): self.playback.play().get() self.playback.pause().get() first = self.playback.get_time_position().get() second = self.playback.get_time_position().get() self.assertEqual(first, second) @populate_tracklist def test_play_with_consume(self): self.tracklist.consume = True self.playback.play().get() self.assert_current_track_is(self.tracks[0]) @populate_tracklist def test_playlist_is_empty_after_all_tracks_are_played_with_consume(self): self.tracklist.consume = True self.playback.play().get() for t in self.tracks: self.trigger_about_to_finish() # EOS should have trigger self.assertEqual(len(self.tracklist.get_tracks().get()), 0) @populate_tracklist @mock.patch('random.shuffle') def test_play_with_random(self, shuffle_mock): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.random = True self.playback.play().get() self.assert_current_track_is(self.tracks[-1]) @populate_tracklist @mock.patch('random.shuffle') def test_previous_with_random(self, shuffle_mock): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.random = True self.playback.play().get() self.playback.next().get() current_track = self.playback.get_current_track().get() self.playback.previous() self.assert_current_track_is(current_track) @populate_tracklist def test_end_of_song_starts_next_track(self): self.playback.play().get() self.trigger_about_to_finish() self.assert_current_track_is(self.tracks[1]) @populate_tracklist def test_end_of_song_with_single_and_repeat_starts_same(self): self.tracklist.single = True self.tracklist.repeat = True self.playback.play().get() self.assert_current_track_is(self.tracks[0]) self.trigger_about_to_finish() self.assert_current_track_is(self.tracks[0]) @populate_tracklist def test_end_of_song_with_single_random_and_repeat_starts_same(self): self.tracklist.single = True self.tracklist.repeat = True self.tracklist.random = True self.playback.play().get() current_track = self.playback.get_current_track().get() self.trigger_about_to_finish() self.assert_current_track_is(current_track) @populate_tracklist def test_end_of_song_with_single_stops(self): self.tracklist.single = True self.playback.play().get() self.assert_current_track_is(self.tracks[0]) self.trigger_about_to_finish() self.assert_current_track_is(None) # EOS should have triggered self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_end_of_song_with_single_and_random_stops(self): self.tracklist.single = True self.tracklist.random = True self.playback.play().get() self.trigger_about_to_finish() # EOS should have triggered self.assert_current_track_is(None) self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_end_of_playlist_stops(self): self.playback.play(self.tl_tracks.get()[-1]).get() self.trigger_about_to_finish() # EOS should have triggered self.assert_state_is(PlaybackState.STOPPED) def test_repeat_off_by_default(self): self.assertEqual(self.tracklist.get_repeat().get(), False) def test_random_off_by_default(self): self.assertEqual(self.tracklist.get_random().get(), False) def test_consume_off_by_default(self): self.assertEqual(self.tracklist.get_consume().get(), False) @populate_tracklist def test_random_until_end_of_playlist(self): self.tracklist.random = True self.playback.play().get() for _ in self.tracks[1:]: self.playback.next().get() self.assert_next_tl_track_is(None) @populate_tracklist def test_random_with_eot_until_end_of_playlist(self): self.tracklist.random = True self.playback.play().get() for _ in self.tracks[1:]: self.trigger_about_to_finish() self.assert_eot_tl_track_is(None) @populate_tracklist def test_random_until_end_of_playlist_and_play_from_start(self): self.tracklist.random = True self.playback.play().get() for _ in self.tracks: self.playback.next().get() self.assert_next_tl_track_is_not(None) self.assert_state_is(PlaybackState.STOPPED) self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_random_with_eot_until_end_of_playlist_and_play_from_start(self): self.tracklist.random = True self.playback.play().get() for _ in self.tracks: self.trigger_about_to_finish() # EOS should have triggered self.assert_eot_tl_track_is_not(None) self.assert_state_is(PlaybackState.STOPPED) self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_random_until_end_of_playlist_with_repeat(self): self.tracklist.repeat = True self.tracklist.random = True self.playback.play().get() for _ in self.tracks[1:]: self.playback.next() self.assert_next_tl_track_is_not(None) @populate_tracklist def test_played_track_during_random_not_played_again(self): self.tracklist.random = True self.playback.play().get() played = [] for _ in self.tracks: track = self.playback.get_current_track().get() self.assertNotIn(track, played) played.append(track) self.playback.next().get() @populate_tracklist @mock.patch('random.shuffle') def test_play_track_then_enable_random(self, shuffle_mock): # Covers underlying issue IssueGH17RegressionTest tests for. shuffle_mock.side_effect = lambda tracks: tracks.reverse() expected = self.tl_tracks.get()[::-1] + [None] actual = [] self.playback.play().get() self.tracklist.random = True while self.playback.get_state().get() != PlaybackState.STOPPED: self.playback.next().get() actual.append(self.playback.get_current_tl_track().get()) if len(actual) > len(expected): break self.assertEqual(actual, expected) @populate_tracklist def test_playing_track_that_isnt_in_playlist(self): with self.assertRaises(AssertionError): self.playback.play(TlTrack(17, Track())).get() Mopidy-2.0.0/tests/local/test_search.py0000664000175000017500000000077412575004517020310 0ustar jodaljodal00000000000000from __future__ import unicode_literals import unittest from mopidy.local import search from mopidy.models import Album, Track class LocalLibrarySearchTest(unittest.TestCase): def test_find_exact_with_album_query(self): expected_tracks = [Track(album=Album(name='foo'))] tracks = [Track(), Track(album=Album(name='bar'))] + expected_tracks search_result = search.find_exact(tracks, {'album': ['foo']}) self.assertEqual(search_result.tracks, tuple(expected_tracks)) Mopidy-2.0.0/tests/mpd/0000775000175000017500000000000012660436443015112 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/mpd/test_exceptions.py0000664000175000017500000000404012575004517020700 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest from mopidy.mpd.exceptions import ( MpdAckError, MpdNoCommand, MpdNoExistError, MpdNotImplemented, MpdPermissionError, MpdSystemError, MpdUnknownCommand) class MpdExceptionsTest(unittest.TestCase): def test_mpd_not_implemented_is_a_mpd_ack_error(self): try: raise MpdNotImplemented except MpdAckError as e: self.assertEqual(e.message, 'Not implemented') def test_get_mpd_ack_with_default_values(self): e = MpdAckError('A description') self.assertEqual(e.get_mpd_ack(), 'ACK [0@0] {None} A description') def test_get_mpd_ack_with_values(self): try: raise MpdAckError('A description', index=7, command='foo') except MpdAckError as e: self.assertEqual(e.get_mpd_ack(), 'ACK [0@7] {foo} A description') def test_mpd_unknown_command(self): try: raise MpdUnknownCommand(command='play') except MpdAckError as e: self.assertEqual( e.get_mpd_ack(), 'ACK [5@0] {} unknown command "play"') def test_mpd_no_command(self): try: raise MpdNoCommand except MpdAckError as e: self.assertEqual( e.get_mpd_ack(), 'ACK [5@0] {} No command given') def test_mpd_system_error(self): try: raise MpdSystemError('foo') except MpdSystemError as e: self.assertEqual( e.get_mpd_ack(), 'ACK [52@0] {None} foo') def test_mpd_permission_error(self): try: raise MpdPermissionError(command='foo') except MpdPermissionError as e: self.assertEqual( e.get_mpd_ack(), 'ACK [4@0] {foo} you don\'t have permission for "foo"') def test_mpd_noexist_error(self): try: raise MpdNoExistError(command='foo') except MpdNoExistError as e: self.assertEqual( e.get_mpd_ack(), 'ACK [50@0] {foo} ') Mopidy-2.0.0/tests/mpd/test_actor.py0000664000175000017500000000317212660436420017631 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import mock import pytest from mopidy.mpd import actor # NOTE: Should be kept in sync with all events from mopidy.core.listener @pytest.mark.parametrize("event,expected", [ (['track_playback_paused', 'tl_track', 'time_position'], None), (['track_playback_resumed', 'tl_track', 'time_position'], None), (['track_playback_started', 'tl_track'], None), (['track_playback_ended', 'tl_track', 'time_position'], None), (['playback_state_changed', 'old_state', 'new_state'], 'player'), (['tracklist_changed'], 'playlist'), (['playlists_loaded'], 'stored_playlist'), (['playlist_changed', 'playlist'], 'stored_playlist'), (['playlist_deleted', 'uri'], 'stored_playlist'), (['options_changed'], 'options'), (['volume_changed', 'volume'], 'mixer'), (['mute_changed', 'mute'], 'output'), (['seeked', 'time_position'], 'player'), (['stream_title_changed', 'title'], 'playlist'), ]) def test_idle_hooked_up_correctly(event, expected): config = {'mpd': {'hostname': 'foobar', 'port': 1234, 'zeroconf': None, 'max_connections': None, 'connection_timeout': None}} with mock.patch.object(actor.MpdFrontend, '_setup_server'): frontend = actor.MpdFrontend(core=mock.Mock(), config=config) with mock.patch('mopidy.listener.send') as send_mock: frontend.on_event(event[0], **{e: None for e in event[1:]}) if expected is None: assert not send_mock.call_args else: send_mock.assert_called_once_with(mock.ANY, expected) Mopidy-2.0.0/tests/mpd/test_commands.py0000664000175000017500000002420612575004517020326 0ustar jodaljodal00000000000000# encoding: utf-8 from __future__ import absolute_import, unicode_literals import unittest from mopidy.mpd import exceptions, protocol class TestConverts(unittest.TestCase): def test_integer(self): self.assertEqual(123, protocol.INT('123')) self.assertEqual(-123, protocol.INT('-123')) self.assertEqual(123, protocol.INT('+123')) self.assertRaises(ValueError, protocol.INT, '3.14') self.assertRaises(ValueError, protocol.INT, '') self.assertRaises(ValueError, protocol.INT, 'abc') self.assertRaises(ValueError, protocol.INT, '12 34') def test_unsigned_integer(self): self.assertEqual(123, protocol.UINT('123')) self.assertRaises(ValueError, protocol.UINT, '-123') self.assertRaises(ValueError, protocol.UINT, '+123') self.assertRaises(ValueError, protocol.UINT, '3.14') self.assertRaises(ValueError, protocol.UINT, '') self.assertRaises(ValueError, protocol.UINT, 'abc') self.assertRaises(ValueError, protocol.UINT, '12 34') def test_boolean(self): self.assertEqual(True, protocol.BOOL('1')) self.assertEqual(False, protocol.BOOL('0')) self.assertRaises(ValueError, protocol.BOOL, '3.14') self.assertRaises(ValueError, protocol.BOOL, '') self.assertRaises(ValueError, protocol.BOOL, 'true') self.assertRaises(ValueError, protocol.BOOL, 'false') self.assertRaises(ValueError, protocol.BOOL, 'abc') self.assertRaises(ValueError, protocol.BOOL, '12 34') def test_range(self): self.assertEqual(slice(1, 2), protocol.RANGE('1')) self.assertEqual(slice(0, 1), protocol.RANGE('0')) self.assertEqual(slice(0, None), protocol.RANGE('0:')) self.assertEqual(slice(1, 3), protocol.RANGE('1:3')) self.assertRaises(ValueError, protocol.RANGE, '3.14') self.assertRaises(ValueError, protocol.RANGE, '1:abc') self.assertRaises(ValueError, protocol.RANGE, 'abc:1') self.assertRaises(ValueError, protocol.RANGE, '2:1') self.assertRaises(ValueError, protocol.RANGE, '-1:2') self.assertRaises(ValueError, protocol.RANGE, '1 : 2') self.assertRaises(ValueError, protocol.RANGE, '') self.assertRaises(ValueError, protocol.RANGE, 'true') self.assertRaises(ValueError, protocol.RANGE, 'false') self.assertRaises(ValueError, protocol.RANGE, 'abc') self.assertRaises(ValueError, protocol.RANGE, '12 34') class TestCommands(unittest.TestCase): def setUp(self): # noqa: N802 self.commands = protocol.Commands() def test_add_as_a_decorator(self): @self.commands.add('test') def test(context): pass def test_register_second_command_to_same_name_fails(self): def func(context): pass self.commands.add('foo')(func) with self.assertRaises(Exception): self.commands.add('foo')(func) def test_function_only_takes_context_succeeds(self): sentinel = object() self.commands.add('bar')(lambda context: sentinel) self.assertEqual(sentinel, self.commands.call(['bar'])) def test_function_has_required_arg_succeeds(self): sentinel = object() self.commands.add('bar')(lambda context, required: sentinel) self.assertEqual(sentinel, self.commands.call(['bar', 'arg'])) def test_function_has_optional_args_succeeds(self): sentinel = object() self.commands.add('bar')(lambda context, optional=None: sentinel) self.assertEqual(sentinel, self.commands.call(['bar'])) self.assertEqual(sentinel, self.commands.call(['bar', 'arg'])) def test_function_has_required_and_optional_args_succeeds(self): sentinel = object() def func(context, required, optional=None): return sentinel self.commands.add('bar')(func) self.assertEqual(sentinel, self.commands.call(['bar', 'arg'])) self.assertEqual(sentinel, self.commands.call(['bar', 'arg', 'arg'])) def test_function_has_varargs_succeeds(self): sentinel, args = object(), [] self.commands.add('bar')(lambda context, *args: sentinel) for i in range(10): self.assertEqual(sentinel, self.commands.call(['bar'] + args)) args.append('test') def test_function_has_only_varags_succeeds(self): sentinel = object() self.commands.add('baz')(lambda *args: sentinel) self.assertEqual(sentinel, self.commands.call(['baz'])) def test_function_has_no_arguments_fails(self): with self.assertRaises(TypeError): self.commands.add('test')(lambda: True) def test_function_has_required_and_varargs_fails(self): with self.assertRaises(TypeError): def func(context, required, *args): pass self.commands.add('test')(func) def test_function_has_optional_and_varargs_fails(self): with self.assertRaises(TypeError): def func(context, optional=None, *args): pass self.commands.add('test')(func) def test_function_hash_keywordargs_fails(self): with self.assertRaises(TypeError): self.commands.add('test')(lambda context, **kwargs: True) def test_call_chooses_correct_handler(self): sentinel1, sentinel2, sentinel3 = object(), object(), object() self.commands.add('foo')(lambda context: sentinel1) self.commands.add('bar')(lambda context: sentinel2) self.commands.add('baz')(lambda context: sentinel3) self.assertEqual(sentinel1, self.commands.call(['foo'])) self.assertEqual(sentinel2, self.commands.call(['bar'])) self.assertEqual(sentinel3, self.commands.call(['baz'])) def test_call_with_nonexistent_handler(self): with self.assertRaises(exceptions.MpdUnknownCommand): self.commands.call(['bar']) def test_call_passes_context(self): sentinel = object() self.commands.add('foo')(lambda context: context) self.assertEqual( sentinel, self.commands.call(['foo'], context=sentinel)) def test_call_without_args_fails(self): with self.assertRaises(exceptions.MpdNoCommand): self.commands.call([]) def test_call_passes_required_argument(self): self.commands.add('foo')(lambda context, required: required) self.assertEqual('test123', self.commands.call(['foo', 'test123'])) def test_call_passes_optional_argument(self): sentinel = object() self.commands.add('foo')(lambda context, optional=sentinel: optional) self.assertEqual(sentinel, self.commands.call(['foo'])) self.assertEqual('test', self.commands.call(['foo', 'test'])) def test_call_passes_required_and_optional_argument(self): def func(context, required, optional=None): return (required, optional) self.commands.add('foo')(func) self.assertEqual(('arg', None), self.commands.call(['foo', 'arg'])) self.assertEqual( ('arg', 'kwarg'), self.commands.call(['foo', 'arg', 'kwarg'])) def test_call_passes_varargs(self): self.commands.add('foo')(lambda context, *args: args) def test_call_incorrect_args(self): self.commands.add('foo')(lambda context: context) with self.assertRaises(exceptions.MpdArgError): self.commands.call(['foo', 'bar']) self.commands.add('bar')(lambda context, required: context) with self.assertRaises(exceptions.MpdArgError): self.commands.call(['bar', 'bar', 'baz']) self.commands.add('baz')(lambda context, optional=None: context) with self.assertRaises(exceptions.MpdArgError): self.commands.call(['baz', 'bar', 'baz']) def test_validator_gets_applied_to_required_arg(self): sentinel = object() def func(context, required): return required self.commands.add('test', required=lambda v: sentinel)(func) self.assertEqual(sentinel, self.commands.call(['test', 'foo'])) def test_validator_gets_applied_to_optional_arg(self): sentinel = object() def func(context, optional=None): return optional self.commands.add('foo', optional=lambda v: sentinel)(func) self.assertEqual(sentinel, self.commands.call(['foo', '123'])) def test_validator_skips_optional_default(self): sentinel = object() def func(context, optional=sentinel): return optional self.commands.add('foo', optional=lambda v: None)(func) self.assertEqual(sentinel, self.commands.call(['foo'])) def test_validator_applied_to_non_existent_arg_fails(self): self.commands.add('foo')(lambda context, arg: arg) with self.assertRaises(TypeError): def func(context, wrong_arg): return wrong_arg self.commands.add('bar', arg=lambda v: v)(func) def test_validator_called_context_fails(self): return # TODO: how to handle this with self.assertRaises(TypeError): def func(context): pass self.commands.add('bar', context=lambda v: v)(func) def test_validator_value_error_is_converted(self): def validdate(value): raise ValueError def func(context, arg): pass self.commands.add('bar', arg=validdate)(func) with self.assertRaises(exceptions.MpdArgError): self.commands.call(['bar', 'test']) def test_auth_required_gets_stored(self): def func1(context): pass def func2(context): pass self.commands.add('foo')(func1) self.commands.add('bar', auth_required=False)(func2) self.assertTrue(self.commands.handlers['foo'].auth_required) self.assertFalse(self.commands.handlers['bar'].auth_required) def test_list_command_gets_stored(self): def func1(context): pass def func2(context): pass self.commands.add('foo')(func1) self.commands.add('bar', list_command=False)(func2) self.assertTrue(self.commands.handlers['foo'].list_command) self.assertFalse(self.commands.handlers['bar'].list_command) Mopidy-2.0.0/tests/mpd/test_tokenizer.py0000664000175000017500000001462612575004517020544 0ustar jodaljodal00000000000000# encoding: utf-8 from __future__ import absolute_import, unicode_literals import unittest from mopidy.mpd import exceptions, tokenize class TestTokenizer(unittest.TestCase): def assertTokenizeEquals(self, expected, line): # noqa: N802 self.assertEqual(expected, tokenize.split(line)) def assertTokenizeRaises(self, exception, message, line): # noqa: N802 with self.assertRaises(exception) as cm: tokenize.split(line) self.assertEqual(cm.exception.message, message) def test_empty_string(self): ex = exceptions.MpdNoCommand msg = 'No command given' self.assertTokenizeRaises(ex, msg, '') self.assertTokenizeRaises(ex, msg, ' ') self.assertTokenizeRaises(ex, msg, '\t\t\t') def test_command(self): self.assertTokenizeEquals(['test'], 'test') self.assertTokenizeEquals(['test123'], 'test123') self.assertTokenizeEquals(['foo_bar'], 'foo_bar') def test_command_trailing_whitespace(self): self.assertTokenizeEquals(['test'], 'test ') self.assertTokenizeEquals(['test'], 'test\t\t\t') def test_command_leading_whitespace(self): ex = exceptions.MpdUnknownError msg = 'Letter expected' self.assertTokenizeRaises(ex, msg, ' test') self.assertTokenizeRaises(ex, msg, '\ttest') def test_invalid_command(self): ex = exceptions.MpdUnknownError msg = 'Invalid word character' self.assertTokenizeRaises(ex, msg, 'foo/bar') self.assertTokenizeRaises(ex, msg, 'æøå') self.assertTokenizeRaises(ex, msg, 'test?') self.assertTokenizeRaises(ex, msg, 'te"st') def test_unquoted_param(self): self.assertTokenizeEquals(['test', 'param'], 'test param') self.assertTokenizeEquals(['test', 'param'], 'test\tparam') def test_unquoted_param_leading_whitespace(self): self.assertTokenizeEquals(['test', 'param'], 'test param') self.assertTokenizeEquals(['test', 'param'], 'test\t\tparam') def test_unquoted_param_trailing_whitespace(self): self.assertTokenizeEquals(['test', 'param'], 'test param ') self.assertTokenizeEquals(['test', 'param'], 'test param\t\t') def test_unquoted_param_invalid_chars(self): ex = exceptions.MpdArgError msg = 'Invalid unquoted character' self.assertTokenizeRaises(ex, msg, 'test par"m') self.assertTokenizeRaises(ex, msg, 'test foo\bbar') self.assertTokenizeRaises(ex, msg, 'test foo"bar"baz') self.assertTokenizeRaises(ex, msg, 'test foo\'bar') def test_unquoted_param_numbers(self): self.assertTokenizeEquals(['test', '123'], 'test 123') self.assertTokenizeEquals(['test', '+123'], 'test +123') self.assertTokenizeEquals(['test', '-123'], 'test -123') self.assertTokenizeEquals(['test', '3.14'], 'test 3.14') def test_unquoted_param_extended_chars(self): self.assertTokenizeEquals(['test', 'æøå'], 'test æøå') self.assertTokenizeEquals(['test', '?#$'], 'test ?#$') self.assertTokenizeEquals(['test', '/foo/bar/'], 'test /foo/bar/') self.assertTokenizeEquals(['test', 'foo\\bar'], 'test foo\\bar') def test_unquoted_params(self): self.assertTokenizeEquals(['test', 'foo', 'bar'], 'test foo bar') def test_quoted_param(self): self.assertTokenizeEquals(['test', 'param'], 'test "param"') self.assertTokenizeEquals(['test', 'param'], 'test\t"param"') def test_quoted_param_leading_whitespace(self): self.assertTokenizeEquals(['test', 'param'], 'test "param"') self.assertTokenizeEquals(['test', 'param'], 'test\t\t"param"') def test_quoted_param_trailing_whitespace(self): self.assertTokenizeEquals(['test', 'param'], 'test "param" ') self.assertTokenizeEquals(['test', 'param'], 'test "param"\t\t') def test_quoted_param_invalid_chars(self): ex = exceptions.MpdArgError msg = 'Space expected after closing \'"\'' self.assertTokenizeRaises(ex, msg, 'test "foo"bar"') self.assertTokenizeRaises(ex, msg, 'test "foo"bar" ') self.assertTokenizeRaises(ex, msg, 'test "foo"bar') self.assertTokenizeRaises(ex, msg, 'test "foo"bar ') def test_quoted_param_numbers(self): self.assertTokenizeEquals(['test', '123'], 'test "123"') self.assertTokenizeEquals(['test', '+123'], 'test "+123"') self.assertTokenizeEquals(['test', '-123'], 'test "-123"') self.assertTokenizeEquals(['test', '3.14'], 'test "3.14"') def test_quoted_param_spaces(self): self.assertTokenizeEquals(['test', 'foo bar'], 'test "foo bar"') self.assertTokenizeEquals(['test', 'foo bar'], 'test "foo bar"') self.assertTokenizeEquals(['test', ' param\t'], 'test " param\t"') def test_quoted_param_extended_chars(self): self.assertTokenizeEquals(['test', 'æøå'], 'test "æøå"') self.assertTokenizeEquals(['test', '?#$'], 'test "?#$"') self.assertTokenizeEquals(['test', '/foo/bar/'], 'test "/foo/bar/"') def test_quoted_param_escaping(self): self.assertTokenizeEquals(['test', '\\'], r'test "\\"') self.assertTokenizeEquals(['test', '"'], r'test "\""') self.assertTokenizeEquals(['test', ' '], r'test "\ "') self.assertTokenizeEquals(['test', '\\n'], r'test "\\\n"') def test_quoted_params(self): self.assertTokenizeEquals(['test', 'foo', 'bar'], 'test "foo" "bar"') def test_mixed_params(self): self.assertTokenizeEquals(['test', 'foo', 'bar'], 'test foo "bar"') self.assertTokenizeEquals(['test', 'foo', 'bar'], 'test "foo" bar') self.assertTokenizeEquals(['test', '1', '2'], 'test 1 "2"') self.assertTokenizeEquals(['test', '1', '2'], 'test "1" 2') self.assertTokenizeEquals(['test', 'foo bar', 'baz', '123'], 'test "foo bar" baz 123') self.assertTokenizeEquals(['test', 'foo"bar', 'baz', '123'], r'test "foo\"bar" baz 123') def test_unbalanced_quotes(self): ex = exceptions.MpdArgError msg = 'Invalid unquoted character' self.assertTokenizeRaises(ex, msg, 'test "foo bar" baz"') def test_missing_closing_quote(self): ex = exceptions.MpdArgError msg = 'Missing closing \'"\'' self.assertTokenizeRaises(ex, msg, 'test "foo') self.assertTokenizeRaises(ex, msg, 'test "foo a ') Mopidy-2.0.0/tests/mpd/test_translator.py0000664000175000017500000001564212653464377020735 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest from mopidy.internal import path from mopidy.models import Album, Artist, Playlist, TlTrack, Track from mopidy.mpd import translator class TrackMpdFormatTest(unittest.TestCase): track = Track( uri='a uri', artists=[Artist(name='an artist')], name='a name', album=Album( name='an album', num_tracks=13, artists=[Artist(name='an other artist')], uri='urischeme:album:12345', images=['image1']), track_no=7, composers=[Artist(name='a composer')], performers=[Artist(name='a performer')], genre='a genre', date='1977-01-01', disc_no=1, comment='a comment', length=137000, ) def setUp(self): # noqa: N802 self.media_dir = '/dir/subdir' path.mtime.set_fake_time(1234567) def tearDown(self): # noqa: N802 path.mtime.undo_fake() def test_track_to_mpd_format_for_empty_track(self): result = translator.track_to_mpd_format( Track(uri='a uri', length=137000) ) self.assertIn(('file', 'a uri'), result) self.assertIn(('Time', 137), result) self.assertNotIn(('Artist', ''), result) self.assertNotIn(('Title', ''), result) self.assertNotIn(('Album', ''), result) self.assertNotIn(('Track', 0), result) self.assertNotIn(('Date', ''), result) self.assertEqual(len(result), 2) def test_track_to_mpd_format_with_position(self): result = translator.track_to_mpd_format(Track(), position=1) self.assertNotIn(('Pos', 1), result) def test_track_to_mpd_format_with_tlid(self): result = translator.track_to_mpd_format(TlTrack(1, Track())) self.assertNotIn(('Id', 1), result) def test_track_to_mpd_format_with_position_and_tlid(self): result = translator.track_to_mpd_format( TlTrack(2, Track(uri='a uri')), position=1) self.assertIn(('Pos', 1), result) self.assertIn(('Id', 2), result) def test_track_to_mpd_format_for_nonempty_track(self): result = translator.track_to_mpd_format( TlTrack(122, self.track), position=9) self.assertIn(('file', 'a uri'), result) self.assertIn(('Time', 137), result) self.assertIn(('Artist', 'an artist'), result) self.assertIn(('Title', 'a name'), result) self.assertIn(('Album', 'an album'), result) self.assertIn(('AlbumArtist', 'an other artist'), result) self.assertIn(('Composer', 'a composer'), result) self.assertIn(('Performer', 'a performer'), result) self.assertIn(('Genre', 'a genre'), result) self.assertIn(('Track', '7/13'), result) self.assertIn(('Date', '1977-01-01'), result) self.assertIn(('Disc', 1), result) self.assertIn(('Pos', 9), result) self.assertIn(('Id', 122), result) self.assertIn(('X-AlbumUri', 'urischeme:album:12345'), result) self.assertIn(('X-AlbumImage', 'image1'), result) self.assertNotIn(('Comment', 'a comment'), result) self.assertEqual(len(result), 16) def test_track_to_mpd_format_with_last_modified(self): track = self.track.replace(last_modified=995303899000) result = translator.track_to_mpd_format(track) self.assertIn(('Last-Modified', '2001-07-16T17:18:19Z'), result) def test_track_to_mpd_format_with_last_modified_of_zero(self): track = self.track.replace(last_modified=0) result = translator.track_to_mpd_format(track) keys = [k for k, v in result] self.assertNotIn('Last-Modified', keys) def test_track_to_mpd_format_musicbrainz_trackid(self): track = self.track.replace(musicbrainz_id='foo') result = translator.track_to_mpd_format(track) self.assertIn(('MUSICBRAINZ_TRACKID', 'foo'), result) def test_track_to_mpd_format_musicbrainz_albumid(self): album = self.track.album.replace(musicbrainz_id='foo') track = self.track.replace(album=album) result = translator.track_to_mpd_format(track) self.assertIn(('MUSICBRAINZ_ALBUMID', 'foo'), result) def test_track_to_mpd_format_musicbrainz_albumartistid(self): artist = list(self.track.artists)[0].replace(musicbrainz_id='foo') album = self.track.album.replace(artists=[artist]) track = self.track.replace(album=album) result = translator.track_to_mpd_format(track) self.assertIn(('MUSICBRAINZ_ALBUMARTISTID', 'foo'), result) def test_track_to_mpd_format_musicbrainz_artistid(self): artist = list(self.track.artists)[0].replace(musicbrainz_id='foo') track = self.track.replace(artists=[artist]) result = translator.track_to_mpd_format(track) self.assertIn(('MUSICBRAINZ_ARTISTID', 'foo'), result) def test_concat_multi_values(self): artists = [Artist(name='ABBA'), Artist(name='Beatles')] translated = translator.concat_multi_values(artists, 'name') self.assertEqual(translated, 'ABBA;Beatles') def test_concat_multi_values_artist_with_no_name(self): artists = [Artist(name=None)] translated = translator.concat_multi_values(artists, 'name') self.assertEqual(translated, '') def test_concat_multi_values_artist_with_no_musicbrainz_id(self): artists = [Artist(name='Jah Wobble')] translated = translator.concat_multi_values(artists, 'musicbrainz_id') self.assertEqual(translated, '') def test_track_to_mpd_format_with_stream_title(self): result = translator.track_to_mpd_format(self.track, stream_title='foo') self.assertIn(('Name', 'a name'), result) self.assertIn(('Title', 'foo'), result) def test_track_to_mpd_format_with_empty_stream_title(self): result = translator.track_to_mpd_format(self.track, stream_title='') self.assertIn(('Name', 'a name'), result) self.assertNotIn(('Title', ''), result) def test_track_to_mpd_format_with_stream_and_no_track_name(self): track = self.track.replace(name=None) result = translator.track_to_mpd_format(track, stream_title='foo') self.assertNotIn(('Name', ''), result) self.assertIn(('Title', 'foo'), result) class PlaylistMpdFormatTest(unittest.TestCase): def test_mpd_format(self): playlist = Playlist(tracks=[ Track(uri='foo', track_no=1), Track(uri='bar', track_no=2), Track(uri='baz', track_no=3)]) result = translator.playlist_to_mpd_format(playlist) self.assertEqual(len(result), 3) def test_mpd_format_with_range(self): playlist = Playlist(tracks=[ Track(uri='foo', track_no=1), Track(uri='bar', track_no=2), Track(uri='baz', track_no=3)]) result = translator.playlist_to_mpd_format(playlist, 1, 2) self.assertEqual(len(result), 1) self.assertEqual(dict(result[0])['Track'], 2) Mopidy-2.0.0/tests/mpd/__init__.py0000664000175000017500000000007112505224626017215 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals Mopidy-2.0.0/tests/mpd/test_status.py0000664000175000017500000002000612660436420020037 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest import pykka from mopidy import core from mopidy.core import PlaybackState from mopidy.internal import deprecation from mopidy.models import Track from mopidy.mpd import dispatcher from mopidy.mpd.protocol import status from tests import dummy_audio, dummy_backend, dummy_mixer PAUSED = PlaybackState.PAUSED PLAYING = PlaybackState.PLAYING STOPPED = PlaybackState.STOPPED # FIXME migrate to using protocol.BaseTestCase instead of status.stats # directly? class StatusHandlerTest(unittest.TestCase): def setUp(self): # noqa: N802 config = { 'core': { 'max_tracklist_length': 10000, } } self.audio = dummy_audio.create_proxy() self.mixer = dummy_mixer.create_proxy() self.backend = dummy_backend.create_proxy(audio=self.audio) with deprecation.ignore(): self.core = core.Core.start( config, audio=self.audio, mixer=self.mixer, backends=[self.backend]).proxy() self.dispatcher = dispatcher.MpdDispatcher(core=self.core) self.context = self.dispatcher.context def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def set_tracklist(self, track): self.backend.library.dummy_library = [track] self.core.tracklist.add(uris=[track.uri]).get() def test_stats_method(self): result = status.stats(self.context) self.assertIn('artists', result) self.assertGreaterEqual(int(result['artists']), 0) self.assertIn('albums', result) self.assertGreaterEqual(int(result['albums']), 0) self.assertIn('songs', result) self.assertGreaterEqual(int(result['songs']), 0) self.assertIn('uptime', result) self.assertGreaterEqual(int(result['uptime']), 0) self.assertIn('db_playtime', result) self.assertGreaterEqual(int(result['db_playtime']), 0) self.assertIn('db_update', result) self.assertGreaterEqual(int(result['db_update']), 0) self.assertIn('playtime', result) self.assertGreaterEqual(int(result['playtime']), 0) def test_status_method_contains_volume_with_na_value(self): result = dict(status.status(self.context)) self.assertIn('volume', result) self.assertEqual(int(result['volume']), -1) def test_status_method_contains_volume(self): self.core.mixer.set_volume(17) result = dict(status.status(self.context)) self.assertIn('volume', result) self.assertEqual(int(result['volume']), 17) def test_status_method_contains_repeat_is_0(self): result = dict(status.status(self.context)) self.assertIn('repeat', result) self.assertEqual(int(result['repeat']), 0) def test_status_method_contains_repeat_is_1(self): self.core.tracklist.set_repeat(True) result = dict(status.status(self.context)) self.assertIn('repeat', result) self.assertEqual(int(result['repeat']), 1) def test_status_method_contains_random_is_0(self): result = dict(status.status(self.context)) self.assertIn('random', result) self.assertEqual(int(result['random']), 0) def test_status_method_contains_random_is_1(self): self.core.tracklist.set_random(True) result = dict(status.status(self.context)) self.assertIn('random', result) self.assertEqual(int(result['random']), 1) def test_status_method_contains_single(self): result = dict(status.status(self.context)) self.assertIn('single', result) self.assertIn(int(result['single']), (0, 1)) def test_status_method_contains_consume_is_0(self): result = dict(status.status(self.context)) self.assertIn('consume', result) self.assertEqual(int(result['consume']), 0) def test_status_method_contains_consume_is_1(self): self.core.tracklist.set_consume(True) result = dict(status.status(self.context)) self.assertIn('consume', result) self.assertEqual(int(result['consume']), 1) def test_status_method_contains_playlist(self): result = dict(status.status(self.context)) self.assertIn('playlist', result) self.assertGreaterEqual(int(result['playlist']), 0) self.assertLessEqual(int(result['playlist']), 2 ** 31 - 1) def test_status_method_contains_playlistlength(self): result = dict(status.status(self.context)) self.assertIn('playlistlength', result) self.assertGreaterEqual(int(result['playlistlength']), 0) def test_status_method_contains_xfade(self): result = dict(status.status(self.context)) self.assertIn('xfade', result) self.assertGreaterEqual(int(result['xfade']), 0) def test_status_method_contains_state_is_play(self): self.core.playback.state = PLAYING result = dict(status.status(self.context)) self.assertIn('state', result) self.assertEqual(result['state'], 'play') def test_status_method_contains_state_is_stop(self): self.core.playback.state = STOPPED result = dict(status.status(self.context)) self.assertIn('state', result) self.assertEqual(result['state'], 'stop') def test_status_method_contains_state_is_pause(self): self.core.playback.state = PLAYING self.core.playback.state = PAUSED result = dict(status.status(self.context)) self.assertIn('state', result) self.assertEqual(result['state'], 'pause') def test_status_method_when_playlist_loaded_contains_song(self): self.set_tracklist(Track(uri='dummy:/a')) self.core.playback.play().get() result = dict(status.status(self.context)) self.assertIn('song', result) self.assertGreaterEqual(int(result['song']), 0) def test_status_method_when_playlist_loaded_contains_tlid_as_songid(self): self.set_tracklist(Track(uri='dummy:/a')) self.core.playback.play().get() result = dict(status.status(self.context)) self.assertIn('songid', result) self.assertEqual(int(result['songid']), 1) def test_status_method_when_playing_contains_time_with_no_length(self): self.set_tracklist(Track(uri='dummy:/a', length=None)) self.core.playback.play().get() result = dict(status.status(self.context)) self.assertIn('time', result) (position, total) = result['time'].split(':') position = int(position) total = int(total) self.assertLessEqual(position, total) def test_status_method_when_playing_contains_time_with_length(self): self.set_tracklist(Track(uri='dummy:/a', length=10000)) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('time', result) (position, total) = result['time'].split(':') position = int(position) total = int(total) self.assertLessEqual(position, total) def test_status_method_when_playing_contains_elapsed(self): self.set_tracklist(Track(uri='dummy:/a', length=60000)) self.core.playback.play().get() self.core.playback.pause() self.core.playback.seek(59123) result = dict(status.status(self.context)) self.assertIn('elapsed', result) self.assertEqual(result['elapsed'], '59.123') def test_status_method_when_starting_playing_contains_elapsed_zero(self): self.set_tracklist(Track(uri='dummy:/a', length=10000)) self.core.playback.play().get() self.core.playback.pause() result = dict(status.status(self.context)) self.assertIn('elapsed', result) self.assertEqual(result['elapsed'], '0.000') def test_status_method_when_playing_contains_bitrate(self): self.set_tracklist(Track(uri='dummy:/a', bitrate=3200)) self.core.playback.play().get() result = dict(status.status(self.context)) self.assertIn('bitrate', result) self.assertEqual(int(result['bitrate']), 3200) Mopidy-2.0.0/tests/mpd/test_dispatcher.py0000664000175000017500000000305212575004517020647 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest import pykka from mopidy import core from mopidy.internal import deprecation from mopidy.mpd.dispatcher import MpdDispatcher from mopidy.mpd.exceptions import MpdAckError from tests import dummy_backend class MpdDispatcherTest(unittest.TestCase): def setUp(self): # noqa: N802 config = { 'mpd': { 'password': None, 'command_blacklist': ['disabled'], } } self.backend = dummy_backend.create_proxy() self.dispatcher = MpdDispatcher(config=config) with deprecation.ignore(): self.core = core.Core.start(backends=[self.backend]).proxy() def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def test_call_handler_for_unknown_command_raises_exception(self): with self.assertRaises(MpdAckError) as cm: self.dispatcher._call_handler('an_unknown_command with args') self.assertEqual( cm.exception.get_mpd_ack(), 'ACK [5@0] {} unknown command "an_unknown_command"') def test_handling_unknown_request_yields_error(self): result = self.dispatcher.handle_request('an unhandled request') self.assertEqual(result[0], 'ACK [5@0] {} unknown command "an"') def test_handling_blacklisted_command(self): result = self.dispatcher.handle_request('disabled') self.assertEqual(result[0], 'ACK [0@0] {disabled} "disabled" has been ' 'disabled in the server') Mopidy-2.0.0/tests/mpd/protocol/0000775000175000017500000000000012660436443016753 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/mpd/protocol/test_stored_playlists.py0000664000175000017500000004041712660436420023771 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import mock from mopidy.models import Playlist, Track from mopidy.mpd.protocol import stored_playlists from tests.mpd import protocol class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist(self): self.backend.playlists.set_dummy_playlists([ Playlist( name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) self.send_request('listplaylist "name"') self.assertInResponse('file: dummy:a') self.assertInResponse('OK') def test_listplaylist_without_quotes(self): self.backend.playlists.set_dummy_playlists([ Playlist( name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) self.send_request('listplaylist name') self.assertInResponse('file: dummy:a') self.assertInResponse('OK') def test_listplaylist_fails_if_no_playlist_is_found(self): self.send_request('listplaylist "name"') self.assertEqualResponse('ACK [50@0] {listplaylist} No such playlist') def test_listplaylist_duplicate(self): playlist1 = Playlist(name='a', uri='dummy:a1', tracks=[Track(uri='b')]) playlist2 = Playlist(name='a', uri='dummy:a2', tracks=[Track(uri='c')]) self.backend.playlists.set_dummy_playlists([playlist1, playlist2]) self.send_request('listplaylist "a [2]"') self.assertInResponse('file: c') self.assertInResponse('OK') def test_listplaylistinfo(self): self.backend.playlists.set_dummy_playlists([ Playlist( name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) self.send_request('listplaylistinfo "name"') self.assertInResponse('file: dummy:a') self.assertNotInResponse('Track: 0') self.assertNotInResponse('Pos: 0') self.assertInResponse('OK') def test_listplaylistinfo_without_quotes(self): self.backend.playlists.set_dummy_playlists([ Playlist( name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) self.send_request('listplaylistinfo name') self.assertInResponse('file: dummy:a') self.assertNotInResponse('Track: 0') self.assertNotInResponse('Pos: 0') self.assertInResponse('OK') def test_listplaylistinfo_fails_if_no_playlist_is_found(self): self.send_request('listplaylistinfo "name"') self.assertEqualResponse( 'ACK [50@0] {listplaylistinfo} No such playlist') def test_listplaylistinfo_duplicate(self): playlist1 = Playlist(name='a', uri='dummy:a1', tracks=[Track(uri='b')]) playlist2 = Playlist(name='a', uri='dummy:a2', tracks=[Track(uri='c')]) self.backend.playlists.set_dummy_playlists([playlist1, playlist2]) self.send_request('listplaylistinfo "a [2]"') self.assertInResponse('file: c') self.assertNotInResponse('Track: 0') self.assertNotInResponse('Pos: 0') self.assertInResponse('OK') @mock.patch.object(stored_playlists, '_get_last_modified') def test_listplaylists(self, last_modified_mock): last_modified_mock.return_value = '2015-08-05T22:51:06Z' self.backend.playlists.set_dummy_playlists([ Playlist(name='a', uri='dummy:a')]) self.send_request('listplaylists') self.assertInResponse('playlist: a') # Date without milliseconds and with time zone information self.assertInResponse('Last-Modified: 2015-08-05T22:51:06Z') self.assertInResponse('OK') def test_listplaylists_duplicate(self): playlist1 = Playlist(name='a', uri='dummy:a1') playlist2 = Playlist(name='a', uri='dummy:a2') self.backend.playlists.set_dummy_playlists([playlist1, playlist2]) self.send_request('listplaylists') self.assertInResponse('playlist: a') self.assertInResponse('playlist: a [2]') self.assertInResponse('OK') def test_listplaylists_ignores_playlists_without_name(self): last_modified = 1390942873222 self.backend.playlists.set_dummy_playlists([ Playlist(name='', uri='dummy:', last_modified=last_modified)]) self.send_request('listplaylists') self.assertNotInResponse('playlist: ') self.assertInResponse('OK') def test_listplaylists_replaces_newline_with_space(self): self.backend.playlists.set_dummy_playlists([ Playlist(name='a\n', uri='dummy:')]) self.send_request('listplaylists') self.assertInResponse('playlist: a ') self.assertNotInResponse('playlist: a\n') self.assertInResponse('OK') def test_listplaylists_replaces_carriage_return_with_space(self): self.backend.playlists.set_dummy_playlists([ Playlist(name='a\r', uri='dummy:')]) self.send_request('listplaylists') self.assertInResponse('playlist: a ') self.assertNotInResponse('playlist: a\r') self.assertInResponse('OK') def test_listplaylists_replaces_forward_slash_with_pipe(self): self.backend.playlists.set_dummy_playlists([ Playlist(name='a/b', uri='dummy:')]) self.send_request('listplaylists') self.assertInResponse('playlist: a|b') self.assertNotInResponse('playlist: a/b') self.assertInResponse('OK') def test_load_appends_to_tracklist(self): tracks = [ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), Track(uri='dummy:d'), Track(uri='dummy:e'), ] self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=['dummy:a', 'dummy:b']).get() self.assertEqual(len(self.core.tracklist.tracks.get()), 2) self.backend.playlists.set_dummy_playlists([ Playlist(name='A-list', uri='dummy:A-list', tracks=tracks[2:])]) self.send_request('load "A-list"') tracks = self.core.tracklist.tracks.get() self.assertEqual(5, len(tracks)) self.assertEqual('dummy:a', tracks[0].uri) self.assertEqual('dummy:b', tracks[1].uri) self.assertEqual('dummy:c', tracks[2].uri) self.assertEqual('dummy:d', tracks[3].uri) self.assertEqual('dummy:e', tracks[4].uri) self.assertInResponse('OK') def test_load_with_range_loads_part_of_playlist(self): tracks = [ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), Track(uri='dummy:d'), Track(uri='dummy:e'), ] self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=['dummy:a', 'dummy:b']).get() self.assertEqual(len(self.core.tracklist.tracks.get()), 2) self.backend.playlists.set_dummy_playlists([ Playlist(name='A-list', uri='dummy:A-list', tracks=tracks[2:])]) self.send_request('load "A-list" "1:2"') tracks = self.core.tracklist.tracks.get() self.assertEqual(3, len(tracks)) self.assertEqual('dummy:a', tracks[0].uri) self.assertEqual('dummy:b', tracks[1].uri) self.assertEqual('dummy:d', tracks[2].uri) self.assertInResponse('OK') def test_load_with_range_without_end_loads_rest_of_playlist(self): tracks = [ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), Track(uri='dummy:d'), Track(uri='dummy:e'), ] self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=['dummy:a', 'dummy:b']).get() self.assertEqual(len(self.core.tracklist.tracks.get()), 2) self.backend.playlists.set_dummy_playlists([ Playlist(name='A-list', uri='dummy:A-list', tracks=tracks[2:])]) self.send_request('load "A-list" "1:"') tracks = self.core.tracklist.tracks.get() self.assertEqual(4, len(tracks)) self.assertEqual('dummy:a', tracks[0].uri) self.assertEqual('dummy:b', tracks[1].uri) self.assertEqual('dummy:d', tracks[2].uri) self.assertEqual('dummy:e', tracks[3].uri) self.assertInResponse('OK') def test_load_unknown_playlist_acks(self): self.send_request('load "unknown playlist"') self.assertEqual(0, len(self.core.tracklist.tracks.get())) self.assertEqualResponse('ACK [50@0] {load} No such playlist') # No invalid name check for load. self.send_request('load "unknown/playlist"') self.assertEqualResponse('ACK [50@0] {load} No such playlist') def test_playlistadd(self): tracks = [ Track(uri='dummy:a'), Track(uri='dummy:b'), ] self.backend.library.dummy_library = tracks self.backend.playlists.set_dummy_playlists([ Playlist( name='name', uri='dummy:a1', tracks=[tracks[0]])]) self.send_request('playlistadd "name" "dummy:b"') self.assertInResponse('OK') self.assertEqual( 2, len(self.backend.playlists.get_items('dummy:a1').get())) def test_playlistadd_creates_playlist(self): tracks = [ Track(uri='dummy:a'), ] self.backend.library.dummy_library = tracks self.send_request('playlistadd "name" "dummy:a"') self.assertInResponse('OK') self.assertIsNotNone(self.backend.playlists.lookup('dummy:name').get()) def test_playlistadd_invalid_name_acks(self): self.send_request('playlistadd "foo/bar" "dummy:a"') self.assertInResponse('ACK [2@0] {playlistadd} playlist name is ' 'invalid: playlist names may not contain ' 'slashes, newlines or carriage returns') def test_playlistclear(self): self.backend.playlists.set_dummy_playlists([ Playlist( name='name', uri='dummy:a1', tracks=[Track(uri='b')])]) self.send_request('playlistclear "name"') self.assertInResponse('OK') self.assertEqual( 0, len(self.backend.playlists.get_items('dummy:a1').get())) def test_playlistclear_creates_playlist(self): self.send_request('playlistclear "name"') self.assertInResponse('OK') self.assertIsNotNone(self.backend.playlists.lookup('dummy:name').get()) def test_playlistclear_invalid_name_acks(self): self.send_request('playlistclear "foo/bar"') self.assertInResponse('ACK [2@0] {playlistclear} playlist name is ' 'invalid: playlist names may not contain ' 'slashes, newlines or carriage returns') def test_playlistdelete(self): tracks = [ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), ] # len() == 3 self.backend.playlists.set_dummy_playlists([ Playlist( name='name', uri='dummy:a1', tracks=tracks)]) self.send_request('playlistdelete "name" "2"') self.assertInResponse('OK') self.assertEqual( 2, len(self.backend.playlists.get_items('dummy:a1').get())) def test_playlistdelete_invalid_name_acks(self): self.send_request('playlistdelete "foo/bar" "0"') self.assertInResponse('ACK [2@0] {playlistdelete} playlist name is ' 'invalid: playlist names may not contain ' 'slashes, newlines or carriage returns') def test_playlistdelete_unknown_playlist_acks(self): self.send_request('playlistdelete "foobar" "0"') self.assertInResponse('ACK [50@0] {playlistdelete} No such playlist') def test_playlistdelete_unknown_index_acks(self): self.send_request('save "foobar"') self.send_request('playlistdelete "foobar" "0"') self.assertInResponse('ACK [2@0] {playlistdelete} Bad song index') def test_playlistmove(self): tracks = [ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c') # this one is getting moved to top ] self.backend.playlists.set_dummy_playlists([ Playlist( name='name', uri='dummy:a1', tracks=tracks)]) self.send_request('playlistmove "name" "2" "0"') self.assertInResponse('OK') self.assertEqual( "dummy:c", self.backend.playlists.get_items('dummy:a1').get()[0].uri) def test_playlistmove_invalid_name_acks(self): self.send_request('playlistmove "foo/bar" "0" "1"') self.assertInResponse('ACK [2@0] {playlistmove} playlist name is ' 'invalid: playlist names may not contain ' 'slashes, newlines or carriage returns') def test_playlistmove_unknown_playlist_acks(self): self.send_request('playlistmove "foobar" "0" "1"') self.assertInResponse('ACK [50@0] {playlistmove} No such playlist') def test_playlistmove_unknown_position_acks(self): self.send_request('save "foobar"') self.send_request('playlistmove "foobar" "0" "1"') self.assertInResponse('ACK [2@0] {playlistmove} Bad song index') def test_playlistmove_same_index_shortcircuits_everything(self): # Bad indexes on unknown playlist: self.send_request('playlistmove "foobar" "0" "0"') self.assertInResponse('OK') self.send_request('playlistmove "foobar" "100000" "100000"') self.assertInResponse('OK') # Bad indexes on known playlist: self.send_request('save "foobar"') self.send_request('playlistmove "foobar" "0" "0"') self.assertInResponse('OK') self.send_request('playlistmove "foobar" "10" "10"') self.assertInResponse('OK') # Invalid playlist name: self.send_request('playlistmove "foo/bar" "0" "0"') self.assertInResponse('OK') def test_rename(self): self.backend.playlists.set_dummy_playlists([ Playlist( name='old_name', uri='dummy:a1', tracks=[Track(uri='b')])]) self.send_request('rename "old_name" "new_name"') self.assertInResponse('OK') self.assertIsNotNone( self.backend.playlists.lookup('dummy:new_name').get()) def test_rename_unknown_playlist_acks(self): self.send_request('rename "foo" "bar"') self.assertInResponse('ACK [50@0] {rename} No such playlist') def test_rename_to_existing_acks(self): self.send_request('save "foo"') self.send_request('save "bar"') self.send_request('rename "foo" "bar"') self.assertInResponse('ACK [56@0] {rename} Playlist already exists') def test_rename_invalid_name_acks(self): expected = ('ACK [2@0] {rename} playlist name is invalid: playlist ' 'names may not contain slashes, newlines or carriage ' 'returns') self.send_request('rename "foo/bar" "bar"') self.assertInResponse(expected) self.send_request('rename "foo" "foo/bar"') self.assertInResponse(expected) self.send_request('rename "bar/foo" "foo/bar"') self.assertInResponse(expected) def test_rm(self): self.backend.playlists.set_dummy_playlists([ Playlist( name='name', uri='dummy:a1', tracks=[Track(uri='b')])]) self.send_request('rm "name"') self.assertInResponse('OK') self.assertIsNone(self.backend.playlists.lookup('dummy:a1').get()) def test_rm_unknown_playlist_acks(self): self.send_request('rm "name"') self.assertInResponse('ACK [50@0] {rm} No such playlist') def test_rm_invalid_name_acks(self): self.send_request('rm "foo/bar"') self.assertInResponse('ACK [2@0] {rm} playlist name is invalid: ' 'playlist names may not contain slashes, ' 'newlines or carriage returns') def test_save(self): self.send_request('save "name"') self.assertInResponse('OK') self.assertIsNotNone(self.backend.playlists.lookup('dummy:name').get()) def test_save_invalid_name_acks(self): self.send_request('save "foo/bar"') self.assertInResponse('ACK [2@0] {save} playlist name is invalid: ' 'playlist names may not contain slashes, ' 'newlines or carriage returns') Mopidy-2.0.0/tests/mpd/protocol/test_mount.py0000664000175000017500000000133112575004517021522 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from tests.mpd import protocol class MountTest(protocol.BaseTestCase): def test_mount(self): self.send_request('mount my_disk /dev/sda') self.assertEqualResponse('ACK [0@0] {mount} Not implemented') def test_unmount(self): self.send_request('unmount my_disk') self.assertEqualResponse('ACK [0@0] {unmount} Not implemented') def test_listmounts(self): self.send_request('listmounts') self.assertEqualResponse('ACK [0@0] {listmounts} Not implemented') def test_listneighbors(self): self.send_request('listneighbors') self.assertEqualResponse('ACK [0@0] {listneighbors} Not implemented') Mopidy-2.0.0/tests/mpd/protocol/test_channels.py0000664000175000017500000000162312575004517022157 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from tests.mpd import protocol class ChannelsHandlerTest(protocol.BaseTestCase): def test_subscribe(self): self.send_request('subscribe "topic"') self.assertEqualResponse('ACK [0@0] {subscribe} Not implemented') def test_unsubscribe(self): self.send_request('unsubscribe "topic"') self.assertEqualResponse('ACK [0@0] {unsubscribe} Not implemented') def test_channels(self): self.send_request('channels') self.assertEqualResponse('ACK [0@0] {channels} Not implemented') def test_readmessages(self): self.send_request('readmessages') self.assertEqualResponse('ACK [0@0] {readmessages} Not implemented') def test_sendmessage(self): self.send_request('sendmessage "topic" "a message"') self.assertEqualResponse('ACK [0@0] {sendmessage} Not implemented') Mopidy-2.0.0/tests/mpd/protocol/__init__.py0000664000175000017500000000610212660436420021056 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest import mock import pykka from mopidy import core from mopidy.internal import deprecation from mopidy.mpd import session, uri_mapper from tests import dummy_audio, dummy_backend, dummy_mixer class MockConnection(mock.Mock): def __init__(self, *args, **kwargs): super(MockConnection, self).__init__(*args, **kwargs) self.host = mock.sentinel.host self.port = mock.sentinel.port self.response = [] def queue_send(self, data): lines = (line for line in data.split('\n') if line) self.response.extend(lines) class BaseTestCase(unittest.TestCase): enable_mixer = True def get_config(self): return { 'core': { 'max_tracklist_length': 10000 }, 'mpd': { 'password': None, 'default_playlist_scheme': 'dummy', } } def setUp(self): # noqa: N802 if self.enable_mixer: self.mixer = dummy_mixer.create_proxy() else: self.mixer = None self.audio = dummy_audio.create_proxy() self.backend = dummy_backend.create_proxy(audio=self.audio) with deprecation.ignore(): self.core = core.Core.start( self.get_config(), audio=self.audio, mixer=self.mixer, backends=[self.backend]).proxy() self.uri_map = uri_mapper.MpdUriMapper(self.core) self.connection = MockConnection() self.session = session.MpdSession( self.connection, config=self.get_config(), core=self.core, uri_map=self.uri_map) self.dispatcher = self.session.dispatcher self.context = self.dispatcher.context def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def send_request(self, request): self.connection.response = [] request = '%s\n' % request.encode('utf-8') self.session.on_receive({'received': request}) return self.connection.response def assertNoResponse(self): # noqa: N802 self.assertEqual([], self.connection.response) def assertInResponse(self, value): # noqa: N802 self.assertIn( value, self.connection.response, 'Did not find %s in %s' % ( repr(value), repr(self.connection.response))) def assertOnceInResponse(self, value): # noqa: N802 matched = len([r for r in self.connection.response if r == value]) self.assertEqual( 1, matched, 'Expected to find %s once in %s' % ( repr(value), repr(self.connection.response))) def assertNotInResponse(self, value): # noqa: N802 self.assertNotIn( value, self.connection.response, 'Found %s in %s' % ( repr(value), repr(self.connection.response))) def assertEqualResponse(self, value): # noqa: N802 self.assertEqual(1, len(self.connection.response)) self.assertEqual(value, self.connection.response[0]) Mopidy-2.0.0/tests/mpd/protocol/test_current_playlist.py0000664000175000017500000004336512660436420023775 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mopidy.internal import deprecation from mopidy.models import Ref, Track from tests.mpd import protocol class AddCommandsTest(protocol.BaseTestCase): def setUp(self): # noqa: N802 super(AddCommandsTest, self).setUp() self.tracks = [Track(uri='dummy:/a', name='a'), Track(uri='dummy:/foo/b', name='b')] self.refs = {'/a': Ref.track(uri='dummy:/a', name='a'), '/foo': Ref.directory(uri='dummy:/foo', name='foo'), '/foo/b': Ref.track(uri='dummy:/foo/b', name='b')} self.backend.library.dummy_library = self.tracks def test_add(self): for track in [self.tracks[0], self.tracks[0], self.tracks[1]]: self.send_request('add "%s"' % track.uri) self.assertEqual(len(self.core.tracklist.tracks.get()), 3) self.assertEqual(self.core.tracklist.tracks.get()[2], self.tracks[1]) self.assertEqualResponse('OK') def test_add_with_uri_not_found_in_library_should_ack(self): self.send_request('add "dummy://foo"') self.assertEqualResponse( 'ACK [50@0] {add} directory or file not found') def test_add_with_empty_uri_should_not_add_anything_and_ok(self): self.backend.library.dummy_browse_result = { 'dummy:/': [self.refs['/a']]} self.send_request('add ""') self.assertEqual(len(self.core.tracklist.tracks.get()), 0) self.assertInResponse('OK') def test_add_with_library_should_recurse(self): self.backend.library.dummy_browse_result = { 'dummy:/': [self.refs['/a'], self.refs['/foo']], 'dummy:/foo': [self.refs['/foo/b']]} self.send_request('add "/dummy"') self.assertEqual(self.core.tracklist.tracks.get(), self.tracks) self.assertInResponse('OK') def test_add_root_should_not_add_anything_and_ok(self): self.backend.library.dummy_browse_result = { 'dummy:/': [self.refs['/a']]} self.send_request('add "/"') self.assertEqual(len(self.core.tracklist.tracks.get()), 0) self.assertInResponse('OK') def test_addid_without_songpos(self): for track in [self.tracks[0], self.tracks[0], self.tracks[1]]: self.send_request('addid "%s"' % track.uri) tl_tracks = self.core.tracklist.tl_tracks.get() self.assertEqual(len(tl_tracks), 3) self.assertEqual(tl_tracks[2].track, self.tracks[1]) self.assertInResponse('Id: %d' % tl_tracks[2].tlid) self.assertInResponse('OK') def test_addid_with_songpos(self): for track in [self.tracks[0], self.tracks[0]]: self.send_request('add "%s"' % track.uri) self.send_request('addid "%s" "1"' % self.tracks[1].uri) tl_tracks = self.core.tracklist.tl_tracks.get() self.assertEqual(len(tl_tracks), 3) self.assertEqual(tl_tracks[1].track, self.tracks[1]) self.assertInResponse('Id: %d' % tl_tracks[1].tlid) self.assertInResponse('OK') def test_addid_with_songpos_out_of_bounds_should_ack(self): self.send_request('addid "%s" "3"' % self.tracks[0].uri) self.assertEqualResponse('ACK [2@0] {addid} Bad song index') def test_addid_with_empty_uri_acks(self): self.send_request('addid ""') self.assertEqualResponse('ACK [50@0] {addid} No such song') def test_addid_with_uri_not_found_in_library_should_ack(self): self.send_request('addid "dummy://foo"') self.assertEqualResponse('ACK [50@0] {addid} No such song') class BasePopulatedTracklistTestCase(protocol.BaseTestCase): def setUp(self): # noqa: N802 super(BasePopulatedTracklistTestCase, self).setUp() tracks = [Track(uri='dummy:/%s' % x, name=x) for x in 'abcdef'] self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=[t.uri for t in tracks]) class DeleteCommandsTest(BasePopulatedTracklistTestCase): def test_clear(self): self.send_request('clear') self.assertEqual(len(self.core.tracklist.tracks.get()), 0) self.assertEqual(self.core.playback.current_track.get(), None) self.assertInResponse('OK') def test_delete_songpos(self): tl_tracks = self.core.tracklist.tl_tracks.get() self.send_request('delete "%d"' % tl_tracks[1].tlid) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.assertInResponse('OK') def test_delete_songpos_out_of_bounds(self): self.send_request('delete "8"') self.assertEqual(len(self.core.tracklist.tracks.get()), 6) self.assertEqualResponse('ACK [2@0] {delete} Bad song index') def test_delete_open_range(self): self.send_request('delete "1:"') self.assertEqual(len(self.core.tracklist.tracks.get()), 1) self.assertInResponse('OK') # TODO: check how this should work. # def test_delete_open_upper_range(self): # self.send_request('delete ":8"') # self.assertEqual(len(self.core.tracklist.tracks.get()), 0) # self.assertInResponse('OK') def test_delete_closed_range(self): self.send_request('delete "1:3"') self.assertEqual(len(self.core.tracklist.tracks.get()), 4) self.assertInResponse('OK') def test_delete_entire_range_out_of_bounds(self): self.send_request('delete "8:9"') self.assertEqual(len(self.core.tracklist.tracks.get()), 6) self.assertEqualResponse('ACK [2@0] {delete} Bad song index') def test_delete_upper_range_out_of_bounds(self): self.send_request('delete "5:9"') self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.assertEqualResponse('OK') def test_deleteid(self): self.send_request('deleteid "1"') self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.assertInResponse('OK') def test_deleteid_does_not_exist(self): self.send_request('deleteid "12345"') self.assertEqual(len(self.core.tracklist.tracks.get()), 6) self.assertEqualResponse('ACK [50@0] {deleteid} No such song') class MoveCommandsTest(BasePopulatedTracklistTestCase): def test_move_songpos(self): self.send_request('move "1" "0"') result = [t.name for t in self.core.tracklist.tracks.get()] self.assertEqual(result, ['b', 'a', 'c', 'd', 'e', 'f']) self.assertInResponse('OK') def test_move_open_range(self): self.send_request('move "2:" "0"') result = [t.name for t in self.core.tracklist.tracks.get()] self.assertEqual(result, ['c', 'd', 'e', 'f', 'a', 'b']) self.assertInResponse('OK') def test_move_closed_range(self): self.send_request('move "1:3" "0"') result = [t.name for t in self.core.tracklist.tracks.get()] self.assertEqual(result, ['b', 'c', 'a', 'd', 'e', 'f']) self.assertInResponse('OK') def test_moveid(self): self.send_request('moveid "5" "2"') result = [t.name for t in self.core.tracklist.tracks.get()] self.assertEqual(result, ['a', 'b', 'e', 'c', 'd', 'f']) self.assertInResponse('OK') def test_moveid_with_tlid_not_found_in_tracklist_should_ack(self): self.send_request('moveid "10" "0"') self.assertEqualResponse( 'ACK [50@0] {moveid} No such song') class PlaylistFindCommandTest(protocol.BaseTestCase): def test_playlistfind(self): self.send_request('playlistfind "tag" "needle"') self.assertEqualResponse('ACK [0@0] {playlistfind} Not implemented') def test_playlistfind_by_filename_not_in_tracklist(self): self.send_request('playlistfind "filename" "file:///dev/null"') self.assertEqualResponse('OK') def test_playlistfind_by_filename_without_quotes(self): self.send_request('playlistfind filename "file:///dev/null"') self.assertEqualResponse('OK') def test_playlistfind_by_filename_in_tracklist(self): track = Track(uri='dummy:///exists') self.backend.library.dummy_library = [track] self.core.tracklist.add(uris=[track.uri]) self.send_request('playlistfind filename "dummy:///exists"') self.assertInResponse('file: dummy:///exists') self.assertInResponse('Id: 1') self.assertInResponse('Pos: 0') self.assertInResponse('OK') class PlaylistIdCommandTest(BasePopulatedTracklistTestCase): def test_playlistid_without_songid(self): self.send_request('playlistid') self.assertInResponse('Title: a') self.assertInResponse('Title: b') self.assertInResponse('OK') def test_playlistid_with_songid(self): self.send_request('playlistid "2"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Id: 1') self.assertInResponse('Title: b') self.assertInResponse('Id: 2') self.assertInResponse('OK') def test_playlistid_with_not_existing_songid_fails(self): self.send_request('playlistid "25"') self.assertEqualResponse('ACK [50@0] {playlistid} No such song') class PlaylistInfoCommandTest(BasePopulatedTracklistTestCase): def test_playlist_returns_same_as_playlistinfo(self): with deprecation.ignore('mpd.protocol.current_playlist.playlist'): playlist_response = self.send_request('playlist') playlistinfo_response = self.send_request('playlistinfo') self.assertEqual(playlist_response, playlistinfo_response) def test_playlistinfo_without_songpos_or_range(self): self.send_request('playlistinfo') self.assertInResponse('Title: a') self.assertInResponse('Pos: 0') self.assertInResponse('Title: b') self.assertInResponse('Pos: 1') self.assertInResponse('Title: c') self.assertInResponse('Pos: 2') self.assertInResponse('Title: d') self.assertInResponse('Pos: 3') self.assertInResponse('Title: e') self.assertInResponse('Pos: 4') self.assertInResponse('Title: f') self.assertInResponse('Pos: 5') self.assertInResponse('OK') def test_playlistinfo_with_songpos(self): # Make the track's CPID not match the playlist position self.core.tracklist.tlid = 17 self.send_request('playlistinfo "4"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Pos: 0') self.assertNotInResponse('Title: b') self.assertNotInResponse('Pos: 1') self.assertNotInResponse('Title: c') self.assertNotInResponse('Pos: 2') self.assertNotInResponse('Title: d') self.assertNotInResponse('Pos: 3') self.assertInResponse('Title: e') self.assertInResponse('Pos: 4') self.assertNotInResponse('Title: f') self.assertNotInResponse('Pos: 5') self.assertInResponse('OK') def test_playlistinfo_with_negative_songpos_same_as_playlistinfo(self): response1 = self.send_request('playlistinfo "-1"') response2 = self.send_request('playlistinfo') self.assertEqual(response1, response2) def test_playlistinfo_with_open_range(self): self.send_request('playlistinfo "2:"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Pos: 0') self.assertNotInResponse('Title: b') self.assertNotInResponse('Pos: 1') self.assertInResponse('Title: c') self.assertInResponse('Pos: 2') self.assertInResponse('Title: d') self.assertInResponse('Pos: 3') self.assertInResponse('Title: e') self.assertInResponse('Pos: 4') self.assertInResponse('Title: f') self.assertInResponse('Pos: 5') self.assertInResponse('OK') def test_playlistinfo_with_closed_range(self): self.send_request('playlistinfo "2:4"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Title: b') self.assertInResponse('Title: c') self.assertInResponse('Title: d') self.assertNotInResponse('Title: e') self.assertNotInResponse('Title: f') self.assertInResponse('OK') def test_playlistinfo_with_too_high_start_of_range_returns_arg_error(self): self.send_request('playlistinfo "10:20"') self.assertEqualResponse('ACK [2@0] {playlistinfo} Bad song index') def test_playlistinfo_with_too_high_end_of_range_returns_ok(self): self.send_request('playlistinfo "0:20"') self.assertInResponse('OK') def test_playlistinfo_with_zero_returns_ok(self): self.send_request('playlistinfo "0"') self.assertInResponse('OK') class PlaylistSearchCommandTest(protocol.BaseTestCase): def test_playlistsearch(self): self.send_request('playlistsearch "any" "needle"') self.assertEqualResponse('ACK [0@0] {playlistsearch} Not implemented') def test_playlistsearch_without_quotes(self): self.send_request('playlistsearch any "needle"') self.assertEqualResponse('ACK [0@0] {playlistsearch} Not implemented') class PlChangeCommandTest(BasePopulatedTracklistTestCase): def test_plchanges_with_lower_version_returns_changes(self): self.send_request('plchanges "0"') self.assertInResponse('Title: a') self.assertInResponse('Title: b') self.assertInResponse('Title: c') self.assertInResponse('OK') def test_plchanges_with_equal_version_returns_nothing(self): self.assertEqual(self.core.tracklist.version.get(), 1) self.send_request('plchanges "1"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Title: b') self.assertNotInResponse('Title: c') self.assertInResponse('OK') def test_plchanges_with_greater_version_returns_nothing(self): self.assertEqual(self.core.tracklist.version.get(), 1) self.send_request('plchanges "2"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Title: b') self.assertNotInResponse('Title: c') self.assertInResponse('OK') def test_plchanges_with_minus_one_returns_entire_playlist(self): self.send_request('plchanges "-1"') self.assertInResponse('Title: a') self.assertInResponse('Title: b') self.assertInResponse('Title: c') self.assertInResponse('OK') def test_plchanges_without_quotes_works(self): self.send_request('plchanges 0') self.assertInResponse('Title: a') self.assertInResponse('Title: b') self.assertInResponse('Title: c') self.assertInResponse('OK') def test_plchangesposid(self): self.send_request('plchangesposid "0"') tl_tracks = self.core.tracklist.tl_tracks.get() self.assertInResponse('cpos: 0') self.assertInResponse('Id: %d' % tl_tracks[0].tlid) self.assertInResponse('cpos: 2') self.assertInResponse('Id: %d' % tl_tracks[1].tlid) self.assertInResponse('cpos: 2') self.assertInResponse('Id: %d' % tl_tracks[2].tlid) self.assertInResponse('OK') class PrioCommandTest(protocol.BaseTestCase): def test_prio(self): self.send_request('prio 255 0:10') self.assertEqualResponse('ACK [0@0] {prio} Not implemented') def test_prioid(self): self.send_request('prioid 255 17 23') self.assertEqualResponse('ACK [0@0] {prioid} Not implemented') class RangeIdCommandTest(protocol.BaseTestCase): def test_rangeid(self): self.send_request('rangeid 17 0:30') self.assertEqualResponse('ACK [0@0] {rangeid} Not implemented') # TODO: we only seem to be testing that don't touch the non shuffled region :/ class ShuffleCommandTest(BasePopulatedTracklistTestCase): def test_shuffle_without_range(self): version = self.core.tracklist.version.get() self.send_request('shuffle') self.assertLess(version, self.core.tracklist.version.get()) self.assertInResponse('OK') def test_shuffle_with_open_range(self): version = self.core.tracklist.version.get() self.send_request('shuffle "4:"') self.assertLess(version, self.core.tracklist.version.get()) result = [t.name for t in self.core.tracklist.tracks.get()] self.assertEqual(result[:4], ['a', 'b', 'c', 'd']) self.assertInResponse('OK') def test_shuffle_with_closed_range(self): version = self.core.tracklist.version.get() self.send_request('shuffle "1:3"') self.assertLess(version, self.core.tracklist.version.get()) result = [t.name for t in self.core.tracklist.tracks.get()] self.assertEqual(result[:1], ['a']) self.assertEqual(result[3:], ['d', 'e', 'f']) self.assertInResponse('OK') class SwapCommandTest(BasePopulatedTracklistTestCase): def test_swap(self): self.send_request('swap "1" "4"') result = [t.name for t in self.core.tracklist.tracks.get()] self.assertEqual(result, ['a', 'e', 'c', 'd', 'b', 'f']) self.assertInResponse('OK') def test_swapid(self): self.send_request('swapid "2" "5"') result = [t.name for t in self.core.tracklist.tracks.get()] self.assertEqual(result, ['a', 'e', 'c', 'd', 'b', 'f']) self.assertInResponse('OK') def test_swapid_with_first_id_unknown_should_ack(self): self.send_request('swapid "1" "8"') self.assertEqualResponse( 'ACK [50@0] {swapid} No such song') def test_swapid_with_second_id_unknown_should_ack(self): self.send_request('swapid "8" "1"') self.assertEqualResponse( 'ACK [50@0] {swapid} No such song') class TagCommandTest(protocol.BaseTestCase): def test_addtagid(self): self.send_request('addtagid 17 artist Abba') self.assertEqualResponse('ACK [0@0] {addtagid} Not implemented') def test_cleartagid(self): self.send_request('cleartagid 17 artist') self.assertEqualResponse('ACK [0@0] {cleartagid} Not implemented') Mopidy-2.0.0/tests/mpd/protocol/test_music_db.py0000664000175000017500000013541712575004517022162 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest import mock from mopidy.models import Album, Artist, Playlist, Ref, SearchResult, Track from mopidy.mpd.protocol import music_db, stored_playlists from tests.mpd import protocol # TODO: split into more modules for faster parallel tests? class QueryFromMpdSearchFormatTest(unittest.TestCase): def test_dates_are_extracted(self): result = music_db._query_from_mpd_search_parameters( ['Date', '1974-01-02', 'Date', '1975'], music_db._SEARCH_MAPPING) self.assertEqual(result['date'][0], '1974-01-02') self.assertEqual(result['date'][1], '1975') def test_empty_value_is_ignored(self): result = music_db._query_from_mpd_search_parameters( ['Date', ''], music_db._SEARCH_MAPPING) self.assertEqual(result, {}) def test_whitespace_value_is_ignored(self): result = music_db._query_from_mpd_search_parameters( ['Date', ' '], music_db._SEARCH_MAPPING) self.assertEqual(result, {}) # TODO Test more mappings class QueryFromMpdListFormatTest(unittest.TestCase): pass # TODO # TODO: why isn't core.playlists.filter getting deprecation warnings? class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_count(self): self.send_request('count "artist" "needle"') self.assertInResponse('songs: 0') self.assertInResponse('playtime: 0') self.assertInResponse('OK') def test_count_without_quotes(self): self.send_request('count artist "needle"') self.assertInResponse('songs: 0') self.assertInResponse('playtime: 0') self.assertInResponse('OK') def test_count_with_multiple_pairs(self): self.send_request('count "artist" "foo" "album" "bar"') self.assertInResponse('songs: 0') self.assertInResponse('playtime: 0') self.assertInResponse('OK') def test_count_correct_length(self): # Count the lone track self.backend.library.dummy_find_exact_result = SearchResult( tracks=[ Track(uri='dummy:a', name='foo', date='2001', length=4000), ]) self.send_request('count "title" "foo"') self.assertInResponse('songs: 1') self.assertInResponse('playtime: 4') self.assertInResponse('OK') # Count multiple tracks self.backend.library.dummy_find_exact_result = SearchResult( tracks=[ Track(uri='dummy:b', date="2001", length=50000), Track(uri='dummy:c', date="2001", length=600000), ]) self.send_request('count "date" "2001"') self.assertInResponse('songs: 2') self.assertInResponse('playtime: 650') self.assertInResponse('OK') def test_count_with_track_length_none(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[ Track(uri='dummy:b', date="2001", length=None), ]) self.send_request('count "date" "2001"') self.assertInResponse('songs: 1') self.assertInResponse('playtime: 0') self.assertInResponse('OK') def test_findadd(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(uri='dummy:a', name='A')]) self.assertEqual(self.core.tracklist.length.get(), 0) self.send_request('findadd "title" "A"') self.assertEqual(self.core.tracklist.length.get(), 1) self.assertEqual(self.core.tracklist.tracks.get()[0].uri, 'dummy:a') self.assertInResponse('OK') def test_searchadd(self): self.backend.library.dummy_search_result = SearchResult( tracks=[Track(uri='dummy:a', name='A')]) self.assertEqual(self.core.tracklist.length.get(), 0) self.send_request('searchadd "title" "a"') self.assertEqual(self.core.tracklist.length.get(), 1) self.assertEqual(self.core.tracklist.tracks.get()[0].uri, 'dummy:a') self.assertInResponse('OK') def test_searchaddpl_appends_to_existing_playlist(self): playlist = self.core.playlists.create('my favs').get() playlist = playlist.replace(tracks=[ Track(uri='dummy:x', name='X'), Track(uri='dummy:y', name='y'), ]) self.core.playlists.save(playlist) self.backend.library.dummy_search_result = SearchResult( tracks=[Track(uri='dummy:a', name='A')]) items = self.core.playlists.get_items(playlist.uri).get() self.assertEqual(len(items), 2) self.send_request('searchaddpl "my favs" "title" "a"') items = self.core.playlists.get_items(playlist.uri).get() self.assertEqual(len(items), 3) self.assertEqual(items[0].uri, 'dummy:x') self.assertEqual(items[1].uri, 'dummy:y') self.assertEqual(items[2].uri, 'dummy:a') self.assertInResponse('OK') def test_searchaddpl_creates_missing_playlist(self): self.backend.library.dummy_search_result = SearchResult( tracks=[Track(uri='dummy:a', name='A')]) playlists = self.core.playlists.as_list().get() self.assertNotIn('my favs', {p.name for p in playlists}) self.send_request('searchaddpl "my favs" "title" "a"') playlists = self.core.playlists.as_list().get() playlist = {p.name: p for p in playlists}['my favs'] items = self.core.playlists.get_items(playlist.uri).get() self.assertEqual(len(items), 1) self.assertEqual(items[0].uri, 'dummy:a') self.assertInResponse('OK') def test_listall_without_uri(self): tracks = [Track(uri='dummy:/a', name='a'), Track(uri='dummy:/foo/b', name='b')] self.backend.library.dummy_library = tracks self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo'), Ref.album(uri='dummy:/album', name='album'), Ref.artist(uri='dummy:/artist', name='artist'), Ref.playlist(uri='dummy:/pl', name='pl')], 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} self.send_request('listall') self.assertInResponse('file: dummy:/a') self.assertInResponse('directory: /dummy/foo') self.assertInResponse('directory: /dummy/album') self.assertInResponse('directory: /dummy/artist') self.assertInResponse('directory: /dummy/pl') self.assertInResponse('file: dummy:/foo/b') self.assertInResponse('OK') def test_listall_with_uri(self): tracks = [Track(uri='dummy:/a', name='a'), Track(uri='dummy:/foo/b', name='b')] self.backend.library.dummy_library = tracks self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')], 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} self.send_request('listall "/dummy/foo"') self.assertNotInResponse('file: dummy:/a') self.assertInResponse('directory: /dummy/foo') self.assertInResponse('file: dummy:/foo/b') self.assertInResponse('OK') def test_listall_with_unknown_uri(self): self.send_request('listall "/unknown"') self.assertEqualResponse('ACK [50@0] {listall} Not found') def test_listall_for_dir_with_and_without_leading_slash_is_the_same(self): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} response1 = self.send_request('listall "dummy"') response2 = self.send_request('listall "/dummy"') self.assertEqual(response1, response2) def test_listall_for_dir_with_and_without_trailing_slash_is_the_same(self): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} response1 = self.send_request('listall "dummy"') response2 = self.send_request('listall "dummy/"') self.assertEqual(response1, response2) def test_listall_duplicate(self): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.directory(uri='dummy:/a1', name='a'), Ref.directory(uri='dummy:/a2', name='a')]} self.send_request('listall') self.assertInResponse('directory: /dummy/a') self.assertInResponse('directory: /dummy/a [2]') def test_listallinfo_without_uri(self): tracks = [Track(uri='dummy:/a', name='a'), Track(uri='dummy:/foo/b', name='b')] self.backend.library.dummy_library = tracks self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo'), Ref.album(uri='dummy:/album', name='album'), Ref.artist(uri='dummy:/artist', name='artist'), Ref.playlist(uri='dummy:/pl', name='pl')], 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} self.send_request('listallinfo') self.assertInResponse('file: dummy:/a') self.assertInResponse('Title: a') self.assertInResponse('directory: /dummy/foo') self.assertInResponse('directory: /dummy/album') self.assertInResponse('directory: /dummy/artist') self.assertInResponse('directory: /dummy/pl') self.assertInResponse('file: dummy:/foo/b') self.assertInResponse('Title: b') self.assertInResponse('OK') def test_listallinfo_with_uri(self): tracks = [Track(uri='dummy:/a', name='a'), Track(uri='dummy:/foo/b', name='b')] self.backend.library.dummy_library = tracks self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')], 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} self.send_request('listallinfo "/dummy/foo"') self.assertNotInResponse('file: dummy:/a') self.assertNotInResponse('Title: a') self.assertInResponse('directory: /dummy/foo') self.assertInResponse('file: dummy:/foo/b') self.assertInResponse('Title: b') self.assertInResponse('OK') def test_listallinfo_with_unknown_uri(self): self.send_request('listallinfo "/unknown"') self.assertEqualResponse('ACK [50@0] {listallinfo} Not found') def test_listallinfo_for_dir_with_and_without_leading_slash_is_same(self): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} response1 = self.send_request('listallinfo "dummy"') response2 = self.send_request('listallinfo "/dummy"') self.assertEqual(response1, response2) def test_listallinfo_for_dir_with_and_without_trailing_slash_is_same(self): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} response1 = self.send_request('listallinfo "dummy"') response2 = self.send_request('listallinfo "dummy/"') self.assertEqual(response1, response2) def test_listallinfo_duplicate(self): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.directory(uri='dummy:/a1', name='a'), Ref.directory(uri='dummy:/a2', name='a')]} self.send_request('listallinfo') self.assertInResponse('directory: /dummy/a') self.assertInResponse('directory: /dummy/a [2]') def test_listfiles(self): self.send_request('listfiles') self.assertEqualResponse('ACK [0@0] {listfiles} Not implemented') @mock.patch.object(stored_playlists, '_get_last_modified') def test_lsinfo_without_path_returns_same_as_for_root( self, last_modified_mock): last_modified_mock.return_value = '2015-08-05T22:51:06Z' self.backend.playlists.set_dummy_playlists([ Playlist(name='a', uri='dummy:/a')]) response1 = self.send_request('lsinfo') response2 = self.send_request('lsinfo "/"') self.assertEqual(response1, response2) @mock.patch.object(stored_playlists, '_get_last_modified') def test_lsinfo_with_empty_path_returns_same_as_for_root( self, last_modified_mock): last_modified_mock.return_value = '2015-08-05T22:51:06Z' self.backend.playlists.set_dummy_playlists([ Playlist(name='a', uri='dummy:/a')]) response1 = self.send_request('lsinfo ""') response2 = self.send_request('lsinfo "/"') self.assertEqual(response1, response2) @mock.patch.object(stored_playlists, '_get_last_modified') def test_lsinfo_for_root_includes_playlists(self, last_modified_mock): last_modified_mock.return_value = '2015-08-05T22:51:06Z' self.backend.playlists.set_dummy_playlists([ Playlist(name='a', uri='dummy:/a')]) self.send_request('lsinfo "/"') self.assertInResponse('playlist: a') self.assertInResponse('Last-Modified: 2015-08-05T22:51:06Z') self.assertInResponse('OK') def test_lsinfo_for_root_includes_dirs_for_each_lib_with_content(self): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} self.send_request('lsinfo "/"') self.assertInResponse('directory: dummy') self.assertInResponse('OK') @mock.patch.object(stored_playlists, '_get_last_modified') def test_lsinfo_for_dir_with_and_without_leading_slash_is_the_same( self, last_modified_mock): last_modified_mock.return_value = '2015-08-05T22:51:06Z' self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} response1 = self.send_request('lsinfo "dummy"') response2 = self.send_request('lsinfo "/dummy"') self.assertEqual(response1, response2) @mock.patch.object(stored_playlists, '_get_last_modified') def test_lsinfo_for_dir_with_and_without_trailing_slash_is_the_same( self, last_modified_mock): last_modified_mock.return_value = '2015-08-05T22:51:06Z' self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} response1 = self.send_request('lsinfo "dummy"') response2 = self.send_request('lsinfo "dummy/"') self.assertEqual(response1, response2) def test_lsinfo_for_dir_includes_tracks(self): self.backend.library.dummy_library = [ Track(uri='dummy:/a', name='a'), ] self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a')]} self.send_request('lsinfo "/dummy"') self.assertInResponse('file: dummy:/a') self.assertInResponse('Title: a') self.assertInResponse('OK') def test_lsinfo_for_dir_includes_subdirs(self): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.directory(uri='dummy:/foo', name='foo')]} self.send_request('lsinfo "/dummy"') self.assertInResponse('directory: dummy/foo') self.assertInResponse('OK') def test_lsinfo_for_empty_dir_returns_nothing(self): self.backend.library.dummy_browse_result = { 'dummy:/': []} self.send_request('lsinfo "/dummy"') self.assertInResponse('OK') def test_lsinfo_for_dir_does_not_recurse(self): self.backend.library.dummy_library = [ Track(uri='dummy:/a', name='a'), ] self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.directory(uri='dummy:/foo', name='foo')], 'dummy:/foo': [Ref.track(uri='dummy:/a', name='a')]} self.send_request('lsinfo "/dummy"') self.assertNotInResponse('file: dummy:/a') self.assertInResponse('OK') def test_lsinfo_for_dir_does_not_include_self(self): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.directory(uri='dummy:/foo', name='foo')], 'dummy:/foo': [Ref.track(uri='dummy:/a', name='a')]} self.send_request('lsinfo "/dummy"') self.assertNotInResponse('directory: dummy') self.assertInResponse('OK') def test_lsinfo_for_root_returns_browse_result_before_playlists(self): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} self.backend.playlists.set_dummy_playlists([ Playlist(name='a', uri='dummy:/a')]) response = self.send_request('lsinfo "/"') self.assertLess(response.index('directory: dummy'), response.index('playlist: a')) def test_lsinfo_duplicate(self): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.directory(uri='dummy:/a1', name='a'), Ref.directory(uri='dummy:/a2', name='a')]} self.send_request('lsinfo "/dummy"') self.assertInResponse('directory: dummy/a') self.assertInResponse('directory: dummy/a [2]') def test_update_without_uri(self): self.send_request('update') self.assertInResponse('updating_db: 0') self.assertInResponse('OK') def test_update_with_uri(self): self.send_request('update "file:///dev/urandom"') self.assertInResponse('updating_db: 0') self.assertInResponse('OK') def test_rescan_without_uri(self): self.send_request('rescan') self.assertInResponse('updating_db: 0') self.assertInResponse('OK') def test_rescan_with_uri(self): self.send_request('rescan "file:///dev/urandom"') self.assertInResponse('updating_db: 0') self.assertInResponse('OK') class MusicDatabaseFindTest(protocol.BaseTestCase): def test_find_includes_fake_artist_and_album_tracks(self): self.backend.library.dummy_find_exact_result = SearchResult( albums=[Album(uri='dummy:album:a', name='A', date='2001')], artists=[Artist(uri='dummy:artist:b', name='B')], tracks=[Track(uri='dummy:track:c', name='C')]) self.send_request('find "any" "foo"') self.assertInResponse('file: dummy:artist:b') self.assertInResponse('Title: Artist: B') self.assertInResponse('file: dummy:album:a') self.assertInResponse('Title: Album: A') self.assertInResponse('Date: 2001') self.assertInResponse('file: dummy:track:c') self.assertInResponse('Title: C') self.assertInResponse('OK') def test_find_artist_does_not_include_fake_artist_tracks(self): self.backend.library.dummy_find_exact_result = SearchResult( albums=[Album(uri='dummy:album:a', name='A', date='2001')], artists=[Artist(uri='dummy:artist:b', name='B')], tracks=[Track(uri='dummy:track:c', name='C')]) self.send_request('find "artist" "foo"') self.assertNotInResponse('file: dummy:artist:b') self.assertNotInResponse('Title: Artist: B') self.assertInResponse('file: dummy:album:a') self.assertInResponse('Title: Album: A') self.assertInResponse('Date: 2001') self.assertInResponse('file: dummy:track:c') self.assertInResponse('Title: C') self.assertInResponse('OK') def test_find_albumartist_does_not_include_fake_artist_tracks(self): self.backend.library.dummy_find_exact_result = SearchResult( albums=[Album(uri='dummy:album:a', name='A', date='2001')], artists=[Artist(uri='dummy:artist:b', name='B')], tracks=[Track(uri='dummy:track:c', name='C')]) self.send_request('find "albumartist" "foo"') self.assertNotInResponse('file: dummy:artist:b') self.assertNotInResponse('Title: Artist: B') self.assertInResponse('file: dummy:album:a') self.assertInResponse('Title: Album: A') self.assertInResponse('Date: 2001') self.assertInResponse('file: dummy:track:c') self.assertInResponse('Title: C') self.assertInResponse('OK') def test_find_artist_and_album_does_not_include_fake_tracks(self): self.backend.library.dummy_find_exact_result = SearchResult( albums=[Album(uri='dummy:album:a', name='A', date='2001')], artists=[Artist(uri='dummy:artist:b', name='B')], tracks=[Track(uri='dummy:track:c', name='C')]) self.send_request('find "artist" "foo" "album" "bar"') self.assertNotInResponse('file: dummy:artist:b') self.assertNotInResponse('Title: Artist: B') self.assertNotInResponse('file: dummy:album:a') self.assertNotInResponse('Title: Album: A') self.assertNotInResponse('Date: 2001') self.assertInResponse('file: dummy:track:c') self.assertInResponse('Title: C') self.assertInResponse('OK') def test_find_album(self): self.send_request('find "album" "what"') self.assertInResponse('OK') def test_find_album_without_quotes(self): self.send_request('find album "what"') self.assertInResponse('OK') def test_find_artist(self): self.send_request('find "artist" "what"') self.assertInResponse('OK') def test_find_artist_without_quotes(self): self.send_request('find artist "what"') self.assertInResponse('OK') def test_find_albumartist(self): self.send_request('find "albumartist" "what"') self.assertInResponse('OK') def test_find_albumartist_without_quotes(self): self.send_request('find albumartist "what"') self.assertInResponse('OK') def test_find_composer(self): self.send_request('find "composer" "what"') self.assertInResponse('OK') def test_find_composer_without_quotes(self): self.send_request('find composer "what"') self.assertInResponse('OK') def test_find_performer(self): self.send_request('find "performer" "what"') self.assertInResponse('OK') def test_find_performer_without_quotes(self): self.send_request('find performer "what"') self.assertInResponse('OK') def test_find_filename(self): self.send_request('find "filename" "afilename"') self.assertInResponse('OK') def test_find_filename_without_quotes(self): self.send_request('find filename "afilename"') self.assertInResponse('OK') def test_find_file(self): self.send_request('find "file" "afilename"') self.assertInResponse('OK') def test_find_file_without_quotes(self): self.send_request('find file "afilename"') self.assertInResponse('OK') def test_find_title(self): self.send_request('find "title" "what"') self.assertInResponse('OK') def test_find_title_without_quotes(self): self.send_request('find title "what"') self.assertInResponse('OK') def test_find_track_no(self): self.send_request('find "track" "10"') self.assertInResponse('OK') def test_find_track_no_without_quotes(self): self.send_request('find track "10"') self.assertInResponse('OK') def test_find_track_no_without_filter_value(self): self.send_request('find "track" ""') self.assertInResponse('OK') def test_find_genre(self): self.send_request('find "genre" "what"') self.assertInResponse('OK') def test_find_genre_without_quotes(self): self.send_request('find genre "what"') self.assertInResponse('OK') def test_find_date(self): self.send_request('find "date" "2002-01-01"') self.assertInResponse('OK') def test_find_date_without_quotes(self): self.send_request('find date "2002-01-01"') self.assertInResponse('OK') def test_find_date_with_capital_d_and_incomplete_date(self): self.send_request('find Date "2005"') self.assertInResponse('OK') def test_find_else_should_fail(self): self.send_request('find "somethingelse" "what"') self.assertEqualResponse('ACK [2@0] {find} incorrect arguments') def test_find_album_and_artist(self): self.send_request('find album "album_what" artist "artist_what"') self.assertInResponse('OK') def test_find_without_filter_value(self): self.send_request('find "album" ""') self.assertInResponse('OK') class MusicDatabaseListTest(protocol.BaseTestCase): def test_list(self): self.backend.library.dummy_get_distinct_result = { 'artist': set(['A Artist'])} self.send_request('list "artist" "artist" "foo"') self.assertInResponse('Artist: A Artist') self.assertInResponse('OK') def test_list_foo_returns_ack(self): self.send_request('list "foo"') self.assertEqualResponse('ACK [2@0] {list} incorrect arguments') # Track title def test_list_title(self): self.send_request('list "title"') self.assertInResponse('OK') # Artist def test_list_artist_with_quotes(self): self.send_request('list "artist"') self.assertInResponse('OK') def test_list_artist_without_quotes(self): self.send_request('list artist') self.assertInResponse('OK') def test_list_artist_without_quotes_and_capitalized(self): self.send_request('list Artist') self.assertInResponse('OK') def test_list_artist_with_query_of_one_token(self): self.send_request('list "artist" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_artist_with_unknown_field_in_query_returns_ack(self): self.send_request('list "artist" "foo" "bar"') self.assertEqualResponse('ACK [2@0] {list} not able to parse args') def test_list_artist_by_artist(self): self.send_request('list "artist" "artist" "anartist"') self.assertInResponse('OK') def test_list_artist_by_album(self): self.send_request('list "artist" "album" "analbum"') self.assertInResponse('OK') def test_list_artist_by_full_date(self): self.send_request('list "artist" "date" "2001-01-01"') self.assertInResponse('OK') def test_list_artist_by_year(self): self.send_request('list "artist" "date" "2001"') self.assertInResponse('OK') def test_list_artist_by_genre(self): self.send_request('list "artist" "genre" "agenre"') self.assertInResponse('OK') def test_list_artist_by_artist_and_album(self): self.send_request( 'list "artist" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') def test_list_artist_without_filter_value(self): self.send_request('list "artist" "artist" ""') self.assertInResponse('OK') def test_list_artist_should_not_return_artists_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(artists=[Artist(name='')])]) self.send_request('list "artist"') self.assertNotInResponse('Artist: ') self.assertInResponse('OK') # Albumartist def test_list_albumartist_with_quotes(self): self.send_request('list "albumartist"') self.assertInResponse('OK') def test_list_albumartist_without_quotes(self): self.send_request('list albumartist') self.assertInResponse('OK') def test_list_albumartist_without_quotes_and_capitalized(self): self.send_request('list Albumartist') self.assertInResponse('OK') def test_list_albumartist_with_query_of_one_token(self): self.send_request('list "albumartist" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_albumartist_with_unknown_field_in_query_returns_ack(self): self.send_request('list "albumartist" "foo" "bar"') self.assertEqualResponse('ACK [2@0] {list} not able to parse args') def test_list_albumartist_by_artist(self): self.send_request('list "albumartist" "artist" "anartist"') self.assertInResponse('OK') def test_list_albumartist_by_album(self): self.send_request('list "albumartist" "album" "analbum"') self.assertInResponse('OK') def test_list_albumartist_by_full_date(self): self.send_request('list "albumartist" "date" "2001-01-01"') self.assertInResponse('OK') def test_list_albumartist_by_year(self): self.send_request('list "albumartist" "date" "2001"') self.assertInResponse('OK') def test_list_albumartist_by_genre(self): self.send_request('list "albumartist" "genre" "agenre"') self.assertInResponse('OK') def test_list_albumartist_by_artist_and_album(self): self.send_request( 'list "albumartist" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') def test_list_albumartist_without_filter_value(self): self.send_request('list "albumartist" "artist" ""') self.assertInResponse('OK') def test_list_albumartist_should_not_return_artists_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(album=Album(artists=[Artist(name='')]))]) self.send_request('list "albumartist"') self.assertNotInResponse('Artist: ') self.assertNotInResponse('Albumartist: ') self.assertNotInResponse('Composer: ') self.assertNotInResponse('Performer: ') self.assertInResponse('OK') # Composer def test_list_composer_with_quotes(self): self.send_request('list "composer"') self.assertInResponse('OK') def test_list_composer_without_quotes(self): self.send_request('list composer') self.assertInResponse('OK') def test_list_composer_without_quotes_and_capitalized(self): self.send_request('list Composer') self.assertInResponse('OK') def test_list_composer_with_query_of_one_token(self): self.send_request('list "composer" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_composer_with_unknown_field_in_query_returns_ack(self): self.send_request('list "composer" "foo" "bar"') self.assertEqualResponse('ACK [2@0] {list} not able to parse args') def test_list_composer_by_artist(self): self.send_request('list "composer" "artist" "anartist"') self.assertInResponse('OK') def test_list_composer_by_album(self): self.send_request('list "composer" "album" "analbum"') self.assertInResponse('OK') def test_list_composer_by_full_date(self): self.send_request('list "composer" "date" "2001-01-01"') self.assertInResponse('OK') def test_list_composer_by_year(self): self.send_request('list "composer" "date" "2001"') self.assertInResponse('OK') def test_list_composer_by_genre(self): self.send_request('list "composer" "genre" "agenre"') self.assertInResponse('OK') def test_list_composer_by_artist_and_album(self): self.send_request( 'list "composer" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') def test_list_composer_without_filter_value(self): self.send_request('list "composer" "artist" ""') self.assertInResponse('OK') def test_list_composer_should_not_return_artists_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(composers=[Artist(name='')])]) self.send_request('list "composer"') self.assertNotInResponse('Artist: ') self.assertNotInResponse('Albumartist: ') self.assertNotInResponse('Composer: ') self.assertNotInResponse('Performer: ') self.assertInResponse('OK') # Performer def test_list_performer_with_quotes(self): self.send_request('list "performer"') self.assertInResponse('OK') def test_list_performer_without_quotes(self): self.send_request('list performer') self.assertInResponse('OK') def test_list_performer_without_quotes_and_capitalized(self): self.send_request('list Albumartist') self.assertInResponse('OK') def test_list_performer_with_query_of_one_token(self): self.send_request('list "performer" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_performer_with_unknown_field_in_query_returns_ack(self): self.send_request('list "performer" "foo" "bar"') self.assertEqualResponse('ACK [2@0] {list} not able to parse args') def test_list_performer_by_artist(self): self.send_request('list "performer" "artist" "anartist"') self.assertInResponse('OK') def test_list_performer_by_album(self): self.send_request('list "performer" "album" "analbum"') self.assertInResponse('OK') def test_list_performer_by_full_date(self): self.send_request('list "performer" "date" "2001-01-01"') self.assertInResponse('OK') def test_list_performer_by_year(self): self.send_request('list "performer" "date" "2001"') self.assertInResponse('OK') def test_list_performer_by_genre(self): self.send_request('list "performer" "genre" "agenre"') self.assertInResponse('OK') def test_list_performer_by_artist_and_album(self): self.send_request( 'list "performer" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') def test_list_performer_without_filter_value(self): self.send_request('list "performer" "artist" ""') self.assertInResponse('OK') def test_list_performer_should_not_return_artists_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(performers=[Artist(name='')])]) self.send_request('list "performer"') self.assertNotInResponse('Artist: ') self.assertNotInResponse('Albumartist: ') self.assertNotInResponse('Composer: ') self.assertNotInResponse('Performer: ') self.assertInResponse('OK') # Album def test_list_album_with_quotes(self): self.send_request('list "album"') self.assertInResponse('OK') def test_list_album_without_quotes(self): self.send_request('list album') self.assertInResponse('OK') def test_list_album_without_quotes_and_capitalized(self): self.send_request('list Album') self.assertInResponse('OK') def test_list_album_with_artist_name(self): self.backend.library.dummy_get_distinct_result = { 'album': set(['foo'])} self.send_request('list "album" "anartist"') self.assertInResponse('Album: foo') self.assertInResponse('OK') def test_list_album_with_artist_name_without_filter_value(self): self.send_request('list "album" ""') self.assertInResponse('OK') def test_list_album_by_artist(self): self.send_request('list "album" "artist" "anartist"') self.assertInResponse('OK') def test_list_album_by_album(self): self.send_request('list "album" "album" "analbum"') self.assertInResponse('OK') def test_list_album_by_albumartist(self): self.send_request('list "album" "albumartist" "anartist"') self.assertInResponse('OK') def test_list_album_by_composer(self): self.send_request('list "album" "composer" "anartist"') self.assertInResponse('OK') def test_list_album_by_performer(self): self.send_request('list "album" "performer" "anartist"') self.assertInResponse('OK') def test_list_album_by_full_date(self): self.send_request('list "album" "date" "2001-01-01"') self.assertInResponse('OK') def test_list_album_by_year(self): self.send_request('list "album" "date" "2001"') self.assertInResponse('OK') def test_list_album_by_genre(self): self.send_request('list "album" "genre" "agenre"') self.assertInResponse('OK') def test_list_album_by_artist_and_album(self): self.send_request( 'list "album" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') def test_list_album_without_filter_value(self): self.send_request('list "album" "artist" ""') self.assertInResponse('OK') def test_list_album_should_not_return_albums_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(album=Album(name=''))]) self.send_request('list "album"') self.assertNotInResponse('Album: ') self.assertInResponse('OK') # Date def test_list_date_with_quotes(self): self.send_request('list "date"') self.assertInResponse('OK') def test_list_date_without_quotes(self): self.send_request('list date') self.assertInResponse('OK') def test_list_date_without_quotes_and_capitalized(self): self.send_request('list Date') self.assertInResponse('OK') def test_list_date_with_query_of_one_token(self): self.send_request('list "date" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_date_by_artist(self): self.send_request('list "date" "artist" "anartist"') self.assertInResponse('OK') def test_list_date_by_album(self): self.send_request('list "date" "album" "analbum"') self.assertInResponse('OK') def test_list_date_by_full_date(self): self.send_request('list "date" "date" "2001-01-01"') self.assertInResponse('OK') def test_list_date_by_year(self): self.send_request('list "date" "date" "2001"') self.assertInResponse('OK') def test_list_date_by_genre(self): self.send_request('list "date" "genre" "agenre"') self.assertInResponse('OK') def test_list_date_by_artist_and_album(self): self.send_request('list "date" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') def test_list_date_without_filter_value(self): self.send_request('list "date" "artist" ""') self.assertInResponse('OK') def test_list_date_should_not_return_blank_dates(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(date='')]) self.send_request('list "date"') self.assertNotInResponse('Date: ') self.assertInResponse('OK') # Genre def test_list_genre_with_quotes(self): self.send_request('list "genre"') self.assertInResponse('OK') def test_list_genre_without_quotes(self): self.send_request('list genre') self.assertInResponse('OK') def test_list_genre_without_quotes_and_capitalized(self): self.send_request('list Genre') self.assertInResponse('OK') def test_list_genre_with_query_of_one_token(self): self.send_request('list "genre" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_genre_by_artist(self): self.send_request('list "genre" "artist" "anartist"') self.assertInResponse('OK') def test_list_genre_by_album(self): self.send_request('list "genre" "album" "analbum"') self.assertInResponse('OK') def test_list_genre_by_full_date(self): self.send_request('list "genre" "date" "2001-01-01"') self.assertInResponse('OK') def test_list_genre_by_year(self): self.send_request('list "genre" "date" "2001"') self.assertInResponse('OK') def test_list_genre_by_genre(self): self.send_request('list "genre" "genre" "agenre"') self.assertInResponse('OK') def test_list_genre_by_artist_and_album(self): self.send_request( 'list "genre" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') def test_list_genre_without_filter_value(self): self.send_request('list "genre" "artist" ""') self.assertInResponse('OK') class MusicDatabaseSearchTest(protocol.BaseTestCase): def test_search(self): self.backend.library.dummy_search_result = SearchResult( albums=[Album(uri='dummy:album:a', name='A')], artists=[Artist(uri='dummy:artist:b', name='B')], tracks=[Track(uri='dummy:track:c', name='C')]) self.send_request('search "any" "foo"') self.assertInResponse('file: dummy:album:a') self.assertInResponse('Title: Album: A') self.assertInResponse('file: dummy:artist:b') self.assertInResponse('Title: Artist: B') self.assertInResponse('file: dummy:track:c') self.assertInResponse('Title: C') self.assertInResponse('OK') def test_search_album(self): self.send_request('search "album" "analbum"') self.assertInResponse('OK') def test_search_album_without_quotes(self): self.send_request('search album "analbum"') self.assertInResponse('OK') def test_search_album_without_filter_value(self): self.send_request('search "album" ""') self.assertInResponse('OK') def test_search_artist(self): self.send_request('search "artist" "anartist"') self.assertInResponse('OK') def test_search_artist_without_quotes(self): self.send_request('search artist "anartist"') self.assertInResponse('OK') def test_search_artist_without_filter_value(self): self.send_request('search "artist" ""') self.assertInResponse('OK') def test_search_albumartist(self): self.send_request('search "albumartist" "analbumartist"') self.assertInResponse('OK') def test_search_albumartist_without_quotes(self): self.send_request('search albumartist "analbumartist"') self.assertInResponse('OK') def test_search_albumartist_without_filter_value(self): self.send_request('search "albumartist" ""') self.assertInResponse('OK') def test_search_composer(self): self.send_request('search "composer" "acomposer"') self.assertInResponse('OK') def test_search_composer_without_quotes(self): self.send_request('search composer "acomposer"') self.assertInResponse('OK') def test_search_composer_without_filter_value(self): self.send_request('search "composer" ""') self.assertInResponse('OK') def test_search_performer(self): self.send_request('search "performer" "aperformer"') self.assertInResponse('OK') def test_search_performer_without_quotes(self): self.send_request('search performer "aperformer"') self.assertInResponse('OK') def test_search_performer_without_filter_value(self): self.send_request('search "performer" ""') self.assertInResponse('OK') def test_search_filename(self): self.send_request('search "filename" "afilename"') self.assertInResponse('OK') def test_search_filename_without_quotes(self): self.send_request('search filename "afilename"') self.assertInResponse('OK') def test_search_filename_without_filter_value(self): self.send_request('search "filename" ""') self.assertInResponse('OK') def test_search_file(self): self.send_request('search "file" "afilename"') self.assertInResponse('OK') def test_search_file_without_quotes(self): self.send_request('search file "afilename"') self.assertInResponse('OK') def test_search_file_without_filter_value(self): self.send_request('search "file" ""') self.assertInResponse('OK') def test_search_title(self): self.send_request('search "title" "atitle"') self.assertInResponse('OK') def test_search_title_without_quotes(self): self.send_request('search title "atitle"') self.assertInResponse('OK') def test_search_title_without_filter_value(self): self.send_request('search "title" ""') self.assertInResponse('OK') def test_search_any(self): self.send_request('search "any" "anything"') self.assertInResponse('OK') def test_search_any_without_quotes(self): self.send_request('search any "anything"') self.assertInResponse('OK') def test_search_any_without_filter_value(self): self.send_request('search "any" ""') self.assertInResponse('OK') def test_search_track_no(self): self.send_request('search "track" "10"') self.assertInResponse('OK') def test_search_track_no_without_quotes(self): self.send_request('search track "10"') self.assertInResponse('OK') def test_search_track_no_without_filter_value(self): self.send_request('search "track" ""') self.assertInResponse('OK') def test_search_genre(self): self.send_request('search "genre" "agenre"') self.assertInResponse('OK') def test_search_genre_without_quotes(self): self.send_request('search genre "agenre"') self.assertInResponse('OK') def test_search_genre_without_filter_value(self): self.send_request('search "genre" ""') self.assertInResponse('OK') def test_search_date(self): self.send_request('search "date" "2002-01-01"') self.assertInResponse('OK') def test_search_date_without_quotes(self): self.send_request('search date "2002-01-01"') self.assertInResponse('OK') def test_search_date_with_capital_d_and_incomplete_date(self): self.send_request('search Date "2005"') self.assertInResponse('OK') def test_search_date_without_filter_value(self): self.send_request('search "date" ""') self.assertInResponse('OK') def test_search_comment(self): self.send_request('search "comment" "acomment"') self.assertInResponse('OK') def test_search_comment_without_quotes(self): self.send_request('search comment "acomment"') self.assertInResponse('OK') def test_search_comment_without_filter_value(self): self.send_request('search "comment" ""') self.assertInResponse('OK') def test_search_else_should_fail(self): self.send_request('search "sometype" "something"') self.assertEqualResponse('ACK [2@0] {search} incorrect arguments') Mopidy-2.0.0/tests/mpd/protocol/test_idle.py0000664000175000017500000001772212660436420021305 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mock import patch from mopidy.mpd.protocol.status import SUBSYSTEMS from tests.mpd import protocol class IdleHandlerTest(protocol.BaseTestCase): def idle_event(self, subsystem): self.session.on_event(subsystem) def assertEqualEvents(self, events): # noqa: N802 self.assertEqual(set(events), self.context.events) def assertEqualSubscriptions(self, events): # noqa: N802 self.assertEqual(set(events), self.context.subscriptions) def assertNoEvents(self): # noqa: N802 self.assertEqualEvents([]) def assertNoSubscriptions(self): # noqa: N802 self.assertEqualSubscriptions([]) def test_base_state(self): self.assertNoSubscriptions() self.assertNoEvents() self.assertNoResponse() def test_idle(self): self.send_request('idle') self.assertEqualSubscriptions(SUBSYSTEMS) self.assertNoEvents() self.assertNoResponse() def test_idle_disables_timeout(self): self.send_request('idle') self.connection.disable_timeout.assert_called_once_with() def test_noidle(self): self.send_request('noidle') self.assertNoSubscriptions() self.assertNoEvents() self.assertNoResponse() def test_idle_player(self): self.send_request('idle player') self.assertEqualSubscriptions(['player']) self.assertNoEvents() self.assertNoResponse() def test_idle_output(self): self.send_request('idle output') self.assertEqualSubscriptions(['output']) self.assertNoEvents() self.assertNoResponse() def test_idle_player_playlist(self): self.send_request('idle player playlist') self.assertEqualSubscriptions(['player', 'playlist']) self.assertNoEvents() self.assertNoResponse() def test_idle_then_noidle(self): self.send_request('idle') self.send_request('noidle') self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse('OK') def test_idle_then_noidle_enables_timeout(self): self.send_request('idle') self.send_request('noidle') self.connection.enable_timeout.assert_called_once_with() def test_idle_then_play(self): with patch.object(self.session, 'stop') as stop_mock: self.send_request('idle') self.send_request('play') stop_mock.assert_called_once_with() def test_idle_then_idle(self): with patch.object(self.session, 'stop') as stop_mock: self.send_request('idle') self.send_request('idle') stop_mock.assert_called_once_with() def test_idle_player_then_play(self): with patch.object(self.session, 'stop') as stop_mock: self.send_request('idle player') self.send_request('play') stop_mock.assert_called_once_with() def test_idle_then_player(self): self.send_request('idle') self.idle_event('player') self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse('changed: player') self.assertOnceInResponse('OK') def test_idle_player_then_event_player(self): self.send_request('idle player') self.idle_event('player') self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse('changed: player') self.assertOnceInResponse('OK') def test_idle_then_output(self): self.send_request('idle') self.idle_event('output') self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse('changed: output') self.assertOnceInResponse('OK') def test_idle_output_then_event_output(self): self.send_request('idle output') self.idle_event('output') self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse('changed: output') self.assertOnceInResponse('OK') def test_idle_player_then_noidle(self): self.send_request('idle player') self.send_request('noidle') self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse('OK') def test_idle_player_playlist_then_noidle(self): self.send_request('idle player playlist') self.send_request('noidle') self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse('OK') def test_idle_player_playlist_then_player(self): self.send_request('idle player playlist') self.idle_event('player') self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse('changed: player') self.assertNotInResponse('changed: playlist') self.assertOnceInResponse('OK') def test_idle_playlist_then_player(self): self.send_request('idle playlist') self.idle_event('player') self.assertEqualEvents(['player']) self.assertEqualSubscriptions(['playlist']) self.assertNoResponse() def test_idle_playlist_then_player_then_playlist(self): self.send_request('idle playlist') self.idle_event('player') self.idle_event('playlist') self.assertNoEvents() self.assertNoSubscriptions() self.assertNotInResponse('changed: player') self.assertOnceInResponse('changed: playlist') self.assertOnceInResponse('OK') def test_player(self): self.idle_event('player') self.assertEqualEvents(['player']) self.assertNoSubscriptions() self.assertNoResponse() def test_player_then_idle_player(self): self.idle_event('player') self.send_request('idle player') self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse('changed: player') self.assertNotInResponse('changed: playlist') self.assertOnceInResponse('OK') def test_player_then_playlist(self): self.idle_event('player') self.idle_event('playlist') self.assertEqualEvents(['player', 'playlist']) self.assertNoSubscriptions() self.assertNoResponse() def test_player_then_idle(self): self.idle_event('player') self.send_request('idle') self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse('changed: player') self.assertOnceInResponse('OK') def test_player_then_playlist_then_idle(self): self.idle_event('player') self.idle_event('playlist') self.send_request('idle') self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse('changed: player') self.assertOnceInResponse('changed: playlist') self.assertOnceInResponse('OK') def test_player_then_idle_playlist(self): self.idle_event('player') self.send_request('idle playlist') self.assertEqualEvents(['player']) self.assertEqualSubscriptions(['playlist']) self.assertNoResponse() def test_player_then_idle_playlist_then_noidle(self): self.idle_event('player') self.send_request('idle playlist') self.send_request('noidle') self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse('OK') def test_player_then_playlist_then_idle_playlist(self): self.idle_event('player') self.idle_event('playlist') self.send_request('idle playlist') self.assertNoEvents() self.assertNoSubscriptions() self.assertNotInResponse('changed: player') self.assertOnceInResponse('changed: playlist') self.assertOnceInResponse('OK') def test_output_then_idle_toggleoutput(self): self.idle_event('output') self.send_request('idle output') self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse('changed: output') self.assertOnceInResponse('OK') Mopidy-2.0.0/tests/mpd/protocol/test_authentication.py0000664000175000017500000000477712575004517023420 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from tests.mpd import protocol class AuthenticationActiveTest(protocol.BaseTestCase): def get_config(self): config = super(AuthenticationActiveTest, self).get_config() config['mpd']['password'] = 'topsecret' return config def test_authentication_with_valid_password_is_accepted(self): self.send_request('password "topsecret"') self.assertTrue(self.dispatcher.authenticated) self.assertInResponse('OK') def test_authentication_with_invalid_password_is_not_accepted(self): self.send_request('password "secret"') self.assertFalse(self.dispatcher.authenticated) self.assertEqualResponse('ACK [3@0] {password} incorrect password') def test_authentication_without_password_fails(self): self.send_request('password') self.assertFalse(self.dispatcher.authenticated) self.assertEqualResponse( 'ACK [2@0] {password} wrong number of arguments for "password"') def test_anything_when_not_authenticated_should_fail(self): self.send_request('any request at all') self.assertFalse(self.dispatcher.authenticated) self.assertEqualResponse( u'ACK [4@0] {any} you don\'t have permission for "any"') def test_close_is_allowed_without_authentication(self): self.send_request('close') self.assertFalse(self.dispatcher.authenticated) def test_commands_is_allowed_without_authentication(self): self.send_request('commands') self.assertFalse(self.dispatcher.authenticated) self.assertInResponse('OK') def test_notcommands_is_allowed_without_authentication(self): self.send_request('notcommands') self.assertFalse(self.dispatcher.authenticated) self.assertInResponse('OK') def test_ping_is_allowed_without_authentication(self): self.send_request('ping') self.assertFalse(self.dispatcher.authenticated) self.assertInResponse('OK') class AuthenticationInactiveTest(protocol.BaseTestCase): def test_authentication_with_anything_when_password_check_turned_off(self): self.send_request('any request at all') self.assertTrue(self.dispatcher.authenticated) self.assertEqualResponse('ACK [5@0] {} unknown command "any"') def test_any_password_is_not_accepted_when_password_check_turned_off(self): self.send_request('password "secret"') self.assertEqualResponse('ACK [3@0] {password} incorrect password') Mopidy-2.0.0/tests/mpd/protocol/test_connection.py0000664000175000017500000000161312575004517022522 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mock import patch from tests.mpd import protocol class ConnectionHandlerTest(protocol.BaseTestCase): def test_close_closes_the_client_connection(self): with patch.object(self.session, 'close') as close_mock: self.send_request('close') close_mock.assert_called_once_with() self.assertEqualResponse('OK') def test_empty_request(self): self.send_request('') self.assertEqualResponse('ACK [5@0] {} No command given') self.send_request(' ') self.assertEqualResponse('ACK [5@0] {} No command given') def test_kill(self): self.send_request('kill') self.assertEqualResponse( 'ACK [4@0] {kill} you don\'t have permission for "kill"') def test_ping(self): self.send_request('ping') self.assertEqualResponse('OK') Mopidy-2.0.0/tests/mpd/protocol/test_reflection.py0000664000175000017500000001001512575004517022511 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from tests.mpd import protocol class ReflectionHandlerTest(protocol.BaseTestCase): def test_config_is_not_allowed_across_the_network(self): self.send_request('config') self.assertEqualResponse( 'ACK [4@0] {config} you don\'t have permission for "config"') def test_commands_returns_list_of_all_commands(self): self.send_request('commands') # Check if some random commands are included self.assertInResponse('command: commands') self.assertInResponse('command: play') self.assertInResponse('command: status') # Check if commands you do not have access to are not present self.assertNotInResponse('command: config') self.assertNotInResponse('command: kill') # Check if the blacklisted commands are not present self.assertNotInResponse('command: command_list_begin') self.assertNotInResponse('command: command_list_ok_begin') self.assertNotInResponse('command: command_list_end') self.assertNotInResponse('command: idle') self.assertNotInResponse('command: noidle') self.assertNotInResponse('command: sticker') self.assertInResponse('OK') def test_decoders(self): self.send_request('decoders') self.assertInResponse('OK') def test_notcommands_returns_only_config_and_kill_and_ok(self): response = self.send_request('notcommands') self.assertEqual(3, len(response)) self.assertInResponse('command: config') self.assertInResponse('command: kill') self.assertInResponse('OK') def test_tagtypes(self): self.send_request('tagtypes') self.assertInResponse('tagtype: Artist') self.assertInResponse('tagtype: ArtistSort') self.assertInResponse('tagtype: Album') self.assertInResponse('tagtype: AlbumArtist') self.assertInResponse('tagtype: AlbumArtistSort') self.assertInResponse('tagtype: Title') self.assertInResponse('tagtype: Track') self.assertInResponse('tagtype: Name') self.assertInResponse('tagtype: Genre') self.assertInResponse('tagtype: Date') self.assertInResponse('tagtype: Composer') self.assertInResponse('tagtype: Performer') self.assertInResponse('tagtype: Disc') self.assertInResponse('tagtype: MUSICBRAINZ_ARTISTID') self.assertInResponse('tagtype: MUSICBRAINZ_ALBUMID') self.assertInResponse('tagtype: MUSICBRAINZ_ALBUMARTISTID') self.assertInResponse('tagtype: MUSICBRAINZ_TRACKID') self.assertInResponse('OK') def test_urlhandlers(self): self.send_request('urlhandlers') self.assertInResponse('OK') self.assertInResponse('handler: dummy') class ReflectionWhenNotAuthedTest(protocol.BaseTestCase): def get_config(self): config = super(ReflectionWhenNotAuthedTest, self).get_config() config['mpd']['password'] = 'topsecret' return config def test_commands_show_less_if_auth_required_and_not_authed(self): self.send_request('commands') # Not requiring auth self.assertInResponse('command: close') self.assertInResponse('command: commands') self.assertInResponse('command: notcommands') self.assertInResponse('command: password') self.assertInResponse('command: ping') # Requiring auth self.assertNotInResponse('command: play') self.assertNotInResponse('command: status') def test_notcommands_returns_more_if_auth_required_and_not_authed(self): self.send_request('notcommands') # Not requiring auth self.assertNotInResponse('command: close') self.assertNotInResponse('command: commands') self.assertNotInResponse('command: notcommands') self.assertNotInResponse('command: password') self.assertNotInResponse('command: ping') # Requiring auth self.assertInResponse('command: play') self.assertInResponse('command: status') Mopidy-2.0.0/tests/mpd/protocol/test_status.py0000664000175000017500000000247712660436420021714 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from mopidy.models import Track from tests.mpd import protocol class StatusHandlerTest(protocol.BaseTestCase): def test_clearerror(self): self.send_request('clearerror') self.assertEqualResponse('ACK [0@0] {clearerror} Not implemented') def test_currentsong(self): track = Track(uri='dummy:/a') self.backend.library.dummy_library = [track] self.core.tracklist.add(uris=[track.uri]).get() self.core.playback.play().get() self.send_request('currentsong') self.assertInResponse('file: dummy:/a') self.assertInResponse('Time: 0') self.assertNotInResponse('Artist: ') self.assertNotInResponse('Title: ') self.assertNotInResponse('Album: ') self.assertNotInResponse('Track: 0') self.assertNotInResponse('Date: ') self.assertInResponse('Pos: 0') self.assertInResponse('Id: 1') self.assertInResponse('OK') def test_currentsong_without_song(self): self.send_request('currentsong') self.assertInResponse('OK') def test_stats_command(self): self.send_request('stats') self.assertInResponse('OK') def test_status_command(self): self.send_request('status') self.assertInResponse('OK') Mopidy-2.0.0/tests/mpd/protocol/test_playback.py0000664000175000017500000004302712660436420022153 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest from mopidy.core import PlaybackState from mopidy.internal import deprecation from mopidy.models import Track from tests.mpd import protocol PAUSED = PlaybackState.PAUSED PLAYING = PlaybackState.PLAYING STOPPED = PlaybackState.STOPPED class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_consume_off(self): self.send_request('consume "0"') self.assertFalse(self.core.tracklist.consume.get()) self.assertInResponse('OK') def test_consume_off_without_quotes(self): self.send_request('consume 0') self.assertFalse(self.core.tracklist.consume.get()) self.assertInResponse('OK') def test_consume_on(self): self.send_request('consume "1"') self.assertTrue(self.core.tracklist.consume.get()) self.assertInResponse('OK') def test_consume_on_without_quotes(self): self.send_request('consume 1') self.assertTrue(self.core.tracklist.consume.get()) self.assertInResponse('OK') def test_crossfade(self): self.send_request('crossfade "10"') self.assertInResponse('ACK [0@0] {crossfade} Not implemented') def test_random_off(self): self.send_request('random "0"') self.assertFalse(self.core.tracklist.random.get()) self.assertInResponse('OK') def test_random_off_without_quotes(self): self.send_request('random 0') self.assertFalse(self.core.tracklist.random.get()) self.assertInResponse('OK') def test_random_on(self): self.send_request('random "1"') self.assertTrue(self.core.tracklist.random.get()) self.assertInResponse('OK') def test_random_on_without_quotes(self): self.send_request('random 1') self.assertTrue(self.core.tracklist.random.get()) self.assertInResponse('OK') def test_repeat_off(self): self.send_request('repeat "0"') self.assertFalse(self.core.tracklist.repeat.get()) self.assertInResponse('OK') def test_repeat_off_without_quotes(self): self.send_request('repeat 0') self.assertFalse(self.core.tracklist.repeat.get()) self.assertInResponse('OK') def test_repeat_on(self): self.send_request('repeat "1"') self.assertTrue(self.core.tracklist.repeat.get()) self.assertInResponse('OK') def test_repeat_on_without_quotes(self): self.send_request('repeat 1') self.assertTrue(self.core.tracklist.repeat.get()) self.assertInResponse('OK') def test_single_off(self): self.send_request('single "0"') self.assertFalse(self.core.tracklist.single.get()) self.assertInResponse('OK') def test_single_off_without_quotes(self): self.send_request('single 0') self.assertFalse(self.core.tracklist.single.get()) self.assertInResponse('OK') def test_single_on(self): self.send_request('single "1"') self.assertTrue(self.core.tracklist.single.get()) self.assertInResponse('OK') def test_single_on_without_quotes(self): self.send_request('single 1') self.assertTrue(self.core.tracklist.single.get()) self.assertInResponse('OK') def test_replay_gain_mode_off(self): self.send_request('replay_gain_mode "off"') self.assertInResponse('ACK [0@0] {replay_gain_mode} Not implemented') def test_replay_gain_mode_track(self): self.send_request('replay_gain_mode "track"') self.assertInResponse('ACK [0@0] {replay_gain_mode} Not implemented') def test_replay_gain_mode_album(self): self.send_request('replay_gain_mode "album"') self.assertInResponse('ACK [0@0] {replay_gain_mode} Not implemented') def test_replay_gain_status_default(self): self.send_request('replay_gain_status') self.assertInResponse('OK') self.assertInResponse('off') def test_mixrampdb(self): self.send_request('mixrampdb "10"') self.assertInResponse('ACK [0@0] {mixrampdb} Not implemented') def test_mixrampdelay(self): self.send_request('mixrampdelay "10"') self.assertInResponse('ACK [0@0] {mixrampdelay} Not implemented') @unittest.SkipTest def test_replay_gain_status_off(self): pass @unittest.SkipTest def test_replay_gain_status_track(self): pass @unittest.SkipTest def test_replay_gain_status_album(self): pass class PlaybackControlHandlerTest(protocol.BaseTestCase): def setUp(self): # noqa: N802 super(PlaybackControlHandlerTest, self).setUp() self.tracks = [Track(uri='dummy:a', length=40000), Track(uri='dummy:b', length=40000)] self.backend.library.dummy_library = self.tracks self.core.tracklist.add(uris=[t.uri for t in self.tracks]).get() def test_next(self): self.core.tracklist.clear().get() self.send_request('next') self.assertInResponse('OK') def test_pause_off(self): self.send_request('play "0"') self.send_request('pause "1"') self.send_request('pause "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_pause_on(self): self.send_request('play "0"') self.send_request('pause "1"') self.assertEqual(PAUSED, self.core.playback.state.get()) self.assertInResponse('OK') def test_pause_toggle(self): self.send_request('play "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') with deprecation.ignore('mpd.protocol.playback.pause:state_arg'): self.send_request('pause') self.assertEqual(PAUSED, self.core.playback.state.get()) self.assertInResponse('OK') self.send_request('pause') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_play_without_pos(self): self.send_request('play') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_play_with_pos(self): self.send_request('play "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_play_with_pos_without_quotes(self): self.send_request('play 0') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_play_with_pos_out_of_bounds(self): self.core.tracklist.clear().get() self.send_request('play "0"') self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertInResponse('ACK [2@0] {play} Bad song index') def test_play_minus_one_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.core.playback.current_track.get(), None) self.send_request('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertEqual( 'dummy:a', self.core.playback.current_track.get().uri) self.assertInResponse('OK') def test_play_minus_one_plays_current_track_if_current_track_is_set(self): self.assertEqual(self.core.playback.current_track.get(), None) self.core.playback.play() self.core.playback.next() self.core.playback.stop().get() self.assertNotEqual(self.core.playback.current_track.get(), None) self.send_request('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertEqual( 'dummy:b', self.core.playback.current_track.get().uri) self.assertInResponse('OK') def test_play_minus_one_on_empty_playlist_does_not_ack(self): self.core.tracklist.clear() self.send_request('play "-1"') self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertEqual(None, self.core.playback.current_track.get()) self.assertInResponse('OK') def test_play_minus_is_ignored_if_playing(self): self.core.playback.play().get() self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertEqual(PLAYING, self.core.playback.state.get()) self.send_request('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') def test_play_minus_one_resumes_if_paused(self): self.core.playback.play().get() self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertEqual(PLAYING, self.core.playback.state.get()) self.core.playback.pause() self.assertEqual(PAUSED, self.core.playback.state.get()) self.send_request('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') def test_playid(self): self.send_request('playid "1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_playid_without_quotes(self): self.send_request('playid 1') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_playid_minus_1_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.core.playback.current_track.get(), None) self.send_request('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertEqual( 'dummy:a', self.core.playback.current_track.get().uri) self.assertInResponse('OK') def test_playid_minus_1_plays_current_track_if_current_track_is_set(self): self.assertEqual(self.core.playback.current_track.get(), None) self.core.playback.play().get() self.core.playback.next().get() self.core.playback.stop() self.assertNotEqual(None, self.core.playback.current_track.get()) self.send_request('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertEqual( 'dummy:b', self.core.playback.current_track.get().uri) self.assertInResponse('OK') def test_playid_minus_one_on_empty_playlist_does_not_ack(self): self.core.tracklist.clear() self.send_request('playid "-1"') self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertEqual(None, self.core.playback.current_track.get()) self.assertInResponse('OK') def test_playid_minus_is_ignored_if_playing(self): self.core.playback.play().get() self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertEqual(PLAYING, self.core.playback.state.get()) self.send_request('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') def test_playid_minus_one_resumes_if_paused(self): self.core.playback.play().get() self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertEqual(PLAYING, self.core.playback.state.get()) self.core.playback.pause() self.assertEqual(PAUSED, self.core.playback.state.get()) self.send_request('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') def test_playid_which_does_not_exist(self): self.send_request('playid "12345"') self.assertInResponse('ACK [50@0] {playid} No such song') def test_previous(self): self.core.tracklist.clear().get() self.send_request('previous') self.assertInResponse('OK') def test_seek_in_current_track(self): self.core.playback.play() self.send_request('seek "0" "30"') current_track = self.core.playback.current_track.get() self.assertEqual(current_track, self.tracks[0]) self.assertGreaterEqual(self.core.playback.time_position, 30000) self.assertInResponse('OK') def test_seek_in_another_track(self): self.core.playback.play() current_track = self.core.playback.current_track.get() self.assertNotEqual(current_track, self.tracks[1]) self.send_request('seek "1" "30"') current_track = self.core.playback.current_track.get() self.assertEqual(current_track, self.tracks[1]) self.assertInResponse('OK') def test_seek_without_quotes(self): self.core.playback.play() self.send_request('seek 0 30') self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') def test_seekid_in_current_track(self): self.core.playback.play() self.send_request('seekid "1" "30"') current_track = self.core.playback.current_track.get() self.assertEqual(current_track, self.tracks[0]) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') def test_seekid_in_another_track(self): self.core.playback.play() self.send_request('seekid "2" "30"') current_tl_track = self.core.playback.current_tl_track.get() self.assertEqual(current_tl_track.tlid, 2) self.assertEqual(current_tl_track.track, self.tracks[1]) self.assertInResponse('OK') def test_seekcur_absolute_value(self): self.core.playback.play().get() self.send_request('seekcur "30"') self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') def test_seekcur_positive_diff(self): self.core.playback.play().get() self.core.playback.seek(10000) self.assertGreaterEqual(self.core.playback.time_position.get(), 10000) self.send_request('seekcur "+20"') self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') def test_seekcur_negative_diff(self): self.core.playback.play().get() self.core.playback.seek(30000) self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) self.send_request('seekcur "-20"') self.assertLessEqual(self.core.playback.time_position.get(), 15000) self.assertInResponse('OK') def test_stop(self): self.core.tracklist.clear().get() self.send_request('stop') self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertInResponse('OK') class VolumeTest(protocol.BaseTestCase): def test_setvol_below_min(self): self.send_request('setvol "-10"') self.assertEqual(0, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_min(self): self.send_request('setvol "0"') self.assertEqual(0, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_middle(self): self.send_request('setvol "50"') self.assertEqual(50, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_max(self): self.send_request('setvol "100"') self.assertEqual(100, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_above_max(self): self.send_request('setvol "110"') self.assertEqual(100, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_plus_is_ignored(self): self.send_request('setvol "+10"') self.assertEqual(10, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_without_quotes(self): self.send_request('setvol 50') self.assertEqual(50, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_volume_plus(self): self.core.mixer.set_volume(50) self.send_request('volume +20') self.assertEqual(70, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_volume_minus(self): self.core.mixer.set_volume(50) self.send_request('volume -20') self.assertEqual(30, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_volume_less_than_minus_100(self): self.core.mixer.set_volume(50) self.send_request('volume -110') self.assertEqual(50, self.core.mixer.get_volume().get()) self.assertInResponse('ACK [2@0] {volume} Invalid volume value') def test_volume_more_than_plus_100(self): self.core.mixer.set_volume(50) self.send_request('volume +110') self.assertEqual(50, self.core.mixer.get_volume().get()) self.assertInResponse('ACK [2@0] {volume} Invalid volume value') class VolumeWithNoMixerTest(protocol.BaseTestCase): enable_mixer = False def test_setvol_without_mixer_fails(self): self.send_request('setvol "100"') self.assertInResponse('ACK [52@0] {setvol} problems setting volume') def test_volume_without_mixer_failes(self): self.send_request('volume +100') self.assertInResponse('ACK [52@0] {volume} problems setting volume') Mopidy-2.0.0/tests/mpd/protocol/test_regression.py0000664000175000017500000001742612660436420022551 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import random import mock from mopidy.models import Playlist, Ref, Track from mopidy.mpd.protocol import stored_playlists from tests.mpd import protocol class IssueGH17RegressionTest(protocol.BaseTestCase): """ The issue: http://github.com/mopidy/mopidy/issues/17 How to reproduce: - Play a playlist where one track cannot be played - Turn on random mode - Press next until you get to the unplayable track """ def test(self): tracks = [ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:error'), Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f'), ] self.audio.trigger_fake_playback_failure('dummy:error') self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=[t.uri for t in tracks]).get() random.seed(1) # Playlist order: abcfde self.send_request('play') self.assertEqual( 'dummy:a', self.core.playback.current_track.get().uri) self.send_request('random "1"') self.send_request('next') self.assertEqual( 'dummy:b', self.core.playback.current_track.get().uri) self.send_request('next') # Should now be at track 'c', but playback fails and it skips ahead self.assertEqual( 'dummy:f', self.core.playback.current_track.get().uri) self.send_request('next') self.assertEqual( 'dummy:d', self.core.playback.current_track.get().uri) self.send_request('next') self.assertEqual( 'dummy:e', self.core.playback.current_track.get().uri) class IssueGH18RegressionTest(protocol.BaseTestCase): """ The issue: http://github.com/mopidy/mopidy/issues/18 How to reproduce: Play, random on, next, random off, next, next. At this point it gives the same song over and over. """ def test(self): tracks = [ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f'), ] self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=[t.uri for t in tracks]).get() random.seed(1) self.send_request('play') self.send_request('random "1"') self.send_request('next') self.send_request('random "0"') self.send_request('next') self.send_request('next') tl_track_1 = self.core.playback.current_tl_track.get() self.send_request('next') tl_track_2 = self.core.playback.current_tl_track.get() self.send_request('next') tl_track_3 = self.core.playback.current_tl_track.get() self.assertNotEqual(tl_track_1, tl_track_2) self.assertNotEqual(tl_track_2, tl_track_3) class IssueGH22RegressionTest(protocol.BaseTestCase): """ The issue: http://github.com/mopidy/mopidy/issues/22 How to reproduce: Play, random on, remove all tracks from the current playlist (as in "delete" each one, not "clear"). Alternatively: Play, random on, remove a random track from the current playlist, press next until it crashes. """ def test(self): tracks = [ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f'), ] self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=[t.uri for t in tracks]).get() random.seed(1) self.send_request('play') self.send_request('random "1"') self.send_request('deleteid "1"') self.send_request('deleteid "2"') self.send_request('deleteid "3"') self.send_request('deleteid "4"') self.send_request('deleteid "5"') self.send_request('deleteid "6"') self.send_request('status') class IssueGH69RegressionTest(protocol.BaseTestCase): """ The issue: https://github.com/mopidy/mopidy/issues/69 How to reproduce: Play track, stop, clear current playlist, load a new playlist, status. The status response now contains "song: None". """ def test(self): self.core.playlists.create('foo') tracks = [ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f'), ] self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=[t.uri for t in tracks]).get() self.send_request('play') self.send_request('stop') self.send_request('clear') self.send_request('load "foo"') self.assertNotInResponse('song: None') class IssueGH113RegressionTest(protocol.BaseTestCase): """ The issue: https://github.com/mopidy/mopidy/issues/113 How to reproduce: - Have a playlist with a name contining backslashes, like "all lart spotify:track:\w\{22\} pastes". - Try to load the playlist with the backslashes in the playlist name escaped. """ def test(self): self.core.playlists.create( u'all lart spotify:track:\w\{22\} pastes') self.send_request('lsinfo "/"') self.assertInResponse( u'playlist: all lart spotify:track:\w\{22\} pastes') self.send_request( r'listplaylistinfo "all lart spotify:track:\\w\\{22\\} pastes"') self.assertInResponse('OK') class IssueGH137RegressionTest(protocol.BaseTestCase): """ The issue: https://github.com/mopidy/mopidy/issues/137 How to reproduce: - Send "list" query with mismatching quotes """ def test(self): self.send_request( u'list Date Artist "Anita Ward" ' u'Album "This Is Remixed Hits - Mashups & Rare 12" Mixes"') self.assertInResponse('ACK [2@0] {list} Invalid unquoted character') class IssueGH1120RegressionTest(protocol.BaseTestCase): """ The issue: https://github.com/mopidy/mopidy/issues/1120 How to reproduce: - A playlist must be in both browse results and playlists - Call for instance ``lsinfo "/"`` to populate the cache with the playlist name from the playlist backend. - Call ``lsinfo "/dummy"`` to override the playlist name with the browse name. - Call ``lsinfo "/"`` and we now have an invalid name with ``/`` in it. """ @mock.patch.object(stored_playlists, '_get_last_modified') def test(self, last_modified_mock): last_modified_mock.return_value = '2015-08-05T22:51:06Z' self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.playlist(name='Top 100 tracks', uri='dummy:/1')], } self.backend.playlists.set_dummy_playlists([ Playlist(name='Top 100 tracks', uri='dummy:/1'), ]) response1 = self.send_request('lsinfo "/"') self.send_request('lsinfo "/dummy"') response2 = self.send_request('lsinfo "/"') self.assertEqual(response1, response2) class IssueGH1348RegressionTest(protocol.BaseTestCase): """ The issue: http://github.com/mopidy/mopidy/issues/1348 """ def test(self): self.backend.library.dummy_library = [Track(uri='dummy:a')] # Create a dummy playlist and trigger population of mapping self.send_request('playlistadd "testing1" "dummy:a"') self.send_request('listplaylists') # Create an other playlist which isn't in the map self.send_request('playlistadd "testing2" "dummy:a"') self.assertEqual(['OK'], self.send_request('rm "testing2"')) playlists = self.backend.playlists.as_list().get() self.assertEqual(['testing1'], [ref.name for ref in playlists]) Mopidy-2.0.0/tests/mpd/protocol/test_stickers.py0000664000175000017500000000251212575004517022211 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from tests.mpd import protocol class StickersHandlerTest(protocol.BaseTestCase): def test_sticker_get(self): self.send_request( 'sticker get "song" "file:///dev/urandom" "a_name"') self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_set(self): self.send_request( 'sticker set "song" "file:///dev/urandom" "a_name" "a_value"') self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_delete_with_name(self): self.send_request( 'sticker delete "song" "file:///dev/urandom" "a_name"') self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_delete_without_name(self): self.send_request( 'sticker delete "song" "file:///dev/urandom"') self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_list(self): self.send_request( 'sticker list "song" "file:///dev/urandom"') self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_find(self): self.send_request( 'sticker find "song" "file:///dev/urandom" "a_name"') self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') Mopidy-2.0.0/tests/mpd/protocol/test_command_list.py0000664000175000017500000000517412575004517023042 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from tests.mpd import protocol class CommandListsTest(protocol.BaseTestCase): def test_command_list_begin(self): response = self.send_request('command_list_begin') self.assertEqual([], response) def test_command_list_end(self): self.send_request('command_list_begin') self.send_request('command_list_end') self.assertInResponse('OK') def test_command_list_end_without_start_first_is_an_unknown_command(self): self.send_request('command_list_end') self.assertEqualResponse( 'ACK [5@0] {} unknown command "command_list_end"') def test_command_list_with_ping(self): self.send_request('command_list_begin') self.assertTrue(self.dispatcher.command_list_receiving) self.assertFalse(self.dispatcher.command_list_ok) self.assertEqual([], self.dispatcher.command_list) self.send_request('ping') self.assertIn('ping', self.dispatcher.command_list) self.send_request('command_list_end') self.assertInResponse('OK') self.assertFalse(self.dispatcher.command_list_receiving) self.assertFalse(self.dispatcher.command_list_ok) self.assertEqual([], self.dispatcher.command_list) def test_command_list_with_error_returns_ack_with_correct_index(self): self.send_request('command_list_begin') self.send_request('play') # Known command self.send_request('paly') # Unknown command self.send_request('command_list_end') self.assertEqualResponse('ACK [5@1] {} unknown command "paly"') def test_command_list_ok_begin(self): response = self.send_request('command_list_ok_begin') self.assertEqual([], response) def test_command_list_ok_with_ping(self): self.send_request('command_list_ok_begin') self.assertTrue(self.dispatcher.command_list_receiving) self.assertTrue(self.dispatcher.command_list_ok) self.assertEqual([], self.dispatcher.command_list) self.send_request('ping') self.assertIn('ping', self.dispatcher.command_list) self.send_request('command_list_end') self.assertInResponse('list_OK') self.assertInResponse('OK') self.assertFalse(self.dispatcher.command_list_receiving) self.assertFalse(self.dispatcher.command_list_ok) self.assertEqual([], self.dispatcher.command_list) # FIXME this should also include the special handling of idle within a # command list. That is that once a idle/noidle command is found inside a # commad list, the rest of the list seems to be ignored. Mopidy-2.0.0/tests/mpd/protocol/test_audio_output.py0000664000175000017500000001073212505224626023104 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from tests.mpd import protocol class AudioOutputHandlerTest(protocol.BaseTestCase): def test_enableoutput(self): self.core.mixer.set_mute(False) self.send_request('enableoutput "0"') self.assertInResponse('OK') self.assertEqual(self.core.mixer.get_mute().get(), True) def test_enableoutput_unknown_outputid(self): self.send_request('enableoutput "7"') self.assertInResponse('ACK [50@0] {enableoutput} No such audio output') def test_disableoutput(self): self.core.mixer.set_mute(True) self.send_request('disableoutput "0"') self.assertInResponse('OK') self.assertEqual(self.core.mixer.get_mute().get(), False) def test_disableoutput_unknown_outputid(self): self.send_request('disableoutput "7"') self.assertInResponse( 'ACK [50@0] {disableoutput} No such audio output') def test_outputs_when_unmuted(self): self.core.mixer.set_mute(False) self.send_request('outputs') self.assertInResponse('outputid: 0') self.assertInResponse('outputname: Mute') self.assertInResponse('outputenabled: 0') self.assertInResponse('OK') def test_outputs_when_muted(self): self.core.mixer.set_mute(True) self.send_request('outputs') self.assertInResponse('outputid: 0') self.assertInResponse('outputname: Mute') self.assertInResponse('outputenabled: 1') self.assertInResponse('OK') def test_outputs_toggleoutput(self): self.core.mixer.set_mute(False) self.send_request('toggleoutput "0"') self.send_request('outputs') self.assertInResponse('outputid: 0') self.assertInResponse('outputname: Mute') self.assertInResponse('outputenabled: 1') self.assertInResponse('OK') self.send_request('toggleoutput "0"') self.send_request('outputs') self.assertInResponse('outputid: 0') self.assertInResponse('outputname: Mute') self.assertInResponse('outputenabled: 0') self.assertInResponse('OK') self.send_request('toggleoutput "0"') self.send_request('outputs') self.assertInResponse('outputid: 0') self.assertInResponse('outputname: Mute') self.assertInResponse('outputenabled: 1') self.assertInResponse('OK') def test_outputs_toggleoutput_unknown_outputid(self): self.send_request('toggleoutput "7"') self.assertInResponse( 'ACK [50@0] {toggleoutput} No such audio output') class AudioOutputHandlerNoneMixerTest(protocol.BaseTestCase): enable_mixer = False def test_enableoutput(self): self.assertEqual(self.core.mixer.get_mute().get(), None) self.send_request('enableoutput "0"') self.assertInResponse( 'ACK [52@0] {enableoutput} problems enabling output') self.assertEqual(self.core.mixer.get_mute().get(), None) def test_disableoutput(self): self.assertEqual(self.core.mixer.get_mute().get(), None) self.send_request('disableoutput "0"') self.assertInResponse( 'ACK [52@0] {disableoutput} problems disabling output') self.assertEqual(self.core.mixer.get_mute().get(), None) def test_outputs_when_unmuted(self): self.core.mixer.set_mute(False) self.send_request('outputs') self.assertInResponse('outputid: 0') self.assertInResponse('outputname: Mute') self.assertInResponse('outputenabled: 0') self.assertInResponse('OK') def test_outputs_when_muted(self): self.core.mixer.set_mute(True) self.send_request('outputs') self.assertInResponse('outputid: 0') self.assertInResponse('outputname: Mute') self.assertInResponse('outputenabled: 0') self.assertInResponse('OK') def test_outputs_toggleoutput(self): self.core.mixer.set_mute(False) self.send_request('toggleoutput "0"') self.send_request('outputs') self.assertInResponse('outputid: 0') self.assertInResponse('outputname: Mute') self.assertInResponse('outputenabled: 0') self.assertInResponse('OK') self.send_request('toggleoutput "0"') self.send_request('outputs') self.assertInResponse('outputid: 0') self.assertInResponse('outputname: Mute') self.assertInResponse('outputenabled: 0') self.assertInResponse('OK') Mopidy-2.0.0/tests/dummy_audio.py0000664000175000017500000000741512660436420017222 0ustar jodaljodal00000000000000"""A dummy audio actor for use in tests. This class implements the audio API in the simplest way possible. It is used in tests of the core and backends. """ from __future__ import absolute_import, unicode_literals import pykka from mopidy import audio def create_proxy(config=None, mixer=None): return DummyAudio.start(config, mixer).proxy() # TODO: reset position on track change? class DummyAudio(pykka.ThreadingActor): def __init__(self, config=None, mixer=None): super(DummyAudio, self).__init__() self.state = audio.PlaybackState.STOPPED self._volume = 0 self._position = 0 self._callback = None self._uri = None self._stream_changed = False self._tags = {} self._bad_uris = set() def set_uri(self, uri): assert self._uri is None, 'prepare change not called before set' self._tags = {} self._uri = uri self._stream_changed = True def set_appsrc(self, *args, **kwargs): pass def emit_data(self, buffer_): pass def emit_end_of_stream(self): pass def get_position(self): return self._position def set_position(self, position): self._position = position audio.AudioListener.send('position_changed', position=position) return True def start_playback(self): return self._change_state(audio.PlaybackState.PLAYING) def pause_playback(self): return self._change_state(audio.PlaybackState.PAUSED) def prepare_change(self): self._uri = None return True def stop_playback(self): return self._change_state(audio.PlaybackState.STOPPED) def get_volume(self): return self._volume def set_volume(self, volume): self._volume = volume return True 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 def enable_sync_handler(self): pass def wait_for_state_change(self): pass def _change_state(self, new_state): if not self._uri: return False if new_state == audio.PlaybackState.STOPPED and self._uri: self._stream_changed = True self._uri = None if self._uri is not None: audio.AudioListener.send('position_changed', position=0) if self._stream_changed: self._stream_changed = False audio.AudioListener.send('stream_changed', uri=self._uri) old_state, self.state = self.state, new_state audio.AudioListener.send( 'state_changed', old_state=old_state, new_state=new_state, target_state=None) if new_state == audio.PlaybackState.PLAYING: self._tags['audio-codec'] = [u'fake info...'] audio.AudioListener.send('tags_changed', tags=['audio-codec']) return self._uri not in self._bad_uris def trigger_fake_playback_failure(self, uri): self._bad_uris.add(uri) def trigger_fake_tags_changed(self, tags): self._tags.update(tags) audio.AudioListener.send('tags_changed', tags=self._tags.keys()) def get_about_to_finish_callback(self): # This needs to be called from outside the actor or we lock up. def wrapper(): if self._callback: self.prepare_change() self._callback() if not self._uri or not self._callback: self._tags = {} audio.AudioListener.send('reached_end_of_stream') else: audio.AudioListener.send('position_changed', position=0) audio.AudioListener.send('stream_changed', uri=self._uri) return wrapper Mopidy-2.0.0/tests/test_ext.py0000664000175000017500000002226112600333733016536 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import os import mock import pkg_resources import pytest from mopidy import config, exceptions, ext from tests import IsA, any_unicode class DummyExtension(ext.Extension): dist_name = 'Mopidy-Foobar' ext_name = 'foobar' version = '1.2.3' def get_default_config(self): return '[foobar]\nenabled = true' any_testextension = IsA(DummyExtension) class TestExtension(object): @pytest.fixture def extension(self): return ext.Extension() def test_dist_name_is_none(self, extension): assert extension.dist_name is None def test_ext_name_is_none(self, extension): assert extension.ext_name is None def test_version_is_none(self, extension): assert extension.version is None def test_get_default_config_raises_not_implemented(self, extension): with pytest.raises(NotImplementedError): extension.get_default_config() def test_get_config_schema_returns_extension_schema(self, extension): schema = extension.get_config_schema() assert isinstance(schema['enabled'], config.Boolean) def test_validate_environment_does_nothing_by_default(self, extension): assert extension.validate_environment() is None def test_setup_raises_not_implemented(self, extension): with pytest.raises(NotImplementedError): extension.setup(None) def test_get_cache_dir_raises_assertion_error(self, extension): config = {'core': {'cache_dir': '/tmp'}} with pytest.raises(AssertionError): # ext_name not set ext.Extension.get_cache_dir(config) def test_get_config_dir_raises_assertion_error(self, extension): config = {'core': {'config_dir': '/tmp'}} with pytest.raises(AssertionError): # ext_name not set ext.Extension.get_config_dir(config) def test_get_data_dir_raises_assertion_error(self, extension): config = {'core': {'data_dir': '/tmp'}} with pytest.raises(AssertionError): # ext_name not set ext.Extension.get_data_dir(config) class TestLoadExtensions(object): @pytest.yield_fixture def iter_entry_points_mock(self, request): patcher = mock.patch('pkg_resources.iter_entry_points') iter_entry_points = patcher.start() iter_entry_points.return_value = [] yield iter_entry_points patcher.stop() def test_no_extensions(self, iter_entry_points_mock): iter_entry_points_mock.return_value = [] assert ext.load_extensions() == [] def test_load_extensions(self, iter_entry_points_mock): mock_entry_point = mock.Mock() mock_entry_point.load.return_value = DummyExtension iter_entry_points_mock.return_value = [mock_entry_point] expected = ext.ExtensionData( any_testextension, mock_entry_point, IsA(config.ConfigSchema), any_unicode, None) assert ext.load_extensions() == [expected] def test_gets_wrong_class(self, iter_entry_points_mock): class WrongClass(object): pass mock_entry_point = mock.Mock() mock_entry_point.load.return_value = WrongClass iter_entry_points_mock.return_value = [mock_entry_point] assert ext.load_extensions() == [] def test_gets_instance(self, iter_entry_points_mock): mock_entry_point = mock.Mock() mock_entry_point.load.return_value = DummyExtension() iter_entry_points_mock.return_value = [mock_entry_point] assert ext.load_extensions() == [] def test_creating_instance_fails(self, iter_entry_points_mock): mock_extension = mock.Mock(spec=ext.Extension) mock_extension.side_effect = Exception mock_entry_point = mock.Mock() mock_entry_point.load.return_value = mock_extension iter_entry_points_mock.return_value = [mock_entry_point] assert ext.load_extensions() == [] def test_get_config_schema_fails(self, iter_entry_points_mock): mock_entry_point = mock.Mock() mock_entry_point.load.return_value = DummyExtension iter_entry_points_mock.return_value = [mock_entry_point] with mock.patch.object(DummyExtension, 'get_config_schema') as get: get.side_effect = Exception assert ext.load_extensions() == [] get.assert_called_once_with() def test_get_default_config_fails(self, iter_entry_points_mock): mock_entry_point = mock.Mock() mock_entry_point.load.return_value = DummyExtension iter_entry_points_mock.return_value = [mock_entry_point] with mock.patch.object(DummyExtension, 'get_default_config') as get: get.side_effect = Exception assert ext.load_extensions() == [] get.assert_called_once_with() def test_get_command_fails(self, iter_entry_points_mock): mock_entry_point = mock.Mock() mock_entry_point.load.return_value = DummyExtension iter_entry_points_mock.return_value = [mock_entry_point] with mock.patch.object(DummyExtension, 'get_command') as get: get.side_effect = Exception assert ext.load_extensions() == [] get.assert_called_once_with() class TestValidateExtensionData(object): @pytest.fixture def ext_data(self): extension = DummyExtension() entry_point = mock.Mock() entry_point.name = extension.ext_name schema = extension.get_config_schema() defaults = extension.get_default_config() command = extension.get_command() return ext.ExtensionData( extension, entry_point, schema, defaults, command) def test_name_mismatch(self, ext_data): ext_data.entry_point.name = 'barfoo' assert not ext.validate_extension_data(ext_data) def test_distribution_not_found(self, ext_data): error = pkg_resources.DistributionNotFound ext_data.entry_point.require.side_effect = error assert not ext.validate_extension_data(ext_data) def test_version_conflict(self, ext_data): error = pkg_resources.VersionConflict ext_data.entry_point.require.side_effect = error assert not ext.validate_extension_data(ext_data) def test_entry_point_require_exception(self, ext_data): ext_data.entry_point.require.side_effect = Exception # Hope that entry points are well behaved, so exception will bubble. with pytest.raises(Exception): assert not ext.validate_extension_data(ext_data) def test_extenions_validate_environment_error(self, ext_data): extension = ext_data.extension with mock.patch.object(extension, 'validate_environment') as validate: validate.side_effect = exceptions.ExtensionError('error') assert not ext.validate_extension_data(ext_data) validate.assert_called_once_with() def test_extenions_validate_environment_exception(self, ext_data): extension = ext_data.extension with mock.patch.object(extension, 'validate_environment') as validate: validate.side_effect = Exception assert not ext.validate_extension_data(ext_data) validate.assert_called_once_with() def test_missing_schema(self, ext_data): ext_data = ext_data._replace(config_schema=None) assert not ext.validate_extension_data(ext_data) def test_schema_that_is_missing_enabled(self, ext_data): del ext_data.config_schema['enabled'] ext_data.config_schema['baz'] = config.String() assert not ext.validate_extension_data(ext_data) def test_schema_with_wrong_types(self, ext_data): ext_data.config_schema['enabled'] = 123 assert not ext.validate_extension_data(ext_data) def test_schema_with_invalid_type(self, ext_data): ext_data.config_schema['baz'] = 123 assert not ext.validate_extension_data(ext_data) def test_no_default_config(self, ext_data): ext_data = ext_data._replace(config_defaults=None) assert not ext.validate_extension_data(ext_data) def test_get_cache_dir(self, ext_data): core_cache_dir = '/tmp' config = {'core': {'cache_dir': core_cache_dir}} extension = ext_data.extension with mock.patch.object(ext.path, 'get_or_create_dir'): cache_dir = extension.get_cache_dir(config) expected = os.path.join(core_cache_dir, extension.ext_name) assert cache_dir == expected def test_get_config_dir(self, ext_data): core_config_dir = '/tmp' config = {'core': {'config_dir': core_config_dir}} extension = ext_data.extension with mock.patch.object(ext.path, 'get_or_create_dir'): config_dir = extension.get_config_dir(config) expected = os.path.join(core_config_dir, extension.ext_name) assert config_dir == expected def test_get_data_dir(self, ext_data): core_data_dir = '/tmp' config = {'core': {'data_dir': core_data_dir}} extension = ext_data.extension with mock.patch.object(ext.path, 'get_or_create_dir'): data_dir = extension.get_data_dir(config) expected = os.path.join(core_data_dir, extension.ext_name) assert data_dir == expected Mopidy-2.0.0/tests/http/0000775000175000017500000000000012660436443015311 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/http/__init__.py0000644000175000017500000000000012441116637017403 0ustar jodaljodal00000000000000Mopidy-2.0.0/tests/http/test_events.py0000664000175000017500000000153712575004517020232 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import json import unittest import mock from mopidy.http import actor @mock.patch('mopidy.http.handlers.WebSocketHandler.broadcast') class HttpEventsTest(unittest.TestCase): def test_track_playback_paused_is_broadcasted(self, broadcast): actor.on_event('track_playback_paused', foo='bar') self.assertDictEqual( json.loads(str(broadcast.call_args[0][0])), { 'event': 'track_playback_paused', 'foo': 'bar', }) def test_track_playback_resumed_is_broadcasted(self, broadcast): actor.on_event('track_playback_resumed', foo='bar') self.assertDictEqual( json.loads(str(broadcast.call_args[0][0])), { 'event': 'track_playback_resumed', 'foo': 'bar', }) Mopidy-2.0.0/tests/http/test_handlers.py0000664000175000017500000000620512575004517020523 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import os import mock import tornado.testing import tornado.web import tornado.websocket import mopidy from mopidy.http import handlers class StaticFileHandlerTest(tornado.testing.AsyncHTTPTestCase): def get_app(self): return tornado.web.Application([ (r'/(.*)', handlers.StaticFileHandler, { 'path': os.path.dirname(__file__), 'default_filename': 'test_handlers.py' }) ]) def test_static_handler(self): response = self.fetch('/test_handlers.py', method='GET') self.assertEqual(200, response.code) self.assertEqual( response.headers['X-Mopidy-Version'], mopidy.__version__) self.assertEqual( response.headers['Cache-Control'], 'no-cache') def test_static_default_filename(self): response = self.fetch('/', method='GET') self.assertEqual(200, response.code) self.assertEqual( response.headers['X-Mopidy-Version'], mopidy.__version__) self.assertEqual( response.headers['Cache-Control'], 'no-cache') # We aren't bothering with skipIf as then we would need to "backport" gen_test if hasattr(tornado.websocket, 'websocket_connect'): class WebSocketHandlerTest(tornado.testing.AsyncHTTPTestCase): def get_app(self): self.core = mock.Mock() return tornado.web.Application([ (r'/ws/?', handlers.WebSocketHandler, {'core': self.core}) ]) def connection(self): url = self.get_url('/ws').replace('http', 'ws') return tornado.websocket.websocket_connect(url, self.io_loop) @tornado.testing.gen_test def test_invalid_json_rpc_request_doesnt_crash_handler(self): # An uncaught error would result in no message, so this is just a # simplistic test to verify this. conn = yield self.connection() conn.write_message('invalid request') message = yield conn.read_message() self.assertTrue(message) @tornado.testing.gen_test def test_broadcast_makes_it_to_client(self): conn = yield self.connection() handlers.WebSocketHandler.broadcast('message') message = yield conn.read_message() self.assertEqual(message, 'message') @tornado.testing.gen_test def test_broadcast_to_client_that_just_closed_connection(self): conn = yield self.connection() conn.stream.close() handlers.WebSocketHandler.broadcast('message') @tornado.testing.gen_test def test_broadcast_to_client_without_ws_connection_present(self): yield self.connection() # Tornado checks for ws_connection and raises WebSocketClosedError # if it is missing, this test case simulates winning a race were # this has happened but we have not yet been removed from clients. for client in handlers.WebSocketHandler.clients: client.ws_connection = None handlers.WebSocketHandler.broadcast('message') Mopidy-2.0.0/tests/http/test_server.py0000664000175000017500000001771212575004517020236 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import os import mock import tornado.testing import tornado.wsgi import mopidy from mopidy.http import actor, handlers class HttpServerTest(tornado.testing.AsyncHTTPTestCase): def get_config(self): return { 'http': { 'hostname': '127.0.0.1', 'port': 6680, 'static_dir': None, 'zeroconf': '', } } def get_app(self): core = mock.Mock() core.get_version = mock.MagicMock(name='get_version') core.get_version.return_value = mopidy.__version__ testapps = [dict(name='testapp')] teststatics = [dict(name='teststatic')] apps = [{ 'name': 'mopidy', 'factory': handlers.make_mopidy_app_factory(testapps, teststatics), }] http_server = actor.HttpServer( config=self.get_config(), core=core, sockets=[], apps=apps, statics=[]) return tornado.web.Application(http_server._get_request_handlers()) class RootRedirectTest(HttpServerTest): def test_should_redirect_to_mopidy_app(self): response = self.fetch('/', method='GET', follow_redirects=False) self.assertEqual(response.code, 302) self.assertEqual(response.headers['Location'], '/mopidy/') class LegacyStaticDirAppTest(HttpServerTest): def get_config(self): config = super(LegacyStaticDirAppTest, self).get_config() config['http']['static_dir'] = os.path.dirname(__file__) return config def test_should_return_index(self): response = self.fetch('/', method='GET', follow_redirects=False) self.assertEqual(response.code, 404, 'No index.html in this dir') def test_should_return_static_files(self): response = self.fetch('/test_server.py', method='GET') self.assertIn( 'test_should_return_static_files', tornado.escape.to_unicode(response.body)) self.assertEqual( response.headers['X-Mopidy-Version'], mopidy.__version__) self.assertEqual(response.headers['Cache-Control'], 'no-cache') class MopidyAppTest(HttpServerTest): def test_should_return_index(self): response = self.fetch('/mopidy/', method='GET') body = tornado.escape.to_unicode(response.body) self.assertIn( 'This web server is a part of the Mopidy music server.', body) self.assertIn('testapp', body) self.assertIn('teststatic', body) self.assertEqual( response.headers['X-Mopidy-Version'], mopidy.__version__) self.assertEqual(response.headers['Cache-Control'], 'no-cache') def test_without_slash_should_redirect(self): response = self.fetch('/mopidy', method='GET', follow_redirects=False) self.assertEqual(response.code, 301) self.assertEqual(response.headers['Location'], '/mopidy/') def test_should_return_static_files(self): response = self.fetch('/mopidy/mopidy.js', method='GET') self.assertIn( 'function Mopidy', tornado.escape.to_unicode(response.body)) self.assertEqual( response.headers['X-Mopidy-Version'], mopidy.__version__) self.assertEqual(response.headers['Cache-Control'], 'no-cache') class MopidyWebSocketHandlerTest(HttpServerTest): def test_should_return_ws(self): response = self.fetch('/mopidy/ws', method='GET') self.assertEqual( 'Can "Upgrade" only to "WebSocket".', tornado.escape.to_unicode(response.body)) def test_should_return_ws_old(self): response = self.fetch('/mopidy/ws/', method='GET') self.assertEqual( 'Can "Upgrade" only to "WebSocket".', tornado.escape.to_unicode(response.body)) class MopidyRPCHandlerTest(HttpServerTest): def test_should_return_rpc_error(self): cmd = tornado.escape.json_encode({'action': 'get_version'}) response = self.fetch('/mopidy/rpc', method='POST', body=cmd) self.assertEqual( {'jsonrpc': '2.0', 'id': None, 'error': {'message': 'Invalid Request', 'code': -32600, 'data': '"jsonrpc" member must be included'}}, tornado.escape.json_decode(response.body)) def test_should_return_parse_error(self): cmd = '{[[[]}' response = self.fetch('/mopidy/rpc', method='POST', body=cmd) self.assertEqual( {'jsonrpc': '2.0', 'id': None, 'error': {'message': 'Parse error', 'code': -32700}}, tornado.escape.json_decode(response.body)) def test_should_return_mopidy_version(self): cmd = tornado.escape.json_encode({ 'method': 'core.get_version', 'params': [], 'jsonrpc': '2.0', 'id': 1, }) response = self.fetch('/mopidy/rpc', method='POST', body=cmd) self.assertEqual( {'jsonrpc': '2.0', 'id': 1, 'result': mopidy.__version__}, tornado.escape.json_decode(response.body)) def test_should_return_extra_headers(self): response = self.fetch('/mopidy/rpc', method='HEAD') self.assertIn('Accept', response.headers) self.assertIn('X-Mopidy-Version', response.headers) self.assertIn('Cache-Control', response.headers) self.assertIn('Content-Type', response.headers) class HttpServerWithStaticFilesTest(tornado.testing.AsyncHTTPTestCase): def get_app(self): config = { 'http': { 'hostname': '127.0.0.1', 'port': 6680, 'static_dir': None, 'zeroconf': '', } } core = mock.Mock() statics = [dict(name='static', path=os.path.dirname(__file__))] http_server = actor.HttpServer( config=config, core=core, sockets=[], apps=[], statics=statics) return tornado.web.Application(http_server._get_request_handlers()) def test_without_slash_should_redirect(self): response = self.fetch('/static', method='GET', follow_redirects=False) self.assertEqual(response.code, 301) self.assertEqual(response.headers['Location'], '/static/') def test_can_serve_static_files(self): response = self.fetch('/static/test_server.py', method='GET') self.assertEqual(200, response.code) self.assertEqual( response.headers['X-Mopidy-Version'], mopidy.__version__) self.assertEqual( response.headers['Cache-Control'], 'no-cache') def wsgi_app_factory(config, core): def wsgi_app(environ, start_response): status = '200 OK' response_headers = [('Content-type', 'text/plain')] start_response(status, response_headers) return ['Hello, world!\n'] return [ ('(.*)', tornado.web.FallbackHandler, { 'fallback': tornado.wsgi.WSGIContainer(wsgi_app), }), ] class HttpServerWithWsgiAppTest(tornado.testing.AsyncHTTPTestCase): def get_app(self): config = { 'http': { 'hostname': '127.0.0.1', 'port': 6680, 'static_dir': None, 'zeroconf': '', } } core = mock.Mock() apps = [{ 'name': 'wsgi', 'factory': wsgi_app_factory, }] http_server = actor.HttpServer( config=config, core=core, sockets=[], apps=apps, statics=[]) return tornado.web.Application(http_server._get_request_handlers()) def test_without_slash_should_redirect(self): response = self.fetch('/wsgi', method='GET', follow_redirects=False) self.assertEqual(response.code, 301) self.assertEqual(response.headers['Location'], '/wsgi/') def test_can_wrap_wsgi_apps(self): response = self.fetch('/wsgi/', method='GET') self.assertEqual(200, response.code) self.assertIn( 'Hello, world!', tornado.escape.to_unicode(response.body)) Mopidy-2.0.0/tests/core/0000775000175000017500000000000012660436443015262 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/core/test_playlists.py0000664000175000017500000004154312660436420020721 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest import mock from mopidy import backend, core from mopidy.internal import deprecation from mopidy.models import Playlist, Ref, Track class BasePlaylistsTest(unittest.TestCase): def setUp(self): # noqa: N802 self.plr1a = Ref.playlist(name='A', uri='dummy1:pl:a') self.plr1b = Ref.playlist(name='B', uri='dummy1:pl:b') self.plr2a = Ref.playlist(name='A', uri='dummy2:pl:a') self.plr2b = Ref.playlist(name='B', uri='dummy2:pl:b') self.pl1a = Playlist(name='A', tracks=[Track(uri='dummy1:t:a')]) self.pl1b = Playlist(name='B', tracks=[Track(uri='dummy1:t:b')]) self.pl2a = Playlist(name='A', tracks=[Track(uri='dummy2:t:a')]) self.pl2b = Playlist(name='B', tracks=[Track(uri='dummy2:t:b')]) self.sp1 = mock.Mock(spec=backend.PlaylistsProvider) self.sp1.as_list.return_value.get.return_value = [ self.plr1a, self.plr1b] self.sp1.lookup.return_value.get.side_effect = [self.pl1a, self.pl1b] self.sp2 = mock.Mock(spec=backend.PlaylistsProvider) self.sp2.as_list.return_value.get.return_value = [ self.plr2a, self.plr2b] self.sp2.lookup.return_value.get.side_effect = [self.pl2a, self.pl2b] self.backend1 = mock.Mock() self.backend1.actor_ref.actor_class.__name__ = 'Backend1' self.backend1.uri_schemes.get.return_value = ['dummy1'] self.backend1.playlists = self.sp1 self.backend2 = mock.Mock() self.backend2.actor_ref.actor_class.__name__ = 'Backend2' self.backend2.uri_schemes.get.return_value = ['dummy2'] self.backend2.playlists = self.sp2 # A backend without the optional playlists provider self.backend3 = mock.Mock() self.backend3.uri_schemes.get.return_value = ['dummy3'] self.backend3.has_playlists().get.return_value = False self.backend3.playlists = None self.core = core.Core(mixer=None, backends=[ self.backend3, self.backend1, self.backend2]) class PlaylistTest(BasePlaylistsTest): def test_as_list_combines_result_from_backends(self): result = self.core.playlists.as_list() self.assertIn(self.plr1a, result) self.assertIn(self.plr1b, result) self.assertIn(self.plr2a, result) self.assertIn(self.plr2b, result) def test_as_list_ignores_backends_that_dont_support_it(self): self.sp2.as_list.return_value.get.side_effect = NotImplementedError result = self.core.playlists.as_list() self.assertEqual(len(result), 2) self.assertIn(self.plr1a, result) self.assertIn(self.plr1b, result) def test_get_items_selects_the_matching_backend(self): ref = Ref.track() self.sp2.get_items.return_value.get.return_value = [ref] result = self.core.playlists.get_items('dummy2:pl:a') self.assertEqual([ref], result) self.assertFalse(self.sp1.get_items.called) self.sp2.get_items.assert_called_once_with('dummy2:pl:a') def test_get_items_with_unknown_uri_scheme_does_nothing(self): result = self.core.playlists.get_items('unknown:a') self.assertIsNone(result) self.assertFalse(self.sp1.delete.called) self.assertFalse(self.sp2.delete.called) def test_create_without_uri_scheme_uses_first_backend(self): playlist = Playlist() self.sp1.create.return_value.get.return_value = playlist result = self.core.playlists.create('foo') self.assertEqual(playlist, result) self.sp1.create.assert_called_once_with('foo') self.assertFalse(self.sp2.create.called) def test_create_without_uri_scheme_ignores_none_result(self): playlist = Playlist() self.sp1.create.return_value.get.return_value = None self.sp2.create.return_value.get.return_value = playlist result = self.core.playlists.create('foo') self.assertEqual(playlist, result) self.sp1.create.assert_called_once_with('foo') self.sp2.create.assert_called_once_with('foo') def test_create_without_uri_scheme_ignores_exception(self): playlist = Playlist() self.sp1.create.return_value.get.side_effect = Exception self.sp2.create.return_value.get.return_value = playlist result = self.core.playlists.create('foo') self.assertEqual(playlist, result) self.sp1.create.assert_called_once_with('foo') self.sp2.create.assert_called_once_with('foo') def test_create_with_uri_scheme_selects_the_matching_backend(self): playlist = Playlist() self.sp2.create.return_value.get.return_value = playlist result = self.core.playlists.create('foo', uri_scheme='dummy2') self.assertEqual(playlist, result) self.assertFalse(self.sp1.create.called) self.sp2.create.assert_called_once_with('foo') def test_create_with_unsupported_uri_scheme_uses_first_backend(self): playlist = Playlist() self.sp1.create.return_value.get.return_value = playlist result = self.core.playlists.create('foo', uri_scheme='dummy3') self.assertEqual(playlist, result) self.sp1.create.assert_called_once_with('foo') self.assertFalse(self.sp2.create.called) def test_delete_selects_the_dummy1_backend(self): self.core.playlists.delete('dummy1:a') self.sp1.delete.assert_called_once_with('dummy1:a') self.assertFalse(self.sp2.delete.called) def test_delete_selects_the_dummy2_backend(self): self.core.playlists.delete('dummy2:a') self.assertFalse(self.sp1.delete.called) self.sp2.delete.assert_called_once_with('dummy2:a') def test_delete_with_unknown_uri_scheme_does_nothing(self): self.core.playlists.delete('unknown:a') self.assertFalse(self.sp1.delete.called) self.assertFalse(self.sp2.delete.called) def test_delete_ignores_backend_without_playlist_support(self): self.core.playlists.delete('dummy3:a') self.assertFalse(self.sp1.delete.called) self.assertFalse(self.sp2.delete.called) def test_lookup_selects_the_dummy1_backend(self): self.core.playlists.lookup('dummy1:a') self.sp1.lookup.assert_called_once_with('dummy1:a') self.assertFalse(self.sp2.lookup.called) def test_lookup_selects_the_dummy2_backend(self): self.core.playlists.lookup('dummy2:a') self.assertFalse(self.sp1.lookup.called) self.sp2.lookup.assert_called_once_with('dummy2:a') def test_lookup_track_in_backend_without_playlists_fails(self): result = self.core.playlists.lookup('dummy3:a') self.assertIsNone(result) self.assertFalse(self.sp1.lookup.called) self.assertFalse(self.sp2.lookup.called) def test_refresh_without_uri_scheme_refreshes_all_backends(self): self.core.playlists.refresh() self.sp1.refresh.assert_called_once_with() self.sp2.refresh.assert_called_once_with() def test_refresh_with_uri_scheme_refreshes_matching_backend(self): self.core.playlists.refresh(uri_scheme='dummy2') self.assertFalse(self.sp1.refresh.called) self.sp2.refresh.assert_called_once_with() def test_refresh_with_unknown_uri_scheme_refreshes_nothing(self): self.core.playlists.refresh(uri_scheme='foobar') self.assertFalse(self.sp1.refresh.called) self.assertFalse(self.sp2.refresh.called) def test_refresh_ignores_backend_without_playlist_support(self): self.core.playlists.refresh(uri_scheme='dummy3') self.assertFalse(self.sp1.refresh.called) self.assertFalse(self.sp2.refresh.called) def test_save_selects_the_dummy1_backend(self): playlist = Playlist(uri='dummy1:a') self.sp1.save.return_value.get.return_value = playlist result = self.core.playlists.save(playlist) self.assertEqual(playlist, result) self.sp1.save.assert_called_once_with(playlist) self.assertFalse(self.sp2.save.called) def test_save_selects_the_dummy2_backend(self): playlist = Playlist(uri='dummy2:a') self.sp2.save.return_value.get.return_value = playlist result = self.core.playlists.save(playlist) self.assertEqual(playlist, result) self.assertFalse(self.sp1.save.called) self.sp2.save.assert_called_once_with(playlist) def test_save_does_nothing_if_playlist_uri_is_unset(self): result = self.core.playlists.save(Playlist()) self.assertIsNone(result) self.assertFalse(self.sp1.save.called) self.assertFalse(self.sp2.save.called) def test_save_does_nothing_if_playlist_uri_has_unknown_scheme(self): result = self.core.playlists.save(Playlist(uri='foobar:a')) self.assertIsNone(result) self.assertFalse(self.sp1.save.called) self.assertFalse(self.sp2.save.called) def test_save_ignores_backend_without_playlist_support(self): result = self.core.playlists.save(Playlist(uri='dummy3:a')) self.assertIsNone(result) self.assertFalse(self.sp1.save.called) self.assertFalse(self.sp2.save.called) def test_get_uri_schemes(self): result = self.core.playlists.get_uri_schemes() self.assertEquals(result, ['dummy1', 'dummy2']) class DeprecatedFilterPlaylistsTest(BasePlaylistsTest): def run(self, result=None): with deprecation.ignore(ids=['core.playlists.filter', 'core.playlists.get_playlists']): return super(DeprecatedFilterPlaylistsTest, self).run(result) def test_filter_returns_matching_playlists(self): result = self.core.playlists.filter({'name': 'A'}) self.assertEqual(2, len(result)) def test_filter_accepts_dict_instead_of_kwargs(self): result = self.core.playlists.filter({'name': 'A'}) self.assertEqual(2, len(result)) class DeprecatedGetPlaylistsTest(BasePlaylistsTest): def run(self, result=None): with deprecation.ignore('core.playlists.get_playlists'): return super(DeprecatedGetPlaylistsTest, self).run(result) def test_get_playlists_combines_result_from_backends(self): result = self.core.playlists.get_playlists() self.assertIn(self.pl1a, result) self.assertIn(self.pl1b, result) self.assertIn(self.pl2a, result) self.assertIn(self.pl2b, result) def test_get_playlists_includes_tracks_by_default(self): result = self.core.playlists.get_playlists() self.assertEqual(result[0].name, 'A') self.assertEqual(len(result[0].tracks), 1) self.assertEqual(result[1].name, 'B') self.assertEqual(len(result[1].tracks), 1) def test_get_playlist_can_strip_tracks_from_returned_playlists(self): result = self.core.playlists.get_playlists(include_tracks=False) self.assertEqual(result[0].name, 'A') self.assertEqual(len(result[0].tracks), 0) self.assertEqual(result[1].name, 'B') self.assertEqual(len(result[1].tracks), 0) class MockBackendCorePlaylistsBase(unittest.TestCase): def setUp(self): # noqa: N802 self.playlists = mock.Mock(spec=backend.PlaylistsProvider) self.backend = mock.Mock() self.backend.actor_ref.actor_class.__name__ = 'DummyBackend' self.backend.uri_schemes.get.return_value = ['dummy'] self.backend.playlists = self.playlists self.core = core.Core(mixer=None, backends=[self.backend]) @mock.patch('mopidy.core.playlists.logger') class AsListBadBackendsTest(MockBackendCorePlaylistsBase): def test_backend_raises_exception(self, logger): self.playlists.as_list.return_value.get.side_effect = Exception self.assertEqual([], self.core.playlists.as_list()) logger.exception.assert_called_with(mock.ANY, 'DummyBackend') def test_backend_returns_none(self, logger): self.playlists.as_list.return_value.get.return_value = None self.assertEqual([], self.core.playlists.as_list()) self.assertFalse(logger.error.called) def test_backend_returns_wrong_type(self, logger): self.playlists.as_list.return_value.get.return_value = 'abc' self.assertEqual([], self.core.playlists.as_list()) logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) @mock.patch('mopidy.core.playlists.logger') class GetItemsBadBackendsTest(MockBackendCorePlaylistsBase): def test_backend_raises_exception(self, logger): self.playlists.get_items.return_value.get.side_effect = Exception self.assertIsNone(self.core.playlists.get_items('dummy:/1')) logger.exception.assert_called_with(mock.ANY, 'DummyBackend') def test_backend_returns_none(self, logger): self.playlists.get_items.return_value.get.return_value = None self.assertIsNone(self.core.playlists.get_items('dummy:/1')) self.assertFalse(logger.error.called) def test_backend_returns_wrong_type(self, logger): self.playlists.get_items.return_value.get.return_value = 'abc' self.assertIsNone(self.core.playlists.get_items('dummy:/1')) logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) @mock.patch('mopidy.core.playlists.logger') class CreateBadBackendsTest(MockBackendCorePlaylistsBase): def test_backend_raises_exception(self, logger): self.playlists.create.return_value.get.side_effect = Exception self.assertIsNone(self.core.playlists.create('foobar')) logger.exception.assert_called_with(mock.ANY, 'DummyBackend') def test_backend_returns_none(self, logger): self.playlists.create.return_value.get.return_value = None self.assertIsNone(self.core.playlists.create('foobar')) self.assertFalse(logger.error.called) def test_backend_returns_wrong_type(self, logger): self.playlists.create.return_value.get.return_value = 'abc' self.assertIsNone(self.core.playlists.create('foobar')) logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) @mock.patch('mopidy.core.playlists.logger') class DeleteBadBackendsTest(MockBackendCorePlaylistsBase): def test_backend_raises_exception(self, logger): self.playlists.delete.return_value.get.side_effect = Exception self.assertIsNone(self.core.playlists.delete('dummy:/1')) logger.exception.assert_called_with(mock.ANY, 'DummyBackend') @mock.patch('mopidy.core.playlists.logger') class LookupBadBackendsTest(MockBackendCorePlaylistsBase): def test_backend_raises_exception(self, logger): self.playlists.lookup.return_value.get.side_effect = Exception self.assertIsNone(self.core.playlists.lookup('dummy:/1')) logger.exception.assert_called_with(mock.ANY, 'DummyBackend') def test_backend_returns_none(self, logger): self.playlists.lookup.return_value.get.return_value = None self.assertIsNone(self.core.playlists.lookup('dummy:/1')) self.assertFalse(logger.error.called) def test_backend_returns_wrong_type(self, logger): self.playlists.lookup.return_value.get.return_value = 'abc' self.assertIsNone(self.core.playlists.lookup('dummy:/1')) logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) @mock.patch('mopidy.core.playlists.logger') class RefreshBadBackendsTest(MockBackendCorePlaylistsBase): @mock.patch('mopidy.core.listener.CoreListener.send') def test_backend_raises_exception(self, send, logger): self.playlists.refresh.return_value.get.side_effect = Exception self.core.playlists.refresh() self.assertFalse(send.called) logger.exception.assert_called_with(mock.ANY, 'DummyBackend') @mock.patch('mopidy.core.listener.CoreListener.send') def test_backend_raises_exception_called_with_uri(self, send, logger): self.playlists.refresh.return_value.get.side_effect = Exception self.core.playlists.refresh('dummy') self.assertFalse(send.called) logger.exception.assert_called_with(mock.ANY, 'DummyBackend') @mock.patch('mopidy.core.playlists.logger') class SaveBadBackendsTest(MockBackendCorePlaylistsBase): def test_backend_raises_exception(self, logger): playlist = Playlist(uri='dummy:/1') self.playlists.save.return_value.get.side_effect = Exception self.assertIsNone(self.core.playlists.save(playlist)) logger.exception.assert_called_with(mock.ANY, 'DummyBackend') def test_backend_returns_none(self, logger): playlist = Playlist(uri='dummy:/1') self.playlists.save.return_value.get.return_value = None self.assertIsNone(self.core.playlists.save(playlist)) self.assertFalse(logger.error.called) def test_backend_returns_wrong_type(self, logger): playlist = Playlist(uri='dummy:/1') self.playlists.save.return_value.get.return_value = 'abc' self.assertIsNone(self.core.playlists.save(playlist)) logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) Mopidy-2.0.0/tests/core/test_actor.py0000664000175000017500000000253612575504731020012 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest import mock import pykka from mopidy.core import Core from mopidy.internal import versioning class CoreActorTest(unittest.TestCase): def setUp(self): # noqa: N802 self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] self.backend1.actor_ref.actor_class.__name__ = b'B1' self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2'] self.backend2.actor_ref.actor_class.__name__ = b'B2' self.core = Core(mixer=None, backends=[self.backend1, self.backend2]) def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def test_uri_schemes_has_uris_from_all_backends(self): result = self.core.uri_schemes self.assertIn('dummy1', result) self.assertIn('dummy2', result) def test_backends_with_colliding_uri_schemes_fails(self): self.backend2.uri_schemes.get.return_value = ['dummy1', 'dummy2'] self.assertRaisesRegexp( AssertionError, 'Cannot add URI scheme "dummy1" for B2, ' 'it is already handled by B1', Core, mixer=None, backends=[self.backend1, self.backend2]) def test_version(self): self.assertEqual(self.core.version, versioning.get_version()) Mopidy-2.0.0/tests/core/test_tracklist.py0000664000175000017500000001500412575004517020671 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest import mock from mopidy import backend, core from mopidy.internal import deprecation from mopidy.models import TlTrack, Track class TracklistTest(unittest.TestCase): def setUp(self): # noqa: config = { 'core': { 'max_tracklist_length': 10000, } } self.tracks = [ Track(uri='dummy1:a', name='foo'), Track(uri='dummy1:b', name='foo'), Track(uri='dummy1:c', name='bar'), ] def lookup(uri): future = mock.Mock() future.get.return_value = [t for t in self.tracks if t.uri == uri] return future self.backend = mock.Mock() self.backend.uri_schemes.get.return_value = ['dummy1'] self.library = mock.Mock(spec=backend.LibraryProvider) self.library.lookup.side_effect = lookup self.backend.library = self.library self.core = core.Core(config, mixer=None, backends=[self.backend]) self.tl_tracks = self.core.tracklist.add(uris=[ t.uri for t in self.tracks]) def test_add_by_uri_looks_up_uri_in_library(self): self.library.lookup.reset_mock() self.core.tracklist.clear() with deprecation.ignore('core.tracklist.add:uri_arg'): tl_tracks = self.core.tracklist.add(uris=['dummy1:a']) self.library.lookup.assert_called_once_with('dummy1:a') self.assertEqual(1, len(tl_tracks)) self.assertEqual(self.tracks[0], tl_tracks[0].track) self.assertEqual(tl_tracks, self.core.tracklist.tl_tracks[-1:]) def test_add_by_uris_looks_up_uris_in_library(self): self.library.lookup.reset_mock() self.core.tracklist.clear() tl_tracks = self.core.tracklist.add(uris=[t.uri for t in self.tracks]) self.library.lookup.assert_has_calls([ mock.call('dummy1:a'), mock.call('dummy1:b'), mock.call('dummy1:c'), ]) self.assertEqual(3, len(tl_tracks)) self.assertEqual(self.tracks[0], tl_tracks[0].track) self.assertEqual(self.tracks[1], tl_tracks[1].track) self.assertEqual(self.tracks[2], tl_tracks[2].track) self.assertEqual( tl_tracks, self.core.tracklist.tl_tracks[-len(tl_tracks):]) def test_remove_removes_tl_tracks_matching_query(self): tl_tracks = self.core.tracklist.remove({'name': ['foo']}) self.assertEqual(2, len(tl_tracks)) self.assertListEqual(self.tl_tracks[:2], tl_tracks) self.assertEqual(1, self.core.tracklist.length) self.assertListEqual(self.tl_tracks[2:], self.core.tracklist.tl_tracks) def test_remove_works_with_dict_instead_of_kwargs(self): tl_tracks = self.core.tracklist.remove({'name': ['foo']}) self.assertEqual(2, len(tl_tracks)) self.assertListEqual(self.tl_tracks[:2], tl_tracks) self.assertEqual(1, self.core.tracklist.length) self.assertListEqual(self.tl_tracks[2:], self.core.tracklist.tl_tracks) def test_filter_returns_tl_tracks_matching_query(self): tl_tracks = self.core.tracklist.filter({'name': ['foo']}) self.assertEqual(2, len(tl_tracks)) self.assertListEqual(self.tl_tracks[:2], tl_tracks) def test_filter_works_with_dict_instead_of_kwargs(self): tl_tracks = self.core.tracklist.filter({'name': ['foo']}) self.assertEqual(2, len(tl_tracks)) self.assertListEqual(self.tl_tracks[:2], tl_tracks) def test_filter_fails_if_values_isnt_iterable(self): with self.assertRaises(ValueError): self.core.tracklist.filter({'tlid': 3}) def test_filter_fails_if_values_is_a_string(self): with self.assertRaises(ValueError): self.core.tracklist.filter({'uri': 'a'}) # TODO Extract tracklist tests from the local backend tests class TracklistIndexTest(unittest.TestCase): def setUp(self): # noqa: N802 config = { 'core': { 'max_tracklist_length': 10000, } } self.tracks = [ Track(uri='dummy1:a', name='foo'), Track(uri='dummy1:b', name='foo'), Track(uri='dummy1:c', name='bar'), ] def lookup(uris): return {u: [t for t in self.tracks if t.uri == u] for u in uris} self.core = core.Core(config, mixer=None, backends=[]) self.core.library = mock.Mock(spec=core.LibraryController) self.core.library.lookup.side_effect = lookup self.core.playback = mock.Mock(spec=core.PlaybackController) self.tl_tracks = self.core.tracklist.add(uris=[ t.uri for t in self.tracks]) def test_index_returns_index_of_track(self): self.assertEqual(0, self.core.tracklist.index(self.tl_tracks[0])) self.assertEqual(1, self.core.tracklist.index(self.tl_tracks[1])) self.assertEqual(2, self.core.tracklist.index(self.tl_tracks[2])) def test_index_returns_none_if_item_not_found(self): tl_track = TlTrack(0, Track()) self.assertEqual(self.core.tracklist.index(tl_track), None) def test_index_returns_none_if_called_with_none(self): self.assertEqual(self.core.tracklist.index(None), None) def test_index_errors_out_for_invalid_tltrack(self): with self.assertRaises(ValueError): self.core.tracklist.index('abc') def test_index_return_index_when_called_with_tlids(self): tl_tracks = self.tl_tracks self.assertEqual(0, self.core.tracklist.index(tlid=tl_tracks[0].tlid)) self.assertEqual(1, self.core.tracklist.index(tlid=tl_tracks[1].tlid)) self.assertEqual(2, self.core.tracklist.index(tlid=tl_tracks[2].tlid)) def test_index_returns_none_if_tlid_not_found(self): self.assertEqual(self.core.tracklist.index(tlid=123), None) def test_index_returns_none_if_called_with_tlid_none(self): self.assertEqual(self.core.tracklist.index(tlid=None), None) def test_index_errors_out_for_invalid_tlid(self): with self.assertRaises(ValueError): self.core.tracklist.index(tlid=-1) def test_index_without_args_returns_current_tl_track_index(self): self.core.playback.get_current_tl_track.side_effect = [ None, self.tl_tracks[0], self.tl_tracks[1], self.tl_tracks[2]] self.assertEqual(None, self.core.tracklist.index()) self.assertEqual(0, self.core.tracklist.index()) self.assertEqual(1, self.core.tracklist.index()) self.assertEqual(2, self.core.tracklist.index()) Mopidy-2.0.0/tests/core/__init__.py0000664000175000017500000000007112505224626017365 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals Mopidy-2.0.0/tests/core/test_events.py0000664000175000017500000000743712575004517020210 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest import mock import pykka from mopidy import core from mopidy.internal import deprecation from mopidy.models import Track from tests import dummy_backend @mock.patch.object(core.CoreListener, 'send') class BackendEventsTest(unittest.TestCase): def setUp(self): # noqa: N802 config = { 'core': { 'max_tracklist_length': 10000, } } self.backend = dummy_backend.create_proxy() self.backend.library.dummy_library = [ Track(uri='dummy:a'), Track(uri='dummy:b')] with deprecation.ignore(): self.core = core.Core.start( config, backends=[self.backend]).proxy() def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def test_forwards_backend_playlists_loaded_event_to_frontends(self, send): self.core.playlists_loaded().get() self.assertEqual(send.call_args[0][0], 'playlists_loaded') def test_forwards_mixer_volume_changed_event_to_frontends(self, send): self.core.volume_changed(volume=60).get() self.assertEqual(send.call_args[0][0], 'volume_changed') self.assertEqual(send.call_args[1]['volume'], 60) def test_forwards_mixer_mute_changed_event_to_frontends(self, send): self.core.mute_changed(mute=True).get() self.assertEqual(send.call_args[0][0], 'mute_changed') self.assertEqual(send.call_args[1]['mute'], True) def test_tracklist_add_sends_tracklist_changed_event(self, send): self.core.tracklist.add(uris=['dummy:a']).get() self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_clear_sends_tracklist_changed_event(self, send): self.core.tracklist.add(uris=['dummy:a']).get() self.core.tracklist.clear().get() self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_move_sends_tracklist_changed_event(self, send): self.core.tracklist.add(uris=['dummy:a', 'dummy:b']).get() self.core.tracklist.move(0, 1, 1).get() self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_remove_sends_tracklist_changed_event(self, send): self.core.tracklist.add(uris=['dummy:a']).get() self.core.tracklist.remove({'uri': ['dummy:a']}).get() self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_shuffle_sends_tracklist_changed_event(self, send): self.core.tracklist.add(uris=['dummy:a', 'dummy:b']).get() self.core.tracklist.shuffle().get() self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_playlists_refresh_sends_playlists_loaded_event(self, send): self.core.playlists.refresh().get() self.assertEqual(send.call_args[0][0], 'playlists_loaded') def test_playlists_refresh_uri_sends_playlists_loaded_event(self, send): self.core.playlists.refresh(uri_scheme='dummy').get() self.assertEqual(send.call_args[0][0], 'playlists_loaded') def test_playlists_create_sends_playlist_changed_event(self, send): self.core.playlists.create('foo').get() self.assertEqual(send.call_args[0][0], 'playlist_changed') def test_playlists_delete_sends_playlist_deleted_event(self, send): playlist = self.core.playlists.create('foo').get() self.core.playlists.delete(playlist.uri).get() self.assertEqual(send.call_args[0][0], 'playlist_deleted') def test_playlists_save_sends_playlist_changed_event(self, send): playlist = self.core.playlists.create('foo').get() playlist = playlist.replace(name='bar') self.core.playlists.save(playlist).get() self.assertEqual(send.call_args[0][0], 'playlist_changed') Mopidy-2.0.0/tests/core/test_library.py0000664000175000017500000006511612653464377020361 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest import mock from mopidy import backend, core from mopidy.internal import deprecation from mopidy.models import Image, Ref, SearchResult, Track class BaseCoreLibraryTest(unittest.TestCase): def setUp(self): # noqa: N802 dummy1_root = Ref.directory(uri='dummy1:directory', name='dummy1') self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] self.backend1.actor_ref.actor_class.__name__ = 'DummyBackend1' self.library1 = mock.Mock(spec=backend.LibraryProvider) self.library1.get_images.return_value.get.return_value = {} self.library1.root_directory.get.return_value = dummy1_root self.backend1.library = self.library1 self.backend1.has_playlists.return_value.get.return_value = False dummy2_root = Ref.directory(uri='dummy2:directory', name='dummy2') self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2', 'du2'] self.backend2.actor_ref.actor_class.__name__ = 'DummyBackend2' self.library2 = mock.Mock(spec=backend.LibraryProvider) self.library2.get_images.return_value.get.return_value = {} self.library2.root_directory.get.return_value = dummy2_root self.backend2.library = self.library2 self.backend2.has_playlists.return_value.get.return_value = False # A backend without the optional library provider self.backend3 = mock.Mock() self.backend3.uri_schemes.get.return_value = ['dummy3'] self.backend3.actor_ref.actor_class.__name__ = 'DummyBackend3' self.backend3.has_library.return_value.get.return_value = False self.backend3.has_library_browse.return_value.get.return_value = False self.core = core.Core(mixer=None, backends=[ self.backend1, self.backend2, self.backend3]) # TODO: split by method class CoreLibraryTest(BaseCoreLibraryTest): def test_get_images_returns_empty_dict_for_no_uris(self): self.assertEqual({}, self.core.library.get_images([])) def test_get_images_returns_empty_result_for_unknown_uri(self): result = self.core.library.get_images(['dummy4:track']) self.assertEqual({'dummy4:track': tuple()}, result) def test_get_images_returns_empty_result_for_library_less_uri(self): result = self.core.library.get_images(['dummy3:track']) self.assertEqual({'dummy3:track': tuple()}, result) def test_get_images_maps_uri_to_backend(self): self.core.library.get_images(['dummy1:track']) self.library1.get_images.assert_called_once_with(['dummy1:track']) self.library2.get_images.assert_not_called() def test_get_images_maps_uri_to_backends(self): self.core.library.get_images(['dummy1:track', 'dummy2:track']) self.library1.get_images.assert_called_once_with(['dummy1:track']) self.library2.get_images.assert_called_once_with(['dummy2:track']) def test_get_images_returns_images(self): self.library1.get_images.return_value.get.return_value = { 'dummy1:track': [Image(uri='uri')]} result = self.core.library.get_images(['dummy1:track']) self.assertEqual({'dummy1:track': (Image(uri='uri'),)}, result) def test_get_images_merges_results(self): self.library1.get_images.return_value.get.return_value = { 'dummy1:track': [Image(uri='uri1')]} self.library2.get_images.return_value.get.return_value = { 'dummy2:track': [Image(uri='uri2')]} result = self.core.library.get_images( ['dummy1:track', 'dummy2:track', 'dummy3:track', 'dummy4:track']) expected = {'dummy1:track': (Image(uri='uri1'),), 'dummy2:track': (Image(uri='uri2'),), 'dummy3:track': tuple(), 'dummy4:track': tuple()} self.assertEqual(expected, result) def test_browse_root_returns_dir_ref_for_each_lib_with_root_dir_name(self): result = self.core.library.browse(None) self.assertEqual(result, [ Ref.directory(uri='dummy1:directory', name='dummy1'), Ref.directory(uri='dummy2:directory', name='dummy2'), ]) self.assertFalse(self.library1.browse.called) self.assertFalse(self.library2.browse.called) self.assertFalse(self.backend3.library.browse.called) def test_browse_empty_string_returns_nothing(self): result = self.core.library.browse('') self.assertEqual(result, []) self.assertFalse(self.library1.browse.called) self.assertFalse(self.library2.browse.called) def test_browse_dummy1_selects_dummy1_backend(self): self.library1.browse.return_value.get.return_value = [ Ref.directory(uri='dummy1:directory:/foo/bar', name='bar'), Ref.track(uri='dummy1:track:/foo/baz.mp3', name='Baz'), ] self.core.library.browse('dummy1:directory:/foo') self.assertEqual(self.library1.browse.call_count, 1) self.assertEqual(self.library2.browse.call_count, 0) self.library1.browse.assert_called_with('dummy1:directory:/foo') def test_browse_dummy2_selects_dummy2_backend(self): self.library2.browse.return_value.get.return_value = [ Ref.directory(uri='dummy2:directory:/bar/baz', name='quux'), Ref.track(uri='dummy2:track:/bar/foo.mp3', name='Baz'), ] self.core.library.browse('dummy2:directory:/bar') self.assertEqual(self.library1.browse.call_count, 0) self.assertEqual(self.library2.browse.call_count, 1) self.library2.browse.assert_called_with('dummy2:directory:/bar') def test_browse_dummy3_returns_nothing(self): result = self.core.library.browse('dummy3:test') self.assertEqual(result, []) self.assertEqual(self.library1.browse.call_count, 0) self.assertEqual(self.library2.browse.call_count, 0) def test_browse_dir_returns_subdirs_and_tracks(self): self.library1.browse.return_value.get.return_value = [ Ref.directory(uri='dummy1:directory:/foo/bar', name='Bar'), Ref.track(uri='dummy1:track:/foo/baz.mp3', name='Baz'), ] result = self.core.library.browse('dummy1:directory:/foo') self.assertEqual(result, [ Ref.directory(uri='dummy1:directory:/foo/bar', name='Bar'), Ref.track(uri='dummy1:track:/foo/baz.mp3', name='Baz'), ]) def test_lookup_fails_with_uri_and_uris_set(self): with self.assertRaises(ValueError): self.core.library.lookup('dummy1:a', ['dummy2:a']) def test_lookup_can_handle_uris(self): track1 = Track(uri='dummy1:a', name='abc') track2 = Track(uri='dummy2:a', name='def') self.library1.lookup().get.return_value = [track1] self.library2.lookup().get.return_value = [track2] result = self.core.library.lookup(uris=['dummy1:a', 'dummy2:a']) self.assertEqual(result, {'dummy2:a': [track2], 'dummy1:a': [track1]}) def test_lookup_uris_returns_empty_list_for_dummy3_track(self): result = self.core.library.lookup(uris=['dummy3:a']) self.assertEqual(result, {'dummy3:a': []}) self.assertFalse(self.library1.lookup.called) self.assertFalse(self.library2.lookup.called) def test_lookup_ignores_tracks_without_uri_set(self): track1 = Track(uri='dummy1:a', name='abc') track2 = Track() self.library1.lookup().get.return_value = [track1, track2] result = self.core.library.lookup(uris=['dummy1:a']) self.assertEqual(result, {'dummy1:a': [track1]}) def test_refresh_with_uri_selects_dummy1_backend(self): self.core.library.refresh('dummy1:a') self.library1.refresh.assert_called_once_with('dummy1:a') self.assertFalse(self.library2.refresh.called) def test_refresh_with_uri_selects_dummy2_backend(self): self.core.library.refresh('dummy2:a') self.assertFalse(self.library1.refresh.called) self.library2.refresh.assert_called_once_with('dummy2:a') def test_refresh_with_uri_fails_silently_for_dummy3_uri(self): self.core.library.refresh('dummy3:a') self.assertFalse(self.library1.refresh.called) self.assertFalse(self.library2.refresh.called) def test_refresh_without_uri_calls_all_backends(self): self.core.library.refresh() self.library1.refresh.return_value.get.assert_called_once_with() self.library2.refresh.return_value.get.assert_called_once_with() def test_search_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') result1 = SearchResult(tracks=[track1]) result2 = SearchResult(tracks=[track2]) self.library1.search.return_value.get.return_value = result1 self.library2.search.return_value.get.return_value = result2 result = self.core.library.search({'any': ['a']}) self.assertIn(result1, result) self.assertIn(result2, result) self.library1.search.assert_called_once_with( query={'any': ['a']}, uris=None, exact=False) self.library2.search.assert_called_once_with( query={'any': ['a']}, uris=None, exact=False) def test_search_with_uris_selects_dummy1_backend(self): self.core.library.search( query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo', 'dummy3:']) self.library1.search.assert_called_once_with( query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo'], exact=False) self.assertFalse(self.library2.search.called) def test_search_with_uris_selects_both_backends(self): self.core.library.search( query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo', 'dummy2:']) self.library1.search.assert_called_once_with( query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo'], exact=False) self.library2.search.assert_called_once_with( query={'any': ['a']}, uris=['dummy2:'], exact=False) def test_search_filters_out_none(self): track1 = Track(uri='dummy1:a') result1 = SearchResult(tracks=[track1]) self.library1.search.return_value.get.return_value = result1 self.library2.search.return_value.get.return_value = None result = self.core.library.search({'any': ['a']}) self.assertIn(result1, result) self.assertNotIn(None, result) self.library1.search.assert_called_once_with( query={'any': ['a']}, uris=None, exact=False) self.library2.search.assert_called_once_with( query={'any': ['a']}, uris=None, exact=False) def test_search_accepts_query_dict_instead_of_kwargs(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') result1 = SearchResult(tracks=[track1]) result2 = SearchResult(tracks=[track2]) self.library1.search.return_value.get.return_value = result1 self.library2.search.return_value.get.return_value = result2 result = self.core.library.search({'any': ['a']}) self.assertIn(result1, result) self.assertIn(result2, result) self.library1.search.assert_called_once_with( query={'any': ['a']}, uris=None, exact=False) self.library2.search.assert_called_once_with( query={'any': ['a']}, uris=None, exact=False) def test_search_normalises_bad_queries(self): self.core.library.search({'any': 'foobar'}) self.library1.search.assert_called_once_with( query={'any': ['foobar']}, uris=None, exact=False) class DeprecatedFindExactCoreLibraryTest(BaseCoreLibraryTest): def run(self, result=None): with deprecation.ignore('core.library.find_exact'): return super(DeprecatedFindExactCoreLibraryTest, self).run(result) def test_find_exact_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') result1 = SearchResult(tracks=[track1]) result2 = SearchResult(tracks=[track2]) self.library1.search.return_value.get.return_value = result1 self.library2.search.return_value.get.return_value = result2 result = self.core.library.find_exact({'any': ['a']}) self.assertIn(result1, result) self.assertIn(result2, result) self.library1.search.assert_called_once_with( query=dict(any=['a']), uris=None, exact=True) self.library2.search.assert_called_once_with( query=dict(any=['a']), uris=None, exact=True) def test_find_exact_with_uris_selects_dummy1_backend(self): self.core.library.find_exact( query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo', 'dummy3:']) self.library1.search.assert_called_once_with( query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo'], exact=True) self.assertFalse(self.library2.search.called) def test_find_exact_with_uris_selects_both_backends(self): self.core.library.find_exact( query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo', 'dummy2:']) self.library1.search.assert_called_once_with( query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo'], exact=True) self.library2.search.assert_called_once_with( query={'any': ['a']}, uris=['dummy2:'], exact=True) def test_find_exact_filters_out_none(self): track1 = Track(uri='dummy1:a') result1 = SearchResult(tracks=[track1]) self.library1.search.return_value.get.return_value = result1 self.library2.search.return_value.get.return_value = None result = self.core.library.find_exact({'any': ['a']}) self.assertIn(result1, result) self.assertNotIn(None, result) self.library1.search.assert_called_once_with( query={'any': ['a']}, uris=None, exact=True) self.library2.search.assert_called_once_with( query={'any': ['a']}, uris=None, exact=True) def test_find_accepts_query_dict_instead_of_kwargs(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') result1 = SearchResult(tracks=[track1]) result2 = SearchResult(tracks=[track2]) self.library1.search.return_value.get.return_value = result1 self.library2.search.return_value.get.return_value = result2 result = self.core.library.find_exact({'any': ['a']}) self.assertIn(result1, result) self.assertIn(result2, result) self.library1.search.assert_called_once_with( query={'any': ['a']}, uris=None, exact=True) self.library2.search.assert_called_once_with( query={'any': ['a']}, uris=None, exact=True) def test_find_exact_normalises_bad_queries(self): self.core.library.find_exact({'any': 'foobar'}) self.library1.search.assert_called_once_with( query={'any': ['foobar']}, uris=None, exact=True) class DeprecatedLookupCoreLibraryTest(BaseCoreLibraryTest): def run(self, result=None): with deprecation.ignore('core.library.lookup:uri_arg'): return super(DeprecatedLookupCoreLibraryTest, self).run(result) def test_lookup_selects_dummy1_backend(self): self.library1.lookup.return_value.get.return_value = [] self.core.library.lookup('dummy1:a') self.library1.lookup.assert_called_once_with('dummy1:a') self.assertFalse(self.library2.lookup.called) def test_lookup_selects_dummy2_backend(self): self.library2.lookup.return_value.get.return_value = [] self.core.library.lookup('dummy2:a') self.assertFalse(self.library1.lookup.called) self.library2.lookup.assert_called_once_with('dummy2:a') def test_lookup_uri_returns_empty_list_for_dummy3_track(self): result = self.core.library.lookup('dummy3:a') self.assertEqual(result, []) self.assertFalse(self.library1.lookup.called) self.assertFalse(self.library2.lookup.called) class LegacyFindExactToSearchLibraryTest(unittest.TestCase): def run(self, result=None): with deprecation.ignore('core.library.find_exact'): return super(LegacyFindExactToSearchLibraryTest, self).run(result) def setUp(self): # noqa: N802 self.backend = mock.Mock() self.backend.actor_ref.actor_class.__name__ = 'DummyBackend' self.backend.uri_schemes.get.return_value = ['dummy'] self.backend.library = mock.Mock(spec=backend.LibraryProvider) self.core = core.Core(mixer=None, backends=[self.backend]) def test_core_find_exact_calls_backend_search_with_exact(self): self.core.library.find_exact(query={'any': ['a']}) self.backend.library.search.assert_called_once_with( query=dict(any=['a']), uris=None, exact=True) def test_core_find_exact_handles_legacy_backend(self): self.backend.library.search.return_value.get.side_effect = TypeError self.core.library.find_exact(query={'any': ['a']}) # We are just testing that this doesn't fail. def test_core_search_call_backend_search_with_exact(self): self.core.library.search(query={'any': ['a']}) self.backend.library.search.assert_called_once_with( query=dict(any=['a']), uris=None, exact=False) def test_core_search_with_exact_call_backend_search_with_exact(self): self.core.library.search(query={'any': ['a']}, exact=True) self.backend.library.search.assert_called_once_with( query=dict(any=['a']), uris=None, exact=True) def test_core_search_with_handles_legacy_backend(self): self.backend.library.search.return_value.get.side_effect = TypeError self.core.library.search(query={'any': ['a']}, exact=True) # We are just testing that this doesn't fail. class MockBackendCoreLibraryBase(unittest.TestCase): def setUp(self): # noqa: N802 dummy_root = Ref.directory(uri='dummy:directory', name='dummy') self.library = mock.Mock(spec=backend.LibraryProvider) self.library.root_directory.get.return_value = dummy_root self.backend = mock.Mock() self.backend.actor_ref.actor_class.__name__ = 'DummyBackend' self.backend.uri_schemes.get.return_value = ['dummy'] self.backend.library = self.library self.core = core.Core(mixer=None, backends=[self.backend]) @mock.patch('mopidy.core.library.logger') class BrowseBadBackendTest(MockBackendCoreLibraryBase): def test_backend_raises_exception_for_root(self, logger): # Might happen if root_directory is a property for some weird reason. self.library.root_directory.get.side_effect = Exception self.assertEqual([], self.core.library.browse(None)) logger.exception.assert_called_with(mock.ANY, 'DummyBackend') def test_backend_returns_none_for_root(self, logger): self.library.root_directory.get.return_value = None self.assertEqual([], self.core.library.browse(None)) logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) def test_backend_returns_wrong_type_for_root(self, logger): self.library.root_directory.get.return_value = 123 self.assertEqual([], self.core.library.browse(None)) logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) def test_backend_raises_exception_for_browse(self, logger): self.library.browse.return_value.get.side_effect = Exception self.assertEqual([], self.core.library.browse('dummy:directory')) logger.exception.assert_called_with(mock.ANY, 'DummyBackend') def test_backend_returns_wrong_type_for_browse(self, logger): self.library.browse.return_value.get.return_value = [123] self.assertEqual([], self.core.library.browse('dummy:directory')) logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) @mock.patch('mopidy.core.library.logger') class GetDistinctBadBackendTest(MockBackendCoreLibraryBase): def test_backend_raises_exception(self, logger): self.library.get_distinct.return_value.get.side_effect = Exception self.assertEqual(set(), self.core.library.get_distinct('artist')) logger.exception.assert_called_with(mock.ANY, 'DummyBackend') def test_backend_returns_none(self, logger): self.library.get_distinct.return_value.get.return_value = None self.assertEqual(set(), self.core.library.get_distinct('artist')) self.assertFalse(logger.error.called) def test_backend_returns_wrong_type(self, logger): self.library.get_distinct.return_value.get.return_value = 'abc' self.assertEqual(set(), self.core.library.get_distinct('artist')) logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) def test_backend_returns_iterable_containing_wrong_types(self, logger): self.library.get_distinct.return_value.get.return_value = [1, 2, 3] self.assertEqual(set(), self.core.library.get_distinct('artist')) logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) @mock.patch('mopidy.core.library.logger') class GetImagesBadBackendTest(MockBackendCoreLibraryBase): def test_backend_raises_exception(self, logger): uri = 'dummy:/1' self.library.get_images.return_value.get.side_effect = Exception self.assertEqual({uri: tuple()}, self.core.library.get_images([uri])) logger.exception.assert_called_with(mock.ANY, 'DummyBackend') def test_backend_returns_none(self, logger): uri = 'dummy:/1' self.library.get_images.return_value.get.return_value = None self.assertEqual({uri: tuple()}, self.core.library.get_images([uri])) self.assertFalse(logger.error.called) def test_backend_returns_wrong_type(self, logger): uri = 'dummy:/1' self.library.get_images.return_value.get.return_value = 'abc' self.assertEqual({uri: tuple()}, self.core.library.get_images([uri])) logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) def test_backend_returns_mapping_containing_wrong_types(self, logger): uri = 'dummy:/1' self.library.get_images.return_value.get.return_value = {uri: 'abc'} self.assertEqual({uri: tuple()}, self.core.library.get_images([uri])) logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) def test_backend_returns_mapping_containing_none(self, logger): uri = 'dummy:/1' self.library.get_images.return_value.get.return_value = {uri: None} self.assertEqual({uri: tuple()}, self.core.library.get_images([uri])) logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) def test_backend_returns_unknown_uri(self, logger): uri = 'dummy:/1' self.library.get_images.return_value.get.return_value = {'foo': []} self.assertEqual({uri: tuple()}, self.core.library.get_images([uri])) logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) @mock.patch('mopidy.core.library.logger') class LookupByUrisBadBackendTest(MockBackendCoreLibraryBase): def test_backend_raises_exception(self, logger): uri = 'dummy:/1' self.library.lookup.return_value.get.side_effect = Exception self.assertEqual({uri: []}, self.core.library.lookup(uris=[uri])) logger.exception.assert_called_with(mock.ANY, 'DummyBackend') def test_backend_returns_none(self, logger): uri = 'dummy:/1' self.library.lookup.return_value.get.return_value = None self.assertEqual({uri: []}, self.core.library.lookup(uris=[uri])) self.assertFalse(logger.error.called) def test_backend_returns_wrong_type(self, logger): uri = 'dummy:/1' self.library.lookup.return_value.get.return_value = 'abc' self.assertEqual({uri: []}, self.core.library.lookup(uris=[uri])) logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) def test_backend_returns_iterable_containing_wrong_types(self, logger): uri = 'dummy:/1' self.library.lookup.return_value.get.return_value = [123] self.assertEqual({uri: []}, self.core.library.lookup(uris=[uri])) logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) def test_backend_returns_none_with_uri(self, logger): uri = 'dummy:/1' self.library.lookup.return_value.get.return_value = None self.assertEqual([], self.core.library.lookup(uri)) self.assertFalse(logger.error.called) def test_backend_returns_wrong_type_with_uri(self, logger): uri = 'dummy:/1' self.library.lookup.return_value.get.return_value = 'abc' self.assertEqual([], self.core.library.lookup(uri)) logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) def test_backend_returns_iterable_wrong_types_with_uri(self, logger): uri = 'dummy:/1' self.library.lookup.return_value.get.return_value = [123] self.assertEqual([], self.core.library.lookup(uri)) logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) @mock.patch('mopidy.core.library.logger') class RefreshBadBackendTest(MockBackendCoreLibraryBase): def test_backend_raises_exception(self, logger): self.library.refresh.return_value.get.side_effect = Exception self.core.library.refresh() logger.exception.assert_called_with(mock.ANY, 'DummyBackend') def test_backend_raises_exception_with_uri(self, logger): self.library.refresh.return_value.get.side_effect = Exception self.core.library.refresh('dummy:/1') logger.exception.assert_called_with(mock.ANY, 'DummyBackend') @mock.patch('mopidy.core.library.logger') class SearchBadBackendTest(MockBackendCoreLibraryBase): def test_backend_raises_exception(self, logger): self.library.search.return_value.get.side_effect = Exception self.assertEqual([], self.core.library.search(query={'any': ['foo']})) logger.exception.assert_called_with(mock.ANY, 'DummyBackend') def test_backend_raises_lookuperror(self, logger): # TODO: is this behavior desired? Do we need to continue handling # LookupError case specially. self.library.search.return_value.get.side_effect = LookupError with self.assertRaises(LookupError): self.core.library.search(query={'any': ['foo']}) def test_backend_returns_none(self, logger): self.library.search.return_value.get.return_value = None self.assertEqual([], self.core.library.search(query={'any': ['foo']})) self.assertFalse(logger.error.called) def test_backend_returns_wrong_type(self, logger): self.library.search.return_value.get.return_value = 'abc' self.assertEqual([], self.core.library.search(query={'any': ['foo']})) logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) Mopidy-2.0.0/tests/core/test_mixer.py0000664000175000017500000001244412575004517020022 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest import mock import pykka from mopidy import core, mixer from tests import dummy_mixer class CoreMixerTest(unittest.TestCase): def setUp(self): # noqa: N802 self.mixer = mock.Mock(spec=mixer.Mixer) self.core = core.Core(mixer=self.mixer, backends=[]) def test_get_volume(self): self.mixer.get_volume.return_value.get.return_value = 30 self.assertEqual(self.core.mixer.get_volume(), 30) self.mixer.get_volume.assert_called_once_with() def test_set_volume(self): self.mixer.set_volume.return_value.get.return_value = True self.core.mixer.set_volume(30) self.mixer.set_volume.assert_called_once_with(30) def test_get_mute(self): self.mixer.get_mute.return_value.get.return_value = True self.assertEqual(self.core.mixer.get_mute(), True) self.mixer.get_mute.assert_called_once_with() def test_set_mute(self): self.mixer.set_mute.return_value.get.return_value = True self.core.mixer.set_mute(True) self.mixer.set_mute.assert_called_once_with(True) class CoreNoneMixerTest(unittest.TestCase): def setUp(self): # noqa: N802 self.core = core.Core(mixer=None, backends=[]) def test_get_volume_return_none_because_it_is_unknown(self): self.assertEqual(self.core.mixer.get_volume(), None) def test_set_volume_return_false_because_it_failed(self): self.assertEqual(self.core.mixer.set_volume(30), False) def test_get_mute_return_none_because_it_is_unknown(self): self.assertEqual(self.core.mixer.get_mute(), None) def test_set_mute_return_false_because_it_failed(self): self.assertEqual(self.core.mixer.set_mute(True), False) @mock.patch.object(mixer.MixerListener, 'send') class CoreMixerListenerTest(unittest.TestCase): def setUp(self): # noqa: N802 self.mixer = dummy_mixer.create_proxy() self.core = core.Core(mixer=self.mixer, backends=[]) def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def test_forwards_mixer_volume_changed_event_to_frontends(self, send): self.assertEqual(self.core.mixer.set_volume(volume=60), True) self.assertEqual(send.call_args[0][0], 'volume_changed') self.assertEqual(send.call_args[1]['volume'], 60) def test_forwards_mixer_mute_changed_event_to_frontends(self, send): self.core.mixer.set_mute(mute=True) self.assertEqual(send.call_args[0][0], 'mute_changed') self.assertEqual(send.call_args[1]['mute'], True) @mock.patch.object(mixer.MixerListener, 'send') class CoreNoneMixerListenerTest(unittest.TestCase): def setUp(self): # noqa: N802 self.core = core.Core(mixer=None, backends=[]) def test_forwards_mixer_volume_changed_event_to_frontends(self, send): self.assertEqual(self.core.mixer.set_volume(volume=60), False) self.assertEqual(send.call_count, 0) def test_forwards_mixer_mute_changed_event_to_frontends(self, send): self.core.mixer.set_mute(mute=True) self.assertEqual(send.call_count, 0) class MockBackendCoreMixerBase(unittest.TestCase): def setUp(self): # noqa: N802 self.mixer = mock.Mock() self.mixer.actor_ref.actor_class.__name__ = 'DummyMixer' self.core = core.Core(mixer=self.mixer, backends=[]) class GetVolumeBadBackendTest(MockBackendCoreMixerBase): def test_backend_raises_exception(self): self.mixer.get_volume.return_value.get.side_effect = Exception self.assertEqual(self.core.mixer.get_volume(), None) def test_backend_returns_too_small_value(self): self.mixer.get_volume.return_value.get.return_value = -1 self.assertEqual(self.core.mixer.get_volume(), None) def test_backend_returns_too_large_value(self): self.mixer.get_volume.return_value.get.return_value = 1000 self.assertEqual(self.core.mixer.get_volume(), None) def test_backend_returns_wrong_type(self): self.mixer.get_volume.return_value.get.return_value = '12' self.assertEqual(self.core.mixer.get_volume(), None) class SetVolumeBadBackendTest(MockBackendCoreMixerBase): def test_backend_raises_exception(self): self.mixer.set_volume.return_value.get.side_effect = Exception self.assertFalse(self.core.mixer.set_volume(30)) def test_backend_returns_wrong_type(self): self.mixer.set_volume.return_value.get.return_value = 'done' self.assertFalse(self.core.mixer.set_volume(30)) class GetMuteBadBackendTest(MockBackendCoreMixerBase): def test_backend_raises_exception(self): self.mixer.get_mute.return_value.get.side_effect = Exception self.assertEqual(self.core.mixer.get_mute(), None) def test_backend_returns_wrong_type(self): self.mixer.get_mute.return_value.get.return_value = '12' self.assertEqual(self.core.mixer.get_mute(), None) class SetMuteBadBackendTest(MockBackendCoreMixerBase): def test_backend_raises_exception(self): self.mixer.set_mute.return_value.get.side_effect = Exception self.assertFalse(self.core.mixer.set_mute(True)) def test_backend_returns_wrong_type(self): self.mixer.set_mute.return_value.get.return_value = 'done' self.assertFalse(self.core.mixer.set_mute(True)) Mopidy-2.0.0/tests/core/test_playback.py0000664000175000017500000011112312660436420020453 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest import mock import pykka from mopidy import backend, core from mopidy.internal import deprecation from mopidy.models import Track from tests import dummy_audio class TestPlaybackProvider(backend.PlaybackProvider): def translate_uri(self, uri): if 'error' in uri: raise Exception(uri) elif 'unplayable' in uri: return None else: return uri # TODO: Replace this with dummy_backend now that it uses a real # playbackprovider Since we rely on our DummyAudio to actually emit events we # need a "real" backend and not a mock so the right calls make it through to # audio. class TestBackend(pykka.ThreadingActor, backend.Backend): uri_schemes = ['dummy'] def __init__(self, config, audio): super(TestBackend, self).__init__() self.playback = TestPlaybackProvider(audio=audio, backend=self) class BaseTest(unittest.TestCase): config = {'core': {'max_tracklist_length': 10000}} tracks = [Track(uri='dummy:a', length=1234), Track(uri='dummy:b', length=1234), Track(uri='dummy:c', length=1234)] def setUp(self): # noqa: N802 # TODO: use create_proxy helpers. self.audio = dummy_audio.DummyAudio.start().proxy() self.backend = TestBackend.start( audio=self.audio, config=self.config).proxy() self.core = core.Core( audio=self.audio, backends=[self.backend], config=self.config) self.playback = self.core.playback # We don't have a core actor running, so call about to finish directly. self.audio.set_about_to_finish_callback( self.playback._on_about_to_finish) with deprecation.ignore('core.tracklist.add:tracks_arg'): self.core.tracklist.add(self.tracks) self.events = [] self.patcher = mock.patch('mopidy.audio.listener.AudioListener.send') self.send_mock = self.patcher.start() def send(event, **kwargs): self.events.append((event, kwargs)) self.send_mock.side_effect = send def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() self.patcher.stop() def replay_events(self, until=None): while self.events: if self.events[0][0] == until: break event, kwargs = self.events.pop(0) self.core.on_event(event, **kwargs) def trigger_about_to_finish(self, replay_until=None): self.replay_events() callback = self.audio.get_about_to_finish_callback().get() callback() self.replay_events(until=replay_until) class TestPlayHandling(BaseTest): def test_get_current_tl_track_play(self): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[0]) self.replay_events() self.assertEqual( self.core.playback.get_current_tl_track(), tl_tracks[0]) def test_get_current_track_play(self): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[0]) self.replay_events() self.assertEqual( self.core.playback.get_current_track(), self.tracks[0]) def test_get_current_tlid_play(self): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[0]) self.replay_events() self.assertEqual( self.core.playback.get_current_tlid(), tl_tracks[0].tlid) def test_play_skips_to_next_on_unplayable_track(self): """Checks that we handle backend.change_track failing.""" tl_tracks = self.core.tracklist.get_tl_tracks() self.audio.trigger_fake_playback_failure(tl_tracks[0].track.uri) self.core.playback.play(tl_tracks[0]) self.replay_events() current_tl_track = self.core.playback.get_current_tl_track() self.assertEqual(tl_tracks[1], current_tl_track) def test_resume_skips_to_next_on_unplayable_track(self): """Checks that we handle backend.change_track failing when resuming playback.""" tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[0]) self.core.playback.pause() self.audio.trigger_fake_playback_failure(tl_tracks[1].track.uri) self.core.playback.next() self.core.playback.resume() self.replay_events() current_tl_track = self.core.playback.get_current_tl_track() self.assertEqual(tl_tracks[2], current_tl_track) def test_play_tlid(self): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tlid=tl_tracks[1].tlid) self.replay_events() current_tl_track = self.core.playback.get_current_tl_track() self.assertEqual(tl_tracks[1], current_tl_track) class TestNextHandling(BaseTest): def test_get_current_tl_track_next(self): self.core.playback.play() self.replay_events() self.core.playback.next() self.replay_events() tl_tracks = self.core.tracklist.get_tl_tracks() current_tl_track = self.core.playback.get_current_tl_track() self.assertEqual(current_tl_track, tl_tracks[1]) def test_get_pending_tl_track_next(self): self.core.playback.play() self.replay_events() self.core.playback.next() tl_tracks = self.core.tracklist.get_tl_tracks() self.assertEqual(self.core.playback._pending_tl_track, tl_tracks[1]) def test_get_current_track_next(self): self.core.playback.play() self.replay_events() self.core.playback.next() self.replay_events() current_track = self.core.playback.get_current_track() self.assertEqual(current_track, self.tracks[1]) def test_next_keeps_finished_track_in_tracklist(self): tl_track = self.core.tracklist.get_tl_tracks()[0] self.core.playback.play(tl_track) self.replay_events() self.core.playback.next() self.replay_events() self.assertIn(tl_track, self.core.tracklist.tl_tracks) def test_next_skips_over_unplayable_track(self): tl_tracks = self.core.tracklist.get_tl_tracks() self.audio.trigger_fake_playback_failure(tl_tracks[1].track.uri) self.core.playback.play(tl_tracks[0]) self.replay_events() self.core.playback.next() self.replay_events() assert self.core.playback.get_current_tl_track() == tl_tracks[2] def test_next_skips_over_change_track_error(self): # Trigger an exception in translate_uri. track = Track(uri='dummy:error', length=1234) self.core.tracklist.add(tracks=[track], at_position=1) tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play() self.replay_events() self.core.playback.next() self.replay_events() assert self.core.playback.get_current_tl_track() == tl_tracks[2] def test_next_skips_over_change_track_unplayable(self): # Make translate_uri return None. track = Track(uri='dummy:unplayable', length=1234) self.core.tracklist.add(tracks=[track], at_position=1) tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play() self.replay_events() self.core.playback.next() self.replay_events() assert self.core.playback.get_current_tl_track() == tl_tracks[2] class TestPreviousHandling(BaseTest): # TODO Test previous() more def test_get_current_tl_track_prev(self): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[1]) self.core.playback.previous() self.replay_events() self.assertEqual( self.core.playback.get_current_tl_track(), tl_tracks[0]) def test_get_current_track_prev(self): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[1]) self.core.playback.previous() self.replay_events() self.assertEqual( self.core.playback.get_current_track(), self.tracks[0]) def test_previous_keeps_finished_track_in_tracklist(self): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[1]) self.core.playback.previous() self.replay_events() self.assertIn(tl_tracks[1], self.core.tracklist.tl_tracks) def test_previous_keeps_finished_track_even_in_consume_mode(self): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[1]) self.core.tracklist.consume = True self.core.playback.previous() self.replay_events() self.assertIn(tl_tracks[1], self.core.tracklist.tl_tracks) def test_previous_skips_over_unplayable_track(self): tl_tracks = self.core.tracklist.get_tl_tracks() self.audio.trigger_fake_playback_failure(tl_tracks[1].track.uri) self.core.playback.play(tl_tracks[2]) self.replay_events() self.core.playback.previous() self.replay_events() assert self.core.playback.get_current_tl_track() == tl_tracks[0] def test_previous_skips_over_change_track_error(self): # Trigger an exception in translate_uri. track = Track(uri='dummy:error', length=1234) self.core.tracklist.add(tracks=[track], at_position=1) tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[2]) self.replay_events() self.core.playback.previous() self.replay_events() assert self.core.playback.get_current_tl_track() == tl_tracks[0] def test_previous_skips_over_change_track_unplayable(self): # Makes translate_uri return None. track = Track(uri='dummy:unplayable', length=1234) self.core.tracklist.add(tracks=[track], at_position=1) tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[2]) self.replay_events() self.core.playback.previous() self.replay_events() assert self.core.playback.get_current_tl_track() == tl_tracks[0] class TestOnAboutToFinish(BaseTest): def test_on_about_to_finish_keeps_finished_track_in_tracklist(self): tl_track = self.core.tracklist.get_tl_tracks()[0] self.core.playback.play(tl_track) self.trigger_about_to_finish() self.assertIn(tl_track, self.core.tracklist.tl_tracks) def test_on_about_to_finish_skips_over_change_track_error(self): # Trigger an exception in translate_uri. track = Track(uri='dummy:error', length=1234) self.core.tracklist.add(tracks=[track], at_position=1) tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[0]) self.replay_events() self.trigger_about_to_finish() assert self.core.playback.get_current_tl_track() == tl_tracks[2] def test_on_about_to_finish_skips_over_change_track_unplayable(self): # Makes translate_uri return None. track = Track(uri='dummy:unplayable', length=1234) self.core.tracklist.add(tracks=[track], at_position=1) tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[0]) self.replay_events() self.trigger_about_to_finish() assert self.core.playback.get_current_tl_track() == tl_tracks[2] class TestConsumeHandling(BaseTest): def test_next_in_consume_mode_removes_finished_track(self): tl_track = self.core.tracklist.get_tl_tracks()[0] self.core.playback.play(tl_track) self.core.tracklist.set_consume(True) self.replay_events() self.core.playback.next() self.replay_events() self.assertNotIn(tl_track, self.core.tracklist.get_tl_tracks()) def test_next_in_consume_mode_removes_unplayable_track(self): last_playable_tl_track = self.core.tracklist.get_tl_tracks()[-2] unplayable_tl_track = self.core.tracklist.get_tl_tracks()[-1] self.audio.trigger_fake_playback_failure(unplayable_tl_track.track.uri) self.core.playback.play(last_playable_tl_track) self.core.tracklist.set_consume(True) self.core.playback.next() self.replay_events() self.assertNotIn( unplayable_tl_track, self.core.tracklist.get_tl_tracks()) def test_on_about_to_finish_in_consume_mode_removes_finished_track(self): tl_track = self.core.tracklist.get_tl_tracks()[0] self.core.playback.play(tl_track) self.core.tracklist.consume = True self.trigger_about_to_finish() self.assertNotIn(tl_track, self.core.tracklist.get_tl_tracks()) class TestCurrentAndPendingTlTrack(BaseTest): def test_get_current_tl_track_none(self): self.assertEqual( self.core.playback.get_current_tl_track(), None) def test_get_current_tlid_none(self): self.assertEqual(self.core.playback.get_current_tlid(), None) def test_pending_tl_track_is_none(self): self.core.playback.play() self.replay_events() self.assertEqual(self.playback._pending_tl_track, None) def test_pending_tl_track_after_about_to_finish(self): self.core.playback.play() self.replay_events() self.trigger_about_to_finish(replay_until='stream_changed') self.assertEqual(self.playback._pending_tl_track.track.uri, 'dummy:b') def test_pending_tl_track_after_stream_changed(self): self.trigger_about_to_finish() self.assertEqual(self.playback._pending_tl_track, None) def test_current_tl_track_after_about_to_finish(self): self.core.playback.play() self.replay_events() self.trigger_about_to_finish(replay_until='stream_changed') self.assertEqual(self.playback.current_tl_track.track.uri, 'dummy:a') def test_current_tl_track_after_stream_changed(self): self.core.playback.play() self.replay_events() self.trigger_about_to_finish() self.assertEqual(self.playback.current_tl_track.track.uri, 'dummy:b') def test_current_tl_track_after_end_of_stream(self): self.core.playback.play() self.replay_events() self.trigger_about_to_finish() self.trigger_about_to_finish() self.trigger_about_to_finish() # EOS self.assertEqual(self.playback.current_tl_track, None) @mock.patch( 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) class EventEmissionTest(BaseTest): maxDiff = None def test_play_when_stopped_emits_events(self, listener_mock): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[0]) self.replay_events() self.assertListEqual( [ mock.call( 'playback_state_changed', old_state='stopped', new_state='playing'), mock.call( 'track_playback_started', tl_track=tl_tracks[0]), ], listener_mock.send.mock_calls) def test_play_when_paused_emits_events(self, listener_mock): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[0]) self.replay_events() self.core.playback.pause() self.replay_events() listener_mock.reset_mock() self.core.playback.play(tl_tracks[1]) self.replay_events() self.assertListEqual( [ mock.call( 'track_playback_ended', tl_track=tl_tracks[0], time_position=mock.ANY), mock.call( 'playback_state_changed', old_state='paused', new_state='playing'), mock.call( 'track_playback_started', tl_track=tl_tracks[1]), ], listener_mock.send.mock_calls) def test_play_when_playing_emits_events(self, listener_mock): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[0]) self.replay_events() listener_mock.reset_mock() self.core.playback.play(tl_tracks[2]) self.replay_events() self.assertListEqual( [ mock.call( 'track_playback_ended', tl_track=tl_tracks[0], time_position=mock.ANY), mock.call( 'playback_state_changed', old_state='playing', new_state='playing'), mock.call( 'track_playback_started', tl_track=tl_tracks[2]), ], listener_mock.send.mock_calls) def test_pause_emits_events(self, listener_mock): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[0]) self.replay_events() self.core.playback.seek(1000) listener_mock.reset_mock() self.core.playback.pause() self.assertListEqual( [ mock.call( 'playback_state_changed', old_state='playing', new_state='paused'), mock.call( 'track_playback_paused', tl_track=tl_tracks[0], time_position=1000), ], listener_mock.send.mock_calls) def test_resume_emits_events(self, listener_mock): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[0]) self.replay_events() self.core.playback.pause() self.core.playback.seek(1000) listener_mock.reset_mock() self.core.playback.resume() self.assertListEqual( [ mock.call( 'playback_state_changed', old_state='paused', new_state='playing'), mock.call( 'track_playback_resumed', tl_track=tl_tracks[0], time_position=1000), ], listener_mock.send.mock_calls) def test_stop_emits_events(self, listener_mock): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[0]) self.replay_events() self.core.playback.seek(1000) self.replay_events() listener_mock.reset_mock() self.core.playback.stop() self.replay_events() self.assertListEqual( [ mock.call( 'playback_state_changed', old_state='playing', new_state='stopped'), mock.call( 'track_playback_ended', tl_track=tl_tracks[0], time_position=1000), ], listener_mock.send.mock_calls) def test_next_emits_events(self, listener_mock): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[0]) self.replay_events() self.core.playback.seek(1000) self.replay_events() listener_mock.reset_mock() self.core.playback.next() self.replay_events() self.assertListEqual( [ mock.call( 'track_playback_ended', tl_track=tl_tracks[0], time_position=mock.ANY), mock.call( 'playback_state_changed', old_state='playing', new_state='playing'), mock.call( 'track_playback_started', tl_track=tl_tracks[1]), ], listener_mock.send.mock_calls) def test_next_emits_events_when_consume_mode_is_enabled( self, listener_mock): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.tracklist.set_consume(True) self.core.playback.play(tl_tracks[0]) self.replay_events() self.core.playback.seek(1000) self.replay_events() listener_mock.reset_mock() self.core.playback.next() self.replay_events() self.assertListEqual( [ mock.call( 'tracklist_changed'), mock.call( 'track_playback_ended', tl_track=tl_tracks[0], time_position=mock.ANY), mock.call( 'playback_state_changed', old_state='playing', new_state='playing'), mock.call( 'track_playback_started', tl_track=tl_tracks[1]), ], listener_mock.send.mock_calls) def test_gapless_track_change_emits_events(self, listener_mock): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[0]) self.replay_events() listener_mock.reset_mock() self.trigger_about_to_finish() self.assertListEqual( [ mock.call( 'track_playback_ended', tl_track=tl_tracks[0], time_position=mock.ANY), mock.call( 'playback_state_changed', old_state='playing', new_state='playing'), mock.call( 'track_playback_started', tl_track=tl_tracks[1]), ], listener_mock.send.mock_calls) def test_seek_emits_seeked_event(self, listener_mock): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[0]) self.replay_events() listener_mock.reset_mock() self.core.playback.seek(1000) self.replay_events() listener_mock.send.assert_called_once_with( 'seeked', time_position=1000) def test_seek_past_end_of_track_emits_events(self, listener_mock): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[0]) self.replay_events() listener_mock.reset_mock() self.core.playback.seek(self.tracks[0].length * 5) self.replay_events() self.assertListEqual( [ mock.call( 'track_playback_ended', tl_track=tl_tracks[0], time_position=mock.ANY), mock.call( 'playback_state_changed', old_state='playing', new_state='playing'), mock.call( 'track_playback_started', tl_track=tl_tracks[1]), ], listener_mock.send.mock_calls) def test_seek_race_condition_emits_events(self, listener_mock): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[0]) self.trigger_about_to_finish(replay_until='stream_changed') listener_mock.reset_mock() self.core.playback.seek(1000) self.replay_events() # When we trigger seek after an about to finish the other code that # emits track stopped/started and playback state changed events gets # triggered as we have to switch back to the previous track. # The correct behavior would be to only emit seeked. self.assertListEqual( [mock.call('seeked', time_position=1000)], listener_mock.send.mock_calls) def test_previous_emits_events(self, listener_mock): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[1]) self.replay_events() listener_mock.reset_mock() self.core.playback.previous() self.replay_events() self.assertListEqual( [ mock.call( 'track_playback_ended', tl_track=tl_tracks[1], time_position=mock.ANY), mock.call( 'playback_state_changed', old_state='playing', new_state='playing'), mock.call( 'track_playback_started', tl_track=tl_tracks[0]), ], listener_mock.send.mock_calls) class TestUnplayableURI(BaseTest): tracks = [ Track(uri='unplayable://'), Track(uri='dummy:b'), ] def setUp(self): # noqa: N802 super(TestUnplayableURI, self).setUp() tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback._set_current_tl_track(tl_tracks[0]) def test_play_skips_to_next_if_track_is_unplayable(self): self.core.playback.play() self.replay_events() current_track = self.core.playback.get_current_track() self.assertEqual(current_track, self.tracks[1]) def test_pause_changes_state_even_if_track_is_unplayable(self): self.core.playback.pause() self.assertEqual(self.core.playback.state, core.PlaybackState.PAUSED) def test_resume_does_nothing_if_track_is_unplayable(self): self.core.playback.state = core.PlaybackState.PAUSED self.core.playback.resume() self.assertEqual(self.core.playback.state, core.PlaybackState.PAUSED) def test_stop_changes_state_even_if_track_is_unplayable(self): self.core.playback.state = core.PlaybackState.PAUSED self.core.playback.stop() self.assertEqual(self.core.playback.state, core.PlaybackState.STOPPED) def test_time_position_returns_0_if_track_is_unplayable(self): result = self.core.playback.time_position self.assertEqual(result, 0) def test_seek_fails_for_unplayable_track(self): self.core.playback.state = core.PlaybackState.PLAYING success = self.core.playback.seek(1000) self.assertFalse(success) class SeekTest(BaseTest): def test_seek_normalizes_negative_positions_to_zero(self): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[0]) self.replay_events() self.core.playback.seek(-100) # Dummy audio doesn't progress time. self.assertEqual(0, self.core.playback.get_time_position()) def test_seek_fails_for_track_without_duration(self): track = self.tracks[0].replace(length=None) self.core.tracklist.clear() self.core.tracklist.add([track]) self.core.playback.play() self.replay_events() self.assertFalse(self.core.playback.seek(1000)) self.assertEqual(0, self.core.playback.get_time_position()) def test_seek_play_stay_playing(self): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[0]) self.replay_events() self.core.playback.seek(1000) self.assertEqual(self.core.playback.state, core.PlaybackState.PLAYING) def test_seek_paused_stay_paused(self): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[0]) self.replay_events() self.core.playback.pause() self.replay_events() self.core.playback.seek(1000) self.assertEqual(self.core.playback.state, core.PlaybackState.PAUSED) def test_seek_race_condition_after_about_to_finish(self): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[0]) self.replay_events() self.trigger_about_to_finish(replay_until='stream_changed') self.core.playback.seek(1000) self.replay_events() current_tl_track = self.core.playback.get_current_tl_track() self.assertEqual(current_tl_track, tl_tracks[0]) class TestStream(BaseTest): def test_get_stream_title_before_playback(self): self.assertEqual(self.playback.get_stream_title(), None) def test_get_stream_title_during_playback(self): self.core.playback.play() self.replay_events() self.assertEqual(self.playback.get_stream_title(), None) def test_get_stream_title_during_playback_with_tags_change(self): self.core.playback.play() self.audio.trigger_fake_tags_changed({'organization': ['baz']}) self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() self.replay_events() self.assertEqual(self.playback.get_stream_title(), 'foobar') def test_get_stream_title_after_next(self): self.core.playback.play() self.audio.trigger_fake_tags_changed({'organization': ['baz']}) self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() self.replay_events() self.core.playback.next() self.replay_events() self.assertEqual(self.playback.get_stream_title(), None) def test_get_stream_title_after_next_with_tags_change(self): self.core.playback.play() self.audio.trigger_fake_tags_changed({'organization': ['baz']}) self.audio.trigger_fake_tags_changed({'title': ['foo']}).get() self.replay_events() self.core.playback.next() self.audio.trigger_fake_tags_changed({'organization': ['baz']}) self.audio.trigger_fake_tags_changed({'title': ['bar']}).get() self.replay_events() self.assertEqual(self.playback.get_stream_title(), 'bar') def test_get_stream_title_after_stop(self): self.core.playback.play() self.audio.trigger_fake_tags_changed({'organization': ['baz']}) self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() self.replay_events() self.core.playback.stop() self.replay_events() self.assertEqual(self.playback.get_stream_title(), None) class TestBackendSelection(unittest.TestCase): def setUp(self): # noqa: N802 config = { 'core': { 'max_tracklist_length': 10000, } } self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] self.playback1 = mock.Mock(spec=backend.PlaybackProvider) self.backend1.playback = self.playback1 self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2'] self.playback2 = mock.Mock(spec=backend.PlaybackProvider) self.backend2.playback = self.playback2 self.tracks = [ Track(uri='dummy1:a', length=40000), Track(uri='dummy2:a', length=40000), ] self.core = core.Core(config, mixer=None, backends=[ self.backend1, self.backend2]) self.tl_tracks = self.core.tracklist.add(self.tracks) def trigger_stream_changed(self): pending = self.core.playback._pending_tl_track if pending: self.core.stream_changed(uri=pending.track.uri) else: self.core.stream_changed(uri=None) def test_play_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) self.trigger_stream_changed() self.playback1.prepare_change.assert_called_once_with() self.playback1.change_track.assert_called_once_with(self.tracks[0]) self.playback1.play.assert_called_once_with() self.assertFalse(self.playback2.play.called) def test_play_selects_dummy2_backend(self): self.core.playback.play(self.tl_tracks[1]) self.trigger_stream_changed() self.assertFalse(self.playback1.play.called) self.playback2.prepare_change.assert_called_once_with() self.playback2.change_track.assert_called_once_with(self.tracks[1]) self.playback2.play.assert_called_once_with() def test_pause_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) self.trigger_stream_changed() self.core.playback.pause() self.playback1.pause.assert_called_once_with() self.assertFalse(self.playback2.pause.called) def test_pause_selects_dummy2_backend(self): self.core.playback.play(self.tl_tracks[1]) self.trigger_stream_changed() self.core.playback.pause() self.assertFalse(self.playback1.pause.called) self.playback2.pause.assert_called_once_with() def test_resume_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) self.trigger_stream_changed() self.core.playback.pause() self.core.playback.resume() self.playback1.resume.assert_called_once_with() self.assertFalse(self.playback2.resume.called) def test_resume_selects_dummy2_backend(self): self.core.playback.play(self.tl_tracks[1]) self.trigger_stream_changed() self.core.playback.pause() self.core.playback.resume() self.assertFalse(self.playback1.resume.called) self.playback2.resume.assert_called_once_with() def test_stop_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) self.trigger_stream_changed() self.core.playback.stop() self.trigger_stream_changed() self.playback1.stop.assert_called_once_with() self.assertFalse(self.playback2.stop.called) def test_stop_selects_dummy2_backend(self): self.core.playback.play(self.tl_tracks[1]) self.trigger_stream_changed() self.core.playback.stop() self.trigger_stream_changed() self.assertFalse(self.playback1.stop.called) self.playback2.stop.assert_called_once_with() def test_seek_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) self.trigger_stream_changed() self.core.playback.seek(10000) self.playback1.seek.assert_called_once_with(10000) self.assertFalse(self.playback2.seek.called) def test_seek_selects_dummy2_backend(self): self.core.playback.play(self.tl_tracks[1]) self.trigger_stream_changed() self.core.playback.seek(10000) self.assertFalse(self.playback1.seek.called) self.playback2.seek.assert_called_once_with(10000) def test_time_position_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) self.trigger_stream_changed() self.core.playback.time_position self.playback1.get_time_position.assert_called_once_with() self.assertFalse(self.playback2.get_time_position.called) def test_time_position_selects_dummy2_backend(self): self.core.playback.play(self.tl_tracks[1]) self.trigger_stream_changed() self.core.playback.time_position self.assertFalse(self.playback1.get_time_position.called) self.playback2.get_time_position.assert_called_once_with() class TestCorePlaybackWithOldBackend(unittest.TestCase): def test_type_error_from_old_backend_does_not_crash_core(self): config = { 'core': { 'max_tracklist_length': 10000, } } b = mock.Mock() b.actor_ref.actor_class.__name__ = 'DummyBackend' b.uri_schemes.get.return_value = ['dummy1'] b.playback = mock.Mock(spec=backend.PlaybackProvider) b.playback.play.side_effect = TypeError b.library.lookup.return_value.get.return_value = [ Track(uri='dummy1:a', length=40000)] c = core.Core(config, mixer=None, backends=[b]) c.tracklist.add(uris=['dummy1:a']) c.playback.play() # No TypeError == test passed. b.playback.play.assert_called_once_with() class TestBug1177Regression(unittest.TestCase): def test(self): config = { 'core': { 'max_tracklist_length': 10000, } } b = mock.Mock() b.uri_schemes.get.return_value = ['dummy'] b.playback = mock.Mock(spec=backend.PlaybackProvider) b.playback.change_track.return_value.get.return_value = True b.playback.play.return_value.get.return_value = True track1 = Track(uri='dummy:a', length=40000) track2 = Track(uri='dummy:b', length=40000) c = core.Core(config, mixer=None, backends=[b]) c.tracklist.add([track1, track2]) c.playback.play() b.playback.change_track.assert_called_once_with(track1) b.playback.change_track.reset_mock() c.playback.pause() c.playback.next() b.playback.change_track.assert_called_once_with(track2) class TestBug1352Regression(BaseTest): tracks = [ Track(uri='dummy:a', length=40000), Track(uri='dummy:b', length=40000), ] def test_next_when_paused_updates_history(self): self.core.history._add_track = mock.Mock() self.core.tracklist._mark_playing = mock.Mock() tl_tracks = self.core.tracklist.get_tl_tracks() self.playback.play() self.replay_events() self.core.history._add_track.assert_called_once_with(self.tracks[0]) self.core.tracklist._mark_playing.assert_called_once_with(tl_tracks[0]) self.core.history._add_track.reset_mock() self.core.tracklist._mark_playing.reset_mock() self.playback.pause() self.playback.next() self.replay_events() self.core.history._add_track.assert_called_once_with(self.tracks[1]) self.core.tracklist._mark_playing.assert_called_once_with(tl_tracks[1]) Mopidy-2.0.0/tests/core/test_listener.py0000664000175000017500000000441712575004517020524 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest import mock from mopidy.core import CoreListener, PlaybackState from mopidy.models import Playlist, TlTrack class CoreListenerTest(unittest.TestCase): def setUp(self): # noqa: N802 self.listener = CoreListener() def test_on_event_forwards_to_specific_handler(self): self.listener.track_playback_paused = mock.Mock() self.listener.on_event( 'track_playback_paused', track=TlTrack(), position=0) self.listener.track_playback_paused.assert_called_with( track=TlTrack(), position=0) def test_listener_has_default_impl_for_track_playback_paused(self): self.listener.track_playback_paused(TlTrack(), 0) def test_listener_has_default_impl_for_track_playback_resumed(self): self.listener.track_playback_resumed(TlTrack(), 0) def test_listener_has_default_impl_for_track_playback_started(self): self.listener.track_playback_started(TlTrack()) def test_listener_has_default_impl_for_track_playback_ended(self): self.listener.track_playback_ended(TlTrack(), 0) def test_listener_has_default_impl_for_playback_state_changed(self): self.listener.playback_state_changed( PlaybackState.STOPPED, PlaybackState.PLAYING) def test_listener_has_default_impl_for_tracklist_changed(self): self.listener.tracklist_changed() def test_listener_has_default_impl_for_playlists_loaded(self): self.listener.playlists_loaded() def test_listener_has_default_impl_for_playlist_changed(self): self.listener.playlist_changed(Playlist()) def test_listener_has_default_impl_for_playlist_deleted(self): self.listener.playlist_deleted(Playlist()) def test_listener_has_default_impl_for_options_changed(self): self.listener.options_changed() def test_listener_has_default_impl_for_volume_changed(self): self.listener.volume_changed(70) def test_listener_has_default_impl_for_mute_changed(self): self.listener.mute_changed(True) def test_listener_has_default_impl_for_seeked(self): self.listener.seeked(0) def test_listener_has_default_impl_for_stream_title_changed(self): self.listener.stream_title_changed('foobar') Mopidy-2.0.0/tests/core/test_history.py0000664000175000017500000000276512660436420020401 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest from mopidy import compat from mopidy.core import HistoryController from mopidy.models import Artist, Track class PlaybackHistoryTest(unittest.TestCase): def setUp(self): # noqa: N802 self.tracks = [ Track(uri='dummy1:a', name='foo', artists=[Artist(name='foober'), Artist(name='barber')]), Track(uri='dummy2:a', name='foo'), Track(uri='dummy3:a', name='bar') ] self.history = HistoryController() def test_add_track(self): self.history._add_track(self.tracks[0]) self.assertEqual(self.history.get_length(), 1) self.history._add_track(self.tracks[1]) self.assertEqual(self.history.get_length(), 2) self.history._add_track(self.tracks[2]) self.assertEqual(self.history.get_length(), 3) def test_non_tracks_are_rejected(self): with self.assertRaises(TypeError): self.history._add_track(object()) self.assertEqual(self.history.get_length(), 0) def test_history_entry_contents(self): track = self.tracks[0] self.history._add_track(track) result = self.history.get_history() (timestamp, ref) = result[0] self.assertIsInstance(timestamp, compat.integer_types) self.assertEqual(track.uri, ref.uri) self.assertIn(track.name, ref.name) for artist in track.artists: self.assertIn(artist.name, ref.name) Mopidy-2.0.0/tests/internal/0000775000175000017500000000000012660436443016146 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/internal/test_playlists.py0000664000175000017500000000757412575504731021621 0ustar jodaljodal00000000000000# encoding: utf-8 from __future__ import absolute_import, unicode_literals import unittest import pytest from mopidy.internal import playlists BAD = b'foobarbaz' EXTM3U = b"""#EXTM3U #EXTINF:123, Sample artist - Sample title file:///tmp/foo #EXTINF:321,Example Artist - Example \xc5\xa7\xc5\x95 file:///tmp/bar #EXTINF:213,Some Artist - Other title file:///tmp/baz """ URILIST = b""" file:///tmp/foo # a comment \xc5\xa7\xc5\x95 file:///tmp/bar file:///tmp/baz """ PLS = b"""[Playlist] NumberOfEntries=3 File1=file:///tmp/foo Title1=Sample Title Length1=123 File2=file:///tmp/bar Title2=Example \xc5\xa7\xc5\x95 Length2=321 File3=file:///tmp/baz Title3=Other title Length3=213 Version=2 """ ASX = b""" Example Sample Title Example \xc5\xa7\xc5\x95 Other title """ SIMPLE_ASX = b""" """ XSPF = b""" Sample Title file:///tmp/foo Example \xc5\xa7\xc5\x95 file:///tmp/bar Other title file:///tmp/baz """ EXPECTED = [b'file:///tmp/foo', b'file:///tmp/bar', b'file:///tmp/baz'] @pytest.mark.parametrize('data,result', [ (BAD, []), (URILIST, EXPECTED), (EXTM3U, EXPECTED), (PLS, EXPECTED), (ASX, EXPECTED), (SIMPLE_ASX, EXPECTED), (XSPF, EXPECTED), ]) def test_parse(data, result): assert playlists.parse(data) == result class BasePlaylistTest(object): valid = None invalid = None detect = None parse = None def test_detect_valid_header(self): self.assertTrue(self.detect(self.valid)) def test_detect_invalid_header(self): self.assertFalse(self.detect(self.invalid)) def test_parse_valid_playlist(self): uris = list(self.parse(self.valid)) self.assertEqual(uris, EXPECTED) def test_parse_invalid_playlist(self): uris = list(self.parse(self.invalid)) self.assertEqual(uris, []) class ExtM3uPlaylistTest(BasePlaylistTest, unittest.TestCase): valid = EXTM3U invalid = BAD detect = staticmethod(playlists.detect_extm3u_header) parse = staticmethod(playlists.parse_extm3u) class PlsPlaylistTest(BasePlaylistTest, unittest.TestCase): valid = PLS invalid = BAD detect = staticmethod(playlists.detect_pls_header) parse = staticmethod(playlists.parse_pls) class AsxPlsPlaylistTest(BasePlaylistTest, unittest.TestCase): valid = ASX invalid = BAD detect = staticmethod(playlists.detect_asx_header) parse = staticmethod(playlists.parse_asx) class SimpleAsxPlsPlaylistTest(BasePlaylistTest, unittest.TestCase): valid = SIMPLE_ASX invalid = BAD detect = staticmethod(playlists.detect_asx_header) parse = staticmethod(playlists.parse_asx) class XspfPlaylistTest(BasePlaylistTest, unittest.TestCase): valid = XSPF invalid = BAD detect = staticmethod(playlists.detect_xspf_header) parse = staticmethod(playlists.parse_xspf) class UriListPlaylistTest(unittest.TestCase): valid = URILIST invalid = BAD parse = staticmethod(playlists.parse_urilist) def test_parse_valid_playlist(self): uris = list(self.parse(self.valid)) self.assertEqual(uris, EXPECTED) def test_parse_invalid_playlist(self): uris = list(self.parse(self.invalid)) self.assertEqual(uris, []) Mopidy-2.0.0/tests/internal/test_http.py0000664000175000017500000000277712600333733020543 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import mock import pytest import requests import responses from mopidy.internal import http TIMEOUT = 1000 URI = 'http://example.com/foo.txt' BODY = "This is the contents of foo.txt." @pytest.fixture def session(): return requests.Session() @pytest.fixture def session_mock(): return mock.Mock(spec=requests.Session) @responses.activate def test_download_on_server_side_error(session, caplog): responses.add(responses.GET, URI, body=BODY, status=500) result = http.download(session, URI) assert result is None assert 'Problem downloading' in caplog.text() def test_download_times_out_if_connection_times_out(session_mock, caplog): session_mock.get.side_effect = requests.exceptions.Timeout result = http.download(session_mock, URI, timeout=1.0) session_mock.get.assert_called_once_with(URI, timeout=1.0, stream=True) assert result is None assert ( 'Download of %r failed due to connection timeout after 1.000s' % URI in caplog.text()) @responses.activate def test_download_times_out_if_download_is_slow(session, caplog): responses.add(responses.GET, URI, body=BODY, content_type='text/plain') with mock.patch.object(http, 'time') as time_mock: time_mock.time.side_effect = [0, TIMEOUT + 1] result = http.download(session, URI) assert result is None assert ( 'Download of %r failed due to download taking more than 1.000s' % URI in caplog.text()) Mopidy-2.0.0/tests/internal/test_validation.py0000664000175000017500000001311712614502604021704 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals from pytest import raises from mopidy import compat, exceptions from mopidy.internal import validation def test_check_boolean_with_valid_values(): for value in (True, False): validation.check_boolean(value) def test_check_boolean_with_other_values(): for value in 1, 0, None, '', list(), tuple(): with raises(exceptions.ValidationError): validation.check_boolean(value) def test_check_boolean_error_message(): with raises(exceptions.ValidationError) as excinfo: validation.check_boolean(1234) assert 'Expected a boolean, not 1234' == str(excinfo.value) def test_check_choice_with_valid_values(): for value, choices in (2, (1, 2, 3)), ('abc', ('abc', 'def')): validation.check_choice(value, choices) def test_check_choice_with_invalid_values(): for value, choices in (5, (1, 2, 3)), ('xyz', ('abc', 'def')): with raises(exceptions.ValidationError): validation.check_choice(value, choices) def test_check_choice_error_message(): with raises(exceptions.ValidationError) as excinfo: validation.check_choice(5, (1, 2, 3)) assert 'Expected one of (1, 2, 3), not 5' == str(excinfo.value) def test_check_instance_with_valid_choices(): for value, cls in ((True, bool), ('a', compat.text_type), (123, int)): validation.check_instance(value, cls) def test_check_instance_with_invalid_values(): for value, cls in (1, str), ('abc', int): with raises(exceptions.ValidationError): validation.check_instance(value, cls) def test_check_instance_error_message(): with raises(exceptions.ValidationError) as excinfo: validation.check_instance(1, dict) assert 'Expected a dict instance, not 1' == str(excinfo.value) def test_check_instances_with_valid_values(): validation.check_instances([], int) validation.check_instances([1, 2], int) validation.check_instances((1, 2), int) def test_check_instances_with_invalid_values(): with raises(exceptions.ValidationError): validation.check_instances('abc', compat.string_types) with raises(exceptions.ValidationError): validation.check_instances(['abc', 123], compat.string_types) with raises(exceptions.ValidationError): validation.check_instances(None, compat.string_types) with raises(exceptions.ValidationError): validation.check_instances([None], compat.string_types) with raises(exceptions.ValidationError): validation.check_instances(iter(['abc']), compat.string_types) def test_check_instances_error_message(): with raises(exceptions.ValidationError) as excinfo: validation.check_instances([1], compat.string_types) assert 'Expected a list of basestring, not [1]' == str(excinfo.value) def test_check_query_valid_values(): for value in {}, {'any': []}, {'any': ['abc']}: validation.check_query(value) def test_check_query_random_iterables(): for value in None, tuple(), list(), 'abc': with raises(exceptions.ValidationError): validation.check_query(value) def test_check_mapping_error_message(): with raises(exceptions.ValidationError) as excinfo: validation.check_query([]) assert 'Expected a query dictionary, not []' == str(excinfo.value) def test_check_query_invalid_fields(): for value in 'wrong', 'bar', 'foo', 'tlid': with raises(exceptions.ValidationError): validation.check_query({value: []}) def test_check_field_error_message(): with raises(exceptions.ValidationError) as excinfo: validation.check_query({'wrong': ['abc']}) assert 'Expected query field to be one of ' in str(excinfo.value) def test_check_query_invalid_values(): for value in '', None, 'foo', 123, [''], [None], iter(['abc']): with raises(exceptions.ValidationError): validation.check_query({'any': value}) def test_check_values_error_message(): with raises(exceptions.ValidationError) as excinfo: validation.check_query({'any': 'abc'}) assert 'Expected "any" to be list of strings, not' in str(excinfo.value) def test_check_uri_with_valid_values(): for value in 'foobar:', 'http://example.com', 'git+http://example.com': validation.check_uri(value) def test_check_uri_with_invalid_values(): # Note that tuple catches a potential bug with using "'foo' % arg" for # formatting. for value in ('foobar', 'htt p://example.com', None, 1234, tuple()): with raises(exceptions.ValidationError): validation.check_uri(value) def test_check_uri_error_message(): with raises(exceptions.ValidationError) as excinfo: validation.check_uri('testing') assert "Expected a valid URI, not u'testing'" == str(excinfo.value) def test_check_uris_with_valid_values(): validation.check_uris([]) validation.check_uris(['foobar:']) validation.check_uris(('foobar:',)) def test_check_uris_with_invalid_values(): with raises(exceptions.ValidationError): validation.check_uris('foobar:') with raises(exceptions.ValidationError): validation.check_uris(None) with raises(exceptions.ValidationError): validation.check_uris([None]) with raises(exceptions.ValidationError): validation.check_uris(['foobar:', 'foobar']) with raises(exceptions.ValidationError): validation.check_uris(iter(['http://example.com'])) def test_check_uris_error_message(): with raises(exceptions.ValidationError) as excinfo: validation.check_uris('testing') assert "Expected a list of URIs, not u'testing'" == str(excinfo.value) Mopidy-2.0.0/tests/internal/test_path.py0000664000175000017500000003457412660436420020523 0ustar jodaljodal00000000000000# encoding: utf-8 from __future__ import absolute_import, unicode_literals import os import shutil import tempfile import unittest from mopidy import compat, exceptions from mopidy.internal import path from mopidy.internal.gi import GLib import tests class GetOrCreateDirTest(unittest.TestCase): def setUp(self): # noqa: N802 self.parent = tempfile.mkdtemp() def tearDown(self): # noqa: N802 if os.path.isdir(self.parent): shutil.rmtree(self.parent) def test_creating_dir(self): dir_path = os.path.join(self.parent, b'test') self.assert_(not os.path.exists(dir_path)) created = path.get_or_create_dir(dir_path) self.assert_(os.path.exists(dir_path)) self.assert_(os.path.isdir(dir_path)) self.assertEqual(created, dir_path) def test_creating_nested_dirs(self): level2_dir = os.path.join(self.parent, b'test') level3_dir = os.path.join(self.parent, b'test', b'test') self.assert_(not os.path.exists(level2_dir)) self.assert_(not os.path.exists(level3_dir)) created = path.get_or_create_dir(level3_dir) self.assert_(os.path.exists(level2_dir)) self.assert_(os.path.isdir(level2_dir)) self.assert_(os.path.exists(level3_dir)) self.assert_(os.path.isdir(level3_dir)) self.assertEqual(created, level3_dir) def test_creating_existing_dir(self): created = path.get_or_create_dir(self.parent) self.assert_(os.path.exists(self.parent)) self.assert_(os.path.isdir(self.parent)) self.assertEqual(created, self.parent) def test_create_dir_with_name_of_existing_file_throws_oserror(self): conflicting_file = os.path.join(self.parent, b'test') open(conflicting_file, 'w').close() dir_path = os.path.join(self.parent, b'test') with self.assertRaises(OSError): path.get_or_create_dir(dir_path) def test_create_dir_with_unicode(self): with self.assertRaises(ValueError): dir_path = compat.text_type(os.path.join(self.parent, b'test')) path.get_or_create_dir(dir_path) def test_create_dir_with_none(self): with self.assertRaises(ValueError): path.get_or_create_dir(None) class GetOrCreateFileTest(unittest.TestCase): def setUp(self): # noqa: N802 self.parent = tempfile.mkdtemp() def tearDown(self): # noqa: N802 if os.path.isdir(self.parent): shutil.rmtree(self.parent) def test_creating_file(self): file_path = os.path.join(self.parent, b'test') self.assert_(not os.path.exists(file_path)) created = path.get_or_create_file(file_path) self.assert_(os.path.exists(file_path)) self.assert_(os.path.isfile(file_path)) self.assertEqual(created, file_path) def test_creating_nested_file(self): level2_dir = os.path.join(self.parent, b'test') file_path = os.path.join(self.parent, b'test', b'test') self.assert_(not os.path.exists(level2_dir)) self.assert_(not os.path.exists(file_path)) created = path.get_or_create_file(file_path) self.assert_(os.path.exists(level2_dir)) self.assert_(os.path.isdir(level2_dir)) self.assert_(os.path.exists(file_path)) self.assert_(os.path.isfile(file_path)) self.assertEqual(created, file_path) def test_creating_existing_file(self): file_path = os.path.join(self.parent, b'test') path.get_or_create_file(file_path) created = path.get_or_create_file(file_path) self.assert_(os.path.exists(file_path)) self.assert_(os.path.isfile(file_path)) self.assertEqual(created, file_path) def test_create_file_with_name_of_existing_dir_throws_ioerror(self): conflicting_dir = os.path.join(self.parent) with self.assertRaises(IOError): path.get_or_create_file(conflicting_dir) def test_create_dir_with_unicode_filename_throws_value_error(self): with self.assertRaises(ValueError): file_path = compat.text_type(os.path.join(self.parent, b'test')) path.get_or_create_file(file_path) def test_create_file_with_none_filename_throws_value_error(self): with self.assertRaises(ValueError): path.get_or_create_file(None) def test_create_dir_without_mkdir(self): file_path = os.path.join(self.parent, b'foo', b'bar') with self.assertRaises(IOError): path.get_or_create_file(file_path, mkdir=False) def test_create_dir_with_bytes_content(self): file_path = os.path.join(self.parent, b'test') created = path.get_or_create_file(file_path, content=b'foobar') with open(created) as fh: self.assertEqual(fh.read(), b'foobar') def test_create_dir_with_unicode_content(self): file_path = os.path.join(self.parent, b'test') created = path.get_or_create_file(file_path, content='foobaræøå') with open(created) as fh: self.assertEqual(fh.read(), b'foobar\xc3\xa6\xc3\xb8\xc3\xa5') class PathToFileURITest(unittest.TestCase): def test_simple_path(self): result = path.path_to_uri('/etc/fstab') self.assertEqual(result, 'file:///etc/fstab') def test_space_in_path(self): result = path.path_to_uri('/tmp/test this') self.assertEqual(result, 'file:///tmp/test%20this') def test_unicode_in_path(self): result = path.path_to_uri('/tmp/æøå') self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5') def test_utf8_in_path(self): result = path.path_to_uri('/tmp/æøå'.encode('utf-8')) self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5') def test_latin1_in_path(self): result = path.path_to_uri('/tmp/æøå'.encode('latin-1')) self.assertEqual(result, 'file:///tmp/%E6%F8%E5') class UriToPathTest(unittest.TestCase): def test_simple_uri(self): result = path.uri_to_path('file:///etc/fstab') self.assertEqual(result, '/etc/fstab'.encode('utf-8')) def test_space_in_uri(self): result = path.uri_to_path('file:///tmp/test%20this') self.assertEqual(result, '/tmp/test this'.encode('utf-8')) def test_unicode_in_uri(self): result = path.uri_to_path('file:///tmp/%C3%A6%C3%B8%C3%A5') self.assertEqual(result, '/tmp/æøå'.encode('utf-8')) def test_latin1_in_uri(self): result = path.uri_to_path('file:///tmp/%E6%F8%E5') self.assertEqual(result, '/tmp/æøå'.encode('latin-1')) class SplitPathTest(unittest.TestCase): def test_empty_path(self): self.assertEqual([], path.split_path('')) def test_single_dir(self): self.assertEqual(['foo'], path.split_path('foo')) def test_dirs(self): self.assertEqual(['foo', 'bar', 'baz'], path.split_path('foo/bar/baz')) def test_initial_slash_is_ignored(self): self.assertEqual( ['foo', 'bar', 'baz'], path.split_path('/foo/bar/baz')) def test_only_slash(self): self.assertEqual([], path.split_path('/')) class ExpandPathTest(unittest.TestCase): # TODO: test via mocks? def test_empty_path(self): self.assertEqual(os.path.abspath(b'.'), path.expand_path(b'')) def test_absolute_path(self): self.assertEqual(b'/tmp/foo', path.expand_path(b'/tmp/foo')) def test_home_dir_expansion(self): self.assertEqual( os.path.expanduser(b'~/foo'), path.expand_path(b'~/foo')) def test_abspath(self): self.assertEqual(os.path.abspath(b'./foo'), path.expand_path(b'./foo')) def test_xdg_subsititution(self): self.assertEqual( GLib.get_user_data_dir() + b'/foo', path.expand_path(b'$XDG_DATA_DIR/foo')) def test_xdg_subsititution_unknown(self): self.assertIsNone( path.expand_path(b'/tmp/$XDG_INVALID_DIR/foo')) class FindMTimesTest(unittest.TestCase): maxDiff = None def setUp(self): # noqa: N802 self.tmpdir = tempfile.mkdtemp(b'.mopidy-tests') def tearDown(self): # noqa: N802 shutil.rmtree(self.tmpdir, ignore_errors=True) def mkdir(self, *args): name = os.path.join(self.tmpdir, *[bytes(a) for a in args]) os.mkdir(name) return name def touch(self, *args): name = os.path.join(self.tmpdir, *[bytes(a) for a in args]) open(name, 'w').close() return name def test_names_are_bytestrings(self): """We shouldn't be mixing in unicode for paths.""" result, errors = path.find_mtimes(tests.path_to_data_dir('')) for name in result.keys() + errors.keys(): self.assertEqual(name, tests.IsA(bytes)) def test_nonexistent_dir(self): """Non existent search roots are an error""" missing = os.path.join(self.tmpdir, 'does-not-exist') result, errors = path.find_mtimes(missing) self.assertEqual(result, {}) self.assertEqual(errors, {missing: tests.IsA(exceptions.FindError)}) def test_empty_dir(self): """Empty directories should not show up in results""" self.mkdir('empty') result, errors = path.find_mtimes(self.tmpdir) self.assertEqual(result, {}) self.assertEqual(errors, {}) def test_file_as_the_root(self): """Specifying a file as the root should just return the file""" single = self.touch('single') result, errors = path.find_mtimes(single) self.assertEqual(result, {single: tests.any_int}) self.assertEqual(errors, {}) def test_nested_directories(self): """Searching nested directories should find all files""" # Setup foo/bar and baz directories self.mkdir('foo') self.mkdir('foo', 'bar') self.mkdir('baz') # Touch foo/file foo/bar/file and baz/file foo_file = self.touch('foo', 'file') foo_bar_file = self.touch('foo', 'bar', 'file') baz_file = self.touch('baz', 'file') result, errors = path.find_mtimes(self.tmpdir) self.assertEqual(result, {foo_file: tests.any_int, foo_bar_file: tests.any_int, baz_file: tests.any_int}) self.assertEqual(errors, {}) def test_missing_permission_to_file(self): """Missing permissions to a file is not a search error""" target = self.touch('no-permission') os.chmod(target, 0) result, errors = path.find_mtimes(self.tmpdir) self.assertEqual({target: tests.any_int}, result) self.assertEqual({}, errors) def test_missing_permission_to_directory(self): """Missing permissions to a directory is an error""" directory = self.mkdir('no-permission') os.chmod(directory, 0) result, errors = path.find_mtimes(self.tmpdir) self.assertEqual({}, result) self.assertEqual({directory: tests.IsA(exceptions.FindError)}, errors) def test_symlinks_are_ignored(self): """By default symlinks should be treated as an error""" target = self.touch('target') link = os.path.join(self.tmpdir, 'link') os.symlink(target, link) result, errors = path.find_mtimes(self.tmpdir) self.assertEqual(result, {target: tests.any_int}) self.assertEqual(errors, {link: tests.IsA(exceptions.FindError)}) def test_symlink_to_file_as_root_is_followed(self): """Passing a symlink as the root should be followed when follow=True""" target = self.touch('target') link = os.path.join(self.tmpdir, 'link') os.symlink(target, link) result, errors = path.find_mtimes(link, follow=True) self.assertEqual({link: tests.any_int}, result) self.assertEqual({}, errors) def test_symlink_to_directory_is_followed(self): pass def test_symlink_pointing_at_itself_fails(self): """Symlink pointing at itself should give as an OS error""" link = os.path.join(self.tmpdir, 'link') os.symlink(link, link) result, errors = path.find_mtimes(link, follow=True) self.assertEqual({}, result) self.assertEqual({link: tests.IsA(exceptions.FindError)}, errors) def test_symlink_pointing_at_parent_fails(self): """We should detect a loop via the parent and give up on the branch""" os.symlink(self.tmpdir, os.path.join(self.tmpdir, 'link')) result, errors = path.find_mtimes(self.tmpdir, follow=True) self.assertEqual({}, result) self.assertEqual(1, len(errors)) self.assertEqual(tests.IsA(Exception), errors.values()[0]) def test_indirect_symlink_loop(self): """More indirect loops should also be detected""" # Setup tmpdir/directory/loop where loop points to tmpdir directory = os.path.join(self.tmpdir, b'directory') loop = os.path.join(directory, b'loop') os.mkdir(directory) os.symlink(self.tmpdir, loop) result, errors = path.find_mtimes(self.tmpdir, follow=True) self.assertEqual({}, result) self.assertEqual({loop: tests.IsA(Exception)}, errors) def test_symlink_branches_are_not_excluded(self): """Using symlinks to make a file show up multiple times should work""" self.mkdir('directory') target = self.touch('directory', 'target') link1 = os.path.join(self.tmpdir, b'link1') link2 = os.path.join(self.tmpdir, b'link2') os.symlink(target, link1) os.symlink(target, link2) expected = {target: tests.any_int, link1: tests.any_int, link2: tests.any_int} result, errors = path.find_mtimes(self.tmpdir, follow=True) self.assertEqual(expected, result) self.assertEqual({}, errors) def test_gives_mtime_in_milliseconds(self): fname = self.touch('foobar') os.utime(fname, (1, 3.14159265)) result, errors = path.find_mtimes(fname) self.assertEqual(len(result), 1) mtime, = result.values() self.assertEqual(mtime, 3141) self.assertEqual(errors, {}) # TODO: kill this in favour of just os.path.getmtime + mocks class MtimeTest(unittest.TestCase): def tearDown(self): # noqa: N802 path.mtime.undo_fake() def test_mtime_of_current_dir(self): mtime_dir = int(os.stat('.').st_mtime) self.assertEqual(mtime_dir, path.mtime('.')) def test_fake_time_is_returned(self): path.mtime.set_fake_time(123456) self.assertEqual(path.mtime('.'), 123456) Mopidy-2.0.0/tests/internal/network/0000775000175000017500000000000012660436443017637 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/internal/network/test_utils.py0000664000175000017500000000374512575004517022417 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import socket import unittest from mock import Mock, patch from mopidy.internal import network class FormatHostnameTest(unittest.TestCase): @patch('mopidy.internal.network.has_ipv6', True) def test_format_hostname_prefixes_ipv4_addresses_when_ipv6_available(self): network.has_ipv6 = True self.assertEqual(network.format_hostname('0.0.0.0'), '::ffff:0.0.0.0') self.assertEqual(network.format_hostname('1.0.0.1'), '::ffff:1.0.0.1') @patch('mopidy.internal.network.has_ipv6', False) def test_format_hostname_does_nothing_when_only_ipv4_available(self): network.has_ipv6 = False self.assertEqual(network.format_hostname('0.0.0.0'), '0.0.0.0') class TryIPv6SocketTest(unittest.TestCase): @patch('socket.has_ipv6', False) def test_system_that_claims_no_ipv6_support(self): self.assertFalse(network.try_ipv6_socket()) @patch('socket.has_ipv6', True) @patch('socket.socket') def test_system_with_broken_ipv6(self, socket_mock): socket_mock.side_effect = IOError() self.assertFalse(network.try_ipv6_socket()) @patch('socket.has_ipv6', True) @patch('socket.socket') def test_with_working_ipv6(self, socket_mock): socket_mock.return_value = Mock() self.assertTrue(network.try_ipv6_socket()) class CreateSocketTest(unittest.TestCase): @patch('mopidy.internal.network.has_ipv6', False) @patch('socket.socket') def test_ipv4_socket(self, socket_mock): network.create_socket() self.assertEqual( socket_mock.call_args[0], (socket.AF_INET, socket.SOCK_STREAM)) @patch('mopidy.internal.network.has_ipv6', True) @patch('socket.socket') def test_ipv6_socket(self, socket_mock): network.create_socket() self.assertEqual( socket_mock.call_args[0], (socket.AF_INET6, socket.SOCK_STREAM)) @unittest.SkipTest def test_ipv6_only_is_set(self): pass Mopidy-2.0.0/tests/internal/network/__init__.py0000664000175000017500000000007112575004517021744 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals Mopidy-2.0.0/tests/internal/network/test_lineprotocol.py0000664000175000017500000002772512614502604023766 0ustar jodaljodal00000000000000# encoding: utf-8 from __future__ import absolute_import, unicode_literals import re import unittest from mock import Mock, sentinel from mopidy import compat from mopidy.internal import network from tests import any_unicode class LineProtocolTest(unittest.TestCase): def setUp(self): # noqa: N802 self.mock = Mock(spec=network.LineProtocol) self.mock.terminator = network.LineProtocol.terminator self.mock.encoding = network.LineProtocol.encoding self.mock.delimiter = network.LineProtocol.delimiter self.mock.prevent_timeout = False def test_init_stores_values_in_attributes(self): delimiter = re.compile(network.LineProtocol.terminator) network.LineProtocol.__init__(self.mock, sentinel.connection) self.assertEqual(sentinel.connection, self.mock.connection) self.assertEqual('', self.mock.recv_buffer) self.assertEqual(delimiter, self.mock.delimiter) self.assertFalse(self.mock.prevent_timeout) def test_init_compiles_delimiter(self): self.mock.delimiter = '\r?\n' delimiter = re.compile('\r?\n') network.LineProtocol.__init__(self.mock, sentinel.connection) self.assertEqual(delimiter, self.mock.delimiter) def test_on_receive_close_calls_stop(self): self.mock.connection = Mock(spec=network.Connection) self.mock.recv_buffer = '' self.mock.parse_lines.return_value = [] network.LineProtocol.on_receive(self.mock, {'close': True}) self.mock.connection.stop.assert_called_once_with(any_unicode) def test_on_receive_no_new_lines_adds_to_recv_buffer(self): self.mock.connection = Mock(spec=network.Connection) self.mock.recv_buffer = '' self.mock.parse_lines.return_value = [] network.LineProtocol.on_receive(self.mock, {'received': 'data'}) self.assertEqual('data', self.mock.recv_buffer) self.mock.parse_lines.assert_called_once_with() self.assertEqual(0, self.mock.on_line_received.call_count) def test_on_receive_toggles_timeout(self): self.mock.connection = Mock(spec=network.Connection) self.mock.recv_buffer = '' self.mock.parse_lines.return_value = [] network.LineProtocol.on_receive(self.mock, {'received': 'data'}) self.mock.connection.disable_timeout.assert_called_once_with() self.mock.connection.enable_timeout.assert_called_once_with() def test_on_receive_toggles_unless_prevent_timeout_is_set(self): self.mock.connection = Mock(spec=network.Connection) self.mock.recv_buffer = '' self.mock.parse_lines.return_value = [] self.mock.prevent_timeout = True network.LineProtocol.on_receive(self.mock, {'received': 'data'}) self.mock.connection.disable_timeout.assert_called_once_with() self.assertEqual(0, self.mock.connection.enable_timeout.call_count) def test_on_receive_no_new_lines_calls_parse_lines(self): self.mock.connection = Mock(spec=network.Connection) self.mock.recv_buffer = '' self.mock.parse_lines.return_value = [] network.LineProtocol.on_receive(self.mock, {'received': 'data'}) self.mock.parse_lines.assert_called_once_with() self.assertEqual(0, self.mock.on_line_received.call_count) def test_on_receive_with_new_line_calls_decode(self): self.mock.connection = Mock(spec=network.Connection) self.mock.recv_buffer = '' self.mock.parse_lines.return_value = [sentinel.line] network.LineProtocol.on_receive(self.mock, {'received': 'data\n'}) self.mock.parse_lines.assert_called_once_with() self.mock.decode.assert_called_once_with(sentinel.line) def test_on_receive_with_new_line_calls_on_recieve(self): self.mock.connection = Mock(spec=network.Connection) self.mock.recv_buffer = '' self.mock.parse_lines.return_value = [sentinel.line] self.mock.decode.return_value = sentinel.decoded network.LineProtocol.on_receive(self.mock, {'received': 'data\n'}) self.mock.on_line_received.assert_called_once_with(sentinel.decoded) def test_on_receive_with_new_line_with_failed_decode(self): self.mock.connection = Mock(spec=network.Connection) self.mock.recv_buffer = '' self.mock.parse_lines.return_value = [sentinel.line] self.mock.decode.return_value = None network.LineProtocol.on_receive(self.mock, {'received': 'data\n'}) self.assertEqual(0, self.mock.on_line_received.call_count) def test_on_receive_with_new_lines_calls_on_recieve(self): self.mock.connection = Mock(spec=network.Connection) self.mock.recv_buffer = '' self.mock.parse_lines.return_value = ['line1', 'line2'] self.mock.decode.return_value = sentinel.decoded network.LineProtocol.on_receive( self.mock, {'received': 'line1\nline2\n'}) self.assertEqual(2, self.mock.on_line_received.call_count) def test_parse_lines_emtpy_buffer(self): self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = '' lines = network.LineProtocol.parse_lines(self.mock) with self.assertRaises(StopIteration): lines.next() def test_parse_lines_no_terminator(self): self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'data' lines = network.LineProtocol.parse_lines(self.mock) with self.assertRaises(StopIteration): lines.next() def test_parse_lines_termintor(self): self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'data\n' lines = network.LineProtocol.parse_lines(self.mock) self.assertEqual('data', lines.next()) with self.assertRaises(StopIteration): lines.next() self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_termintor_with_carriage_return(self): self.mock.delimiter = re.compile(r'\r?\n') self.mock.recv_buffer = 'data\r\n' lines = network.LineProtocol.parse_lines(self.mock) self.assertEqual('data', lines.next()) with self.assertRaises(StopIteration): lines.next() self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_no_data_before_terminator(self): self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = '\n' lines = network.LineProtocol.parse_lines(self.mock) self.assertEqual('', lines.next()) with self.assertRaises(StopIteration): lines.next() self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_extra_data_after_terminator(self): self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'data1\ndata2' lines = network.LineProtocol.parse_lines(self.mock) self.assertEqual('data1', lines.next()) with self.assertRaises(StopIteration): lines.next() self.assertEqual('data2', self.mock.recv_buffer) def test_parse_lines_unicode(self): self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'æøå\n'.encode('utf-8') lines = network.LineProtocol.parse_lines(self.mock) self.assertEqual('æøå'.encode('utf-8'), lines.next()) with self.assertRaises(StopIteration): lines.next() self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_multiple_lines(self): self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'abc\ndef\nghi\njkl' lines = network.LineProtocol.parse_lines(self.mock) self.assertEqual('abc', lines.next()) self.assertEqual('def', lines.next()) self.assertEqual('ghi', lines.next()) with self.assertRaises(StopIteration): lines.next() self.assertEqual('jkl', self.mock.recv_buffer) def test_parse_lines_multiple_calls(self): self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'data1' lines = network.LineProtocol.parse_lines(self.mock) with self.assertRaises(StopIteration): lines.next() self.assertEqual('data1', self.mock.recv_buffer) self.mock.recv_buffer += '\ndata2' lines = network.LineProtocol.parse_lines(self.mock) self.assertEqual('data1', lines.next()) with self.assertRaises(StopIteration): lines.next() self.assertEqual('data2', self.mock.recv_buffer) def test_send_lines_called_with_no_lines(self): self.mock.connection = Mock(spec=network.Connection) network.LineProtocol.send_lines(self.mock, []) self.assertEqual(0, self.mock.encode.call_count) self.assertEqual(0, self.mock.connection.queue_send.call_count) def test_send_lines_calls_join_lines(self): self.mock.connection = Mock(spec=network.Connection) self.mock.join_lines.return_value = 'lines' network.LineProtocol.send_lines(self.mock, sentinel.lines) self.mock.join_lines.assert_called_once_with(sentinel.lines) def test_send_line_encodes_joined_lines_with_final_terminator(self): self.mock.connection = Mock(spec=network.Connection) self.mock.join_lines.return_value = 'lines\n' network.LineProtocol.send_lines(self.mock, sentinel.lines) self.mock.encode.assert_called_once_with('lines\n') def test_send_lines_sends_encoded_string(self): self.mock.connection = Mock(spec=network.Connection) self.mock.join_lines.return_value = 'lines' self.mock.encode.return_value = sentinel.data network.LineProtocol.send_lines(self.mock, sentinel.lines) self.mock.connection.queue_send.assert_called_once_with(sentinel.data) def test_join_lines_returns_empty_string_for_no_lines(self): self.assertEqual('', network.LineProtocol.join_lines(self.mock, [])) def test_join_lines_returns_joined_lines(self): self.assertEqual('1\n2\n', network.LineProtocol.join_lines( self.mock, ['1', '2'])) def test_decode_calls_decode_on_string(self): string = Mock() network.LineProtocol.decode(self.mock, string) string.decode.assert_called_once_with(self.mock.encoding) def test_decode_plain_ascii(self): result = network.LineProtocol.decode(self.mock, 'abc') self.assertEqual('abc', result) self.assertEqual(compat.text_type, type(result)) def test_decode_utf8(self): result = network.LineProtocol.decode( self.mock, 'æøå'.encode('utf-8')) self.assertEqual('æøå', result) self.assertEqual(compat.text_type, type(result)) def test_decode_invalid_data(self): string = Mock() string.decode.side_effect = UnicodeError network.LineProtocol.decode(self.mock, string) self.mock.stop.assert_called_once_with() def test_encode_calls_encode_on_string(self): string = Mock() network.LineProtocol.encode(self.mock, string) string.encode.assert_called_once_with(self.mock.encoding) def test_encode_plain_ascii(self): result = network.LineProtocol.encode(self.mock, 'abc') self.assertEqual('abc', result) self.assertEqual(str, type(result)) def test_encode_utf8(self): result = network.LineProtocol.encode(self.mock, 'æøå') self.assertEqual('æøå'.encode('utf-8'), result) self.assertEqual(str, type(result)) def test_encode_invalid_data(self): string = Mock() string.encode.side_effect = UnicodeError network.LineProtocol.encode(self.mock, string) self.mock.stop.assert_called_once_with() def test_host_property(self): mock = Mock(spec=network.Connection) mock.host = sentinel.host lineprotocol = network.LineProtocol(mock) self.assertEqual(sentinel.host, lineprotocol.host) def test_port_property(self): mock = Mock(spec=network.Connection) mock.port = sentinel.port lineprotocol = network.LineProtocol(mock) self.assertEqual(sentinel.port, lineprotocol.port) Mopidy-2.0.0/tests/internal/network/test_connection.py0000664000175000017500000005317312660436420023413 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import errno import logging import socket import unittest from mock import Mock, call, patch, sentinel import pykka from mopidy.internal import network from mopidy.internal.gi import GObject from tests import any_int, any_unicode class ConnectionTest(unittest.TestCase): def setUp(self): # noqa: N802 self.mock = Mock(spec=network.Connection) def test_init_ensure_nonblocking_io(self): sock = Mock(spec=socket.SocketType) network.Connection.__init__( self.mock, Mock(), {}, sock, (sentinel.host, sentinel.port), sentinel.timeout) sock.setblocking.assert_called_once_with(False) def test_init_starts_actor(self): protocol = Mock(spec=network.LineProtocol) network.Connection.__init__( self.mock, protocol, {}, Mock(), (sentinel.host, sentinel.port), sentinel.timeout) protocol.start.assert_called_once_with(self.mock) def test_init_enables_recv_and_timeout(self): network.Connection.__init__( self.mock, Mock(), {}, Mock(), (sentinel.host, sentinel.port), sentinel.timeout) self.mock.enable_recv.assert_called_once_with() self.mock.enable_timeout.assert_called_once_with() def test_init_stores_values_in_attributes(self): addr = (sentinel.host, sentinel.port) protocol = Mock(spec=network.LineProtocol) protocol_kwargs = {} sock = Mock(spec=socket.SocketType) network.Connection.__init__( self.mock, protocol, protocol_kwargs, sock, addr, sentinel.timeout) self.assertEqual(sock, self.mock.sock) self.assertEqual(protocol, self.mock.protocol) self.assertEqual(protocol_kwargs, self.mock.protocol_kwargs) self.assertEqual(sentinel.timeout, self.mock.timeout) self.assertEqual(sentinel.host, self.mock.host) self.assertEqual(sentinel.port, self.mock.port) def test_init_handles_ipv6_addr(self): addr = ( sentinel.host, sentinel.port, sentinel.flowinfo, sentinel.scopeid) protocol = Mock(spec=network.LineProtocol) protocol_kwargs = {} sock = Mock(spec=socket.SocketType) network.Connection.__init__( self.mock, protocol, protocol_kwargs, sock, addr, sentinel.timeout) self.assertEqual(sentinel.host, self.mock.host) self.assertEqual(sentinel.port, self.mock.port) def test_stop_disables_recv_send_and_timeout(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock.sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) self.mock.disable_timeout.assert_called_once_with() self.mock.disable_recv.assert_called_once_with() self.mock.disable_send.assert_called_once_with() def test_stop_closes_socket(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock.sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) self.mock.sock.close.assert_called_once_with() def test_stop_closes_socket_error(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.close.side_effect = socket.error network.Connection.stop(self.mock, sentinel.reason) self.mock.sock.close.assert_called_once_with() def test_stop_stops_actor(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock.sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) self.mock.actor_ref.stop.assert_called_once_with(block=False) def test_stop_handles_actor_already_being_stopped(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock.actor_ref.stop.side_effect = pykka.ActorDeadError() self.mock.sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) self.mock.actor_ref.stop.assert_called_once_with(block=False) def test_stop_sets_stopping_to_true(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock.sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) self.assertEqual(True, self.mock.stopping) def test_stop_does_not_proceed_when_already_stopping(self): self.mock.stopping = True self.mock.actor_ref = Mock() self.mock.sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) self.assertEqual(0, self.mock.actor_ref.stop.call_count) self.assertEqual(0, self.mock.sock.close.call_count) @patch.object(network.logger, 'log', new=Mock()) def test_stop_logs_reason(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock.sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) network.logger.log.assert_called_once_with( logging.DEBUG, sentinel.reason) @patch.object(network.logger, 'log', new=Mock()) def test_stop_logs_reason_with_level(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock.sock = Mock(spec=socket.SocketType) network.Connection.stop( self.mock, sentinel.reason, level=sentinel.level) network.logger.log.assert_called_once_with( sentinel.level, sentinel.reason) @patch.object(network.logger, 'log', new=Mock()) def test_stop_logs_that_it_is_calling_itself(self): self.mock.stopping = True self.mock.actor_ref = Mock() self.mock.sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) network.logger.log(any_int, any_unicode) @patch.object(GObject, 'io_add_watch', new=Mock()) def test_enable_recv_registers_with_gobject(self): self.mock.recv_id = None self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.fileno.return_value = sentinel.fileno GObject.io_add_watch.return_value = sentinel.tag network.Connection.enable_recv(self.mock) GObject.io_add_watch.assert_called_once_with( sentinel.fileno, GObject.IO_IN | GObject.IO_ERR | GObject.IO_HUP, self.mock.recv_callback) self.assertEqual(sentinel.tag, self.mock.recv_id) @patch.object(GObject, 'io_add_watch', new=Mock()) def test_enable_recv_already_registered(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.recv_id = sentinel.tag network.Connection.enable_recv(self.mock) self.assertEqual(0, GObject.io_add_watch.call_count) def test_enable_recv_does_not_change_tag(self): self.mock.recv_id = sentinel.tag self.mock.sock = Mock(spec=socket.SocketType) network.Connection.enable_recv(self.mock) self.assertEqual(sentinel.tag, self.mock.recv_id) @patch.object(GObject, 'source_remove', new=Mock()) def test_disable_recv_deregisters(self): self.mock.recv_id = sentinel.tag network.Connection.disable_recv(self.mock) GObject.source_remove.assert_called_once_with(sentinel.tag) self.assertEqual(None, self.mock.recv_id) @patch.object(GObject, 'source_remove', new=Mock()) def test_disable_recv_already_deregistered(self): self.mock.recv_id = None network.Connection.disable_recv(self.mock) self.assertEqual(0, GObject.source_remove.call_count) self.assertEqual(None, self.mock.recv_id) def test_enable_recv_on_closed_socket(self): self.mock.recv_id = None self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.fileno.side_effect = socket.error(errno.EBADF, '') network.Connection.enable_recv(self.mock) self.mock.stop.assert_called_once_with(any_unicode) self.assertEqual(None, self.mock.recv_id) @patch.object(GObject, 'io_add_watch', new=Mock()) def test_enable_send_registers_with_gobject(self): self.mock.send_id = None self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.fileno.return_value = sentinel.fileno GObject.io_add_watch.return_value = sentinel.tag network.Connection.enable_send(self.mock) GObject.io_add_watch.assert_called_once_with( sentinel.fileno, GObject.IO_OUT | GObject.IO_ERR | GObject.IO_HUP, self.mock.send_callback) self.assertEqual(sentinel.tag, self.mock.send_id) @patch.object(GObject, 'io_add_watch', new=Mock()) def test_enable_send_already_registered(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.send_id = sentinel.tag network.Connection.enable_send(self.mock) self.assertEqual(0, GObject.io_add_watch.call_count) def test_enable_send_does_not_change_tag(self): self.mock.send_id = sentinel.tag self.mock.sock = Mock(spec=socket.SocketType) network.Connection.enable_send(self.mock) self.assertEqual(sentinel.tag, self.mock.send_id) @patch.object(GObject, 'source_remove', new=Mock()) def test_disable_send_deregisters(self): self.mock.send_id = sentinel.tag network.Connection.disable_send(self.mock) GObject.source_remove.assert_called_once_with(sentinel.tag) self.assertEqual(None, self.mock.send_id) @patch.object(GObject, 'source_remove', new=Mock()) def test_disable_send_already_deregistered(self): self.mock.send_id = None network.Connection.disable_send(self.mock) self.assertEqual(0, GObject.source_remove.call_count) self.assertEqual(None, self.mock.send_id) def test_enable_send_on_closed_socket(self): self.mock.send_id = None self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.fileno.side_effect = socket.error(errno.EBADF, '') network.Connection.enable_send(self.mock) self.assertEqual(None, self.mock.send_id) @patch.object(GObject, 'timeout_add_seconds', new=Mock()) def test_enable_timeout_clears_existing_timeouts(self): self.mock.timeout = 10 network.Connection.enable_timeout(self.mock) self.mock.disable_timeout.assert_called_once_with() @patch.object(GObject, 'timeout_add_seconds', new=Mock()) def test_enable_timeout_add_gobject_timeout(self): self.mock.timeout = 10 GObject.timeout_add_seconds.return_value = sentinel.tag network.Connection.enable_timeout(self.mock) GObject.timeout_add_seconds.assert_called_once_with( 10, self.mock.timeout_callback) self.assertEqual(sentinel.tag, self.mock.timeout_id) @patch.object(GObject, 'timeout_add_seconds', new=Mock()) def test_enable_timeout_does_not_add_timeout(self): self.mock.timeout = 0 network.Connection.enable_timeout(self.mock) self.assertEqual(0, GObject.timeout_add_seconds.call_count) self.mock.timeout = -1 network.Connection.enable_timeout(self.mock) self.assertEqual(0, GObject.timeout_add_seconds.call_count) self.mock.timeout = None network.Connection.enable_timeout(self.mock) self.assertEqual(0, GObject.timeout_add_seconds.call_count) def test_enable_timeout_does_not_call_disable_for_invalid_timeout(self): self.mock.timeout = 0 network.Connection.enable_timeout(self.mock) self.assertEqual(0, self.mock.disable_timeout.call_count) self.mock.timeout = -1 network.Connection.enable_timeout(self.mock) self.assertEqual(0, self.mock.disable_timeout.call_count) self.mock.timeout = None network.Connection.enable_timeout(self.mock) self.assertEqual(0, self.mock.disable_timeout.call_count) @patch.object(GObject, 'source_remove', new=Mock()) def test_disable_timeout_deregisters(self): self.mock.timeout_id = sentinel.tag network.Connection.disable_timeout(self.mock) GObject.source_remove.assert_called_once_with(sentinel.tag) self.assertEqual(None, self.mock.timeout_id) @patch.object(GObject, 'source_remove', new=Mock()) def test_disable_timeout_already_deregistered(self): self.mock.timeout_id = None network.Connection.disable_timeout(self.mock) self.assertEqual(0, GObject.source_remove.call_count) self.assertEqual(None, self.mock.timeout_id) def test_queue_send_acquires_and_releases_lock(self): self.mock.send_lock = Mock() self.mock.send_buffer = '' network.Connection.queue_send(self.mock, 'data') self.mock.send_lock.acquire.assert_called_once_with(True) self.mock.send_lock.release.assert_called_once_with() def test_queue_send_calls_send(self): self.mock.send_buffer = '' self.mock.send_lock = Mock() self.mock.send.return_value = '' network.Connection.queue_send(self.mock, 'data') self.mock.send.assert_called_once_with('data') self.assertEqual(0, self.mock.enable_send.call_count) self.assertEqual('', self.mock.send_buffer) def test_queue_send_calls_enable_send_for_partial_send(self): self.mock.send_buffer = '' self.mock.send_lock = Mock() self.mock.send.return_value = 'ta' network.Connection.queue_send(self.mock, 'data') self.mock.send.assert_called_once_with('data') self.mock.enable_send.assert_called_once_with() self.assertEqual('ta', self.mock.send_buffer) def test_queue_send_calls_send_with_existing_buffer(self): self.mock.send_buffer = 'foo' self.mock.send_lock = Mock() self.mock.send.return_value = '' network.Connection.queue_send(self.mock, 'bar') self.mock.send.assert_called_once_with('foobar') self.assertEqual(0, self.mock.enable_send.call_count) self.assertEqual('', self.mock.send_buffer) def test_recv_callback_respects_io_err(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.actor_ref = Mock() self.assertTrue(network.Connection.recv_callback( self.mock, sentinel.fd, GObject.IO_IN | GObject.IO_ERR)) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_respects_io_hup(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.actor_ref = Mock() self.assertTrue(network.Connection.recv_callback( self.mock, sentinel.fd, GObject.IO_IN | GObject.IO_HUP)) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_respects_io_hup_and_io_err(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.actor_ref = Mock() self.assertTrue(network.Connection.recv_callback( self.mock, sentinel.fd, GObject.IO_IN | GObject.IO_HUP | GObject.IO_ERR)) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_sends_data_to_actor(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.recv.return_value = 'data' self.mock.actor_ref = Mock() self.assertTrue(network.Connection.recv_callback( self.mock, sentinel.fd, GObject.IO_IN)) self.mock.actor_ref.tell.assert_called_once_with( {'received': 'data'}) def test_recv_callback_handles_dead_actors(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.recv.return_value = 'data' self.mock.actor_ref = Mock() self.mock.actor_ref.tell.side_effect = pykka.ActorDeadError() self.assertTrue(network.Connection.recv_callback( self.mock, sentinel.fd, GObject.IO_IN)) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_gets_no_data(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.recv.return_value = '' self.mock.actor_ref = Mock() self.assertTrue(network.Connection.recv_callback( self.mock, sentinel.fd, GObject.IO_IN)) self.assertEqual(self.mock.mock_calls, [ call.sock.recv(any_int), call.disable_recv(), call.actor_ref.tell({'close': True}), ]) def test_recv_callback_recoverable_error(self): self.mock.sock = Mock(spec=socket.SocketType) for error in (errno.EWOULDBLOCK, errno.EINTR): self.mock.sock.recv.side_effect = socket.error(error, '') self.assertTrue(network.Connection.recv_callback( self.mock, sentinel.fd, GObject.IO_IN)) self.assertEqual(0, self.mock.stop.call_count) def test_recv_callback_unrecoverable_error(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.recv.side_effect = socket.error self.assertTrue(network.Connection.recv_callback( self.mock, sentinel.fd, GObject.IO_IN)) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_respects_io_err(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.send.return_value = 1 self.mock.send_lock = Mock() self.mock.actor_ref = Mock() self.mock.send_buffer = '' self.assertTrue(network.Connection.send_callback( self.mock, sentinel.fd, GObject.IO_IN | GObject.IO_ERR)) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_respects_io_hup(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.send.return_value = 1 self.mock.send_lock = Mock() self.mock.actor_ref = Mock() self.mock.send_buffer = '' self.assertTrue(network.Connection.send_callback( self.mock, sentinel.fd, GObject.IO_IN | GObject.IO_HUP)) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_respects_io_hup_and_io_err(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.send.return_value = 1 self.mock.send_lock = Mock() self.mock.actor_ref = Mock() self.mock.send_buffer = '' self.assertTrue(network.Connection.send_callback( self.mock, sentinel.fd, GObject.IO_IN | GObject.IO_HUP | GObject.IO_ERR)) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_acquires_and_releases_lock(self): self.mock.send_lock = Mock() self.mock.send_lock.acquire.return_value = True self.mock.send_buffer = '' self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.send.return_value = 0 self.assertTrue(network.Connection.send_callback( self.mock, sentinel.fd, GObject.IO_IN)) self.mock.send_lock.acquire.assert_called_once_with(False) self.mock.send_lock.release.assert_called_once_with() def test_send_callback_fails_to_acquire_lock(self): self.mock.send_lock = Mock() self.mock.send_lock.acquire.return_value = False self.mock.send_buffer = '' self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.send.return_value = 0 self.assertTrue(network.Connection.send_callback( self.mock, sentinel.fd, GObject.IO_IN)) self.mock.send_lock.acquire.assert_called_once_with(False) self.assertEqual(0, self.mock.sock.send.call_count) def test_send_callback_sends_all_data(self): self.mock.send_lock = Mock() self.mock.send_lock.acquire.return_value = True self.mock.send_buffer = 'data' self.mock.send.return_value = '' self.assertTrue(network.Connection.send_callback( self.mock, sentinel.fd, GObject.IO_IN)) self.mock.disable_send.assert_called_once_with() self.mock.send.assert_called_once_with('data') self.assertEqual('', self.mock.send_buffer) def test_send_callback_sends_partial_data(self): self.mock.send_lock = Mock() self.mock.send_lock.acquire.return_value = True self.mock.send_buffer = 'data' self.mock.send.return_value = 'ta' self.assertTrue(network.Connection.send_callback( self.mock, sentinel.fd, GObject.IO_IN)) self.mock.send.assert_called_once_with('data') self.assertEqual('ta', self.mock.send_buffer) def test_send_recoverable_error(self): self.mock.sock = Mock(spec=socket.SocketType) for error in (errno.EWOULDBLOCK, errno.EINTR): self.mock.sock.send.side_effect = socket.error(error, '') network.Connection.send(self.mock, 'data') self.assertEqual(0, self.mock.stop.call_count) def test_send_calls_socket_send(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.send.return_value = 4 self.assertEqual('', network.Connection.send(self.mock, 'data')) self.mock.sock.send.assert_called_once_with('data') def test_send_calls_socket_send_partial_send(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.send.return_value = 2 self.assertEqual('ta', network.Connection.send(self.mock, 'data')) self.mock.sock.send.assert_called_once_with('data') def test_send_unrecoverable_error(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.send.side_effect = socket.error self.assertEqual('', network.Connection.send(self.mock, 'data')) self.mock.stop.assert_called_once_with(any_unicode) def test_timeout_callback(self): self.mock.timeout = 10 self.assertFalse(network.Connection.timeout_callback(self.mock)) self.mock.stop.assert_called_once_with(any_unicode) Mopidy-2.0.0/tests/internal/network/test_server.py0000664000175000017500000001763512660436420022565 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import errno import socket import unittest from mock import Mock, patch, sentinel from mopidy.internal import network from mopidy.internal.gi import GObject from tests import any_int class ServerTest(unittest.TestCase): def setUp(self): # noqa: N802 self.mock = Mock(spec=network.Server) def test_init_calls_create_server_socket(self): network.Server.__init__( self.mock, sentinel.host, sentinel.port, sentinel.protocol) self.mock.create_server_socket.assert_called_once_with( sentinel.host, sentinel.port) def test_init_calls_register_server(self): sock = Mock(spec=socket.SocketType) sock.fileno.return_value = sentinel.fileno self.mock.create_server_socket.return_value = sock network.Server.__init__( self.mock, sentinel.host, sentinel.port, sentinel.protocol) self.mock.register_server_socket.assert_called_once_with( sentinel.fileno) def test_init_fails_on_fileno_call(self): sock = Mock(spec=socket.SocketType) sock.fileno.side_effect = socket.error self.mock.create_server_socket.return_value = sock with self.assertRaises(socket.error): network.Server.__init__( self.mock, sentinel.host, sentinel.port, sentinel.protocol) def test_init_stores_values_in_attributes(self): # This need to be a mock and no a sentinel as fileno() is called on it sock = Mock(spec=socket.SocketType) self.mock.create_server_socket.return_value = sock network.Server.__init__( self.mock, sentinel.host, sentinel.port, sentinel.protocol, max_connections=sentinel.max_connections, timeout=sentinel.timeout) self.assertEqual(sentinel.protocol, self.mock.protocol) self.assertEqual(sentinel.max_connections, self.mock.max_connections) self.assertEqual(sentinel.timeout, self.mock.timeout) self.assertEqual(sock, self.mock.server_socket) @patch.object(network, 'create_socket', spec=socket.SocketType) def test_create_server_socket_sets_up_listener(self, create_socket): sock = create_socket.return_value network.Server.create_server_socket( self.mock, sentinel.host, sentinel.port) sock.setblocking.assert_called_once_with(False) sock.bind.assert_called_once_with((sentinel.host, sentinel.port)) sock.listen.assert_called_once_with(any_int) @patch.object(network, 'create_socket', new=Mock()) def test_create_server_socket_fails(self): network.create_socket.side_effect = socket.error with self.assertRaises(socket.error): network.Server.create_server_socket( self.mock, sentinel.host, sentinel.port) @patch.object(network, 'create_socket', new=Mock()) def test_create_server_bind_fails(self): sock = network.create_socket.return_value sock.bind.side_effect = socket.error with self.assertRaises(socket.error): network.Server.create_server_socket( self.mock, sentinel.host, sentinel.port) @patch.object(network, 'create_socket', new=Mock()) def test_create_server_listen_fails(self): sock = network.create_socket.return_value sock.listen.side_effect = socket.error with self.assertRaises(socket.error): network.Server.create_server_socket( self.mock, sentinel.host, sentinel.port) @patch.object(GObject, 'io_add_watch', new=Mock()) def test_register_server_socket_sets_up_io_watch(self): network.Server.register_server_socket(self.mock, sentinel.fileno) GObject.io_add_watch.assert_called_once_with( sentinel.fileno, GObject.IO_IN, self.mock.handle_connection) def test_handle_connection(self): self.mock.accept_connection.return_value = ( sentinel.sock, sentinel.addr) self.mock.maximum_connections_exceeded.return_value = False self.assertTrue(network.Server.handle_connection( self.mock, sentinel.fileno, GObject.IO_IN)) self.mock.accept_connection.assert_called_once_with() self.mock.maximum_connections_exceeded.assert_called_once_with() self.mock.init_connection.assert_called_once_with( sentinel.sock, sentinel.addr) self.assertEqual(0, self.mock.reject_connection.call_count) def test_handle_connection_exceeded_connections(self): self.mock.accept_connection.return_value = ( sentinel.sock, sentinel.addr) self.mock.maximum_connections_exceeded.return_value = True self.assertTrue(network.Server.handle_connection( self.mock, sentinel.fileno, GObject.IO_IN)) self.mock.accept_connection.assert_called_once_with() self.mock.maximum_connections_exceeded.assert_called_once_with() self.mock.reject_connection.assert_called_once_with( sentinel.sock, sentinel.addr) self.assertEqual(0, self.mock.init_connection.call_count) def test_accept_connection(self): sock = Mock(spec=socket.SocketType) sock.accept.return_value = (sentinel.sock, sentinel.addr) self.mock.server_socket = sock sock, addr = network.Server.accept_connection(self.mock) self.assertEqual(sentinel.sock, sock) self.assertEqual(sentinel.addr, addr) def test_accept_connection_recoverable_error(self): sock = Mock(spec=socket.SocketType) self.mock.server_socket = sock for error in (errno.EAGAIN, errno.EINTR): sock.accept.side_effect = socket.error(error, '') with self.assertRaises(network.ShouldRetrySocketCall): network.Server.accept_connection(self.mock) # FIXME decide if this should be allowed to propegate def test_accept_connection_unrecoverable_error(self): sock = Mock(spec=socket.SocketType) self.mock.server_socket = sock sock.accept.side_effect = socket.error with self.assertRaises(socket.error): network.Server.accept_connection(self.mock) def test_maximum_connections_exceeded(self): self.mock.max_connections = 10 self.mock.number_of_connections.return_value = 11 self.assertTrue(network.Server.maximum_connections_exceeded(self.mock)) self.mock.number_of_connections.return_value = 10 self.assertTrue(network.Server.maximum_connections_exceeded(self.mock)) self.mock.number_of_connections.return_value = 9 self.assertFalse( network.Server.maximum_connections_exceeded(self.mock)) @patch('pykka.registry.ActorRegistry.get_by_class') def test_number_of_connections(self, get_by_class): self.mock.protocol = sentinel.protocol get_by_class.return_value = [1, 2, 3] self.assertEqual(3, network.Server.number_of_connections(self.mock)) get_by_class.return_value = [] self.assertEqual(0, network.Server.number_of_connections(self.mock)) @patch.object(network, 'Connection', new=Mock()) def test_init_connection(self): self.mock.protocol = sentinel.protocol self.mock.protocol_kwargs = {} self.mock.timeout = sentinel.timeout network.Server.init_connection(self.mock, sentinel.sock, sentinel.addr) network.Connection.assert_called_once_with( sentinel.protocol, {}, sentinel.sock, sentinel.addr, sentinel.timeout) def test_reject_connection(self): sock = Mock(spec=socket.SocketType) network.Server.reject_connection( self.mock, sock, (sentinel.host, sentinel.port)) sock.close.assert_called_once_with() def test_reject_connection_error(self): sock = Mock(spec=socket.SocketType) sock.close.side_effect = socket.error network.Server.reject_connection( self.mock, sock, (sentinel.host, sentinel.port)) sock.close.assert_called_once_with() Mopidy-2.0.0/tests/internal/__init__.py0000664000175000017500000000007112575004517020253 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals Mopidy-2.0.0/tests/internal/test_jsonrpc.py0000664000175000017500000005364412575004517021247 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import json import unittest import mock import pykka from mopidy import core, models from mopidy.internal import deprecation, jsonrpc from tests import dummy_backend class Calculator(object): def __init__(self): self._mem = None def model(self): return 'TI83' def add(self, a, b): """Returns the sum of the given numbers""" return a + b def sub(self, a, b): return a - b def set_mem(self, value): self._mem = value def get_mem(self): return self._mem def describe(self): return { 'add': 'Returns the sum of the terms', 'sub': 'Returns the diff of the terms', } def take_it_all(self, a, b, c=True, *args, **kwargs): pass def _secret(self): return 'Grand Unified Theory' def fail(self): raise ValueError('What did you expect?') class JsonRpcTestBase(unittest.TestCase): def setUp(self): # noqa: N802 self.backend = dummy_backend.create_proxy() self.calc = Calculator() with deprecation.ignore(): self.core = core.Core.start(backends=[self.backend]).proxy() self.jrw = jsonrpc.JsonRpcWrapper( objects={ 'hello': lambda: 'Hello, world!', 'calc': self.calc, 'core': self.core, 'core.playback': self.core.playback, 'core.tracklist': self.core.tracklist, 'get_uri_schemes': self.core.get_uri_schemes, }, encoders=[models.ModelJSONEncoder], decoders=[models.model_json_decoder]) def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() class JsonRpcSetupTest(JsonRpcTestBase): def test_empty_object_mounts_is_not_allowed(self): with self.assertRaises(AttributeError): jsonrpc.JsonRpcWrapper(objects={'': Calculator()}) class JsonRpcSerializationTest(JsonRpcTestBase): def test_handle_json_converts_from_and_to_json(self): self.jrw.handle_data = mock.Mock() self.jrw.handle_data.return_value = {'foo': 'response'} request = '{"foo": "request"}' response = self.jrw.handle_json(request) self.jrw.handle_data.assert_called_once_with({'foo': 'request'}) self.assertEqual(response, '{"foo": "response"}') def test_handle_json_decodes_mopidy_models(self): self.jrw.handle_data = mock.Mock() self.jrw.handle_data.return_value = [] request = '{"foo": {"__model__": "Artist", "name": "bar"}}' self.jrw.handle_json(request) self.jrw.handle_data.assert_called_once_with( {'foo': models.Artist(name='bar')}) def test_handle_json_encodes_mopidy_models(self): self.jrw.handle_data = mock.Mock() self.jrw.handle_data.return_value = {'foo': models.Artist(name='bar')} request = '[]' response = json.loads(self.jrw.handle_json(request)) self.assertIn('foo', response) self.assertIn('__model__', response['foo']) self.assertEqual(response['foo']['__model__'], 'Artist') self.assertIn('name', response['foo']) self.assertEqual(response['foo']['name'], 'bar') def test_handle_json_returns_nothing_for_notices(self): request = '{"jsonrpc": "2.0", "method": "core.get_uri_schemes"}' response = self.jrw.handle_json(request) self.assertEqual(response, None) def test_invalid_json_command_causes_parse_error(self): request = ( '{"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz]') response = self.jrw.handle_json(request) response = json.loads(response) self.assertEqual(response['jsonrpc'], '2.0') error = response['error'] self.assertEqual(error['code'], -32700) self.assertEqual(error['message'], 'Parse error') def test_invalid_json_batch_causes_parse_error(self): request = """[ {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"}, {"jsonrpc": "2.0", "method" ]""" response = self.jrw.handle_json(request) response = json.loads(response) self.assertEqual(response['jsonrpc'], '2.0') error = response['error'] self.assertEqual(error['code'], -32700) self.assertEqual(error['message'], 'Parse error') class JsonRpcSingleCommandTest(JsonRpcTestBase): def test_call_method_on_root(self): request = { 'jsonrpc': '2.0', 'method': 'hello', 'id': 1, } response = self.jrw.handle_data(request) self.assertEqual(response['jsonrpc'], '2.0') self.assertEqual(response['id'], 1) self.assertNotIn('error', response) self.assertEqual(response['result'], 'Hello, world!') def test_call_method_on_plain_object(self): request = { 'jsonrpc': '2.0', 'method': 'calc.model', 'id': 1, } response = self.jrw.handle_data(request) self.assertEqual(response['jsonrpc'], '2.0') self.assertEqual(response['id'], 1) self.assertNotIn('error', response) self.assertEqual(response['result'], 'TI83') def test_call_method_which_returns_dict_from_plain_object(self): request = { 'jsonrpc': '2.0', 'method': 'calc.describe', 'id': 1, } response = self.jrw.handle_data(request) self.assertEqual(response['jsonrpc'], '2.0') self.assertEqual(response['id'], 1) self.assertNotIn('error', response) self.assertIn('add', response['result']) self.assertIn('sub', response['result']) def test_call_method_on_actor_root(self): request = { 'jsonrpc': '2.0', 'method': 'core.get_uri_schemes', 'id': 1, } response = self.jrw.handle_data(request) self.assertEqual(response['jsonrpc'], '2.0') self.assertEqual(response['id'], 1) self.assertNotIn('error', response) self.assertEqual(response['result'], ['dummy']) def test_call_method_on_actor_member(self): request = { 'jsonrpc': '2.0', 'method': 'core.playback.get_time_position', 'id': 1, } response = self.jrw.handle_data(request) self.assertEqual(response['result'], 0) def test_call_method_which_is_a_directly_mounted_actor_member(self): # 'get_uri_schemes' isn't a regular callable, but a Pykka # CallableProxy. This test checks that CallableProxy objects are # threated by JsonRpcWrapper like any other callable. request = { 'jsonrpc': '2.0', 'method': 'get_uri_schemes', 'id': 1, } response = self.jrw.handle_data(request) self.assertEqual(response['jsonrpc'], '2.0') self.assertEqual(response['id'], 1) self.assertNotIn('error', response) self.assertEqual(response['result'], ['dummy']) def test_call_method_with_positional_params(self): request = { 'jsonrpc': '2.0', 'method': 'calc.add', 'params': [3, 4], 'id': 1, } response = self.jrw.handle_data(request) self.assertEqual(response['result'], 7) def test_call_methods_with_named_params(self): request = { 'jsonrpc': '2.0', 'method': 'calc.add', 'params': {'a': 3, 'b': 4}, 'id': 1, } response = self.jrw.handle_data(request) self.assertEqual(response['result'], 7) class JsonRpcSingleNotificationTest(JsonRpcTestBase): def test_notification_does_not_return_a_result(self): request = { 'jsonrpc': '2.0', 'method': 'core.get_uri_schemes', } response = self.jrw.handle_data(request) self.assertIsNone(response) def test_notification_makes_an_observable_change(self): self.assertEqual(self.calc.get_mem(), None) request = { 'jsonrpc': '2.0', 'method': 'calc.set_mem', 'params': [37], } response = self.jrw.handle_data(request) self.assertIsNone(response) self.assertEqual(self.calc.get_mem(), 37) def test_notification_unknown_method_returns_nothing(self): request = { 'jsonrpc': '2.0', 'method': 'bogus', 'params': ['bogus'], } response = self.jrw.handle_data(request) self.assertIsNone(response) class JsonRpcBatchTest(JsonRpcTestBase): def test_batch_of_only_commands_returns_all(self): self.core.tracklist.set_random(True).get() request = [ {'jsonrpc': '2.0', 'method': 'core.tracklist.get_repeat', 'id': 1}, {'jsonrpc': '2.0', 'method': 'core.tracklist.get_random', 'id': 2}, {'jsonrpc': '2.0', 'method': 'core.tracklist.get_single', 'id': 3}, ] response = self.jrw.handle_data(request) self.assertEqual(len(response), 3) response = dict((row['id'], row) for row in response) self.assertEqual(response[1]['result'], False) self.assertEqual(response[2]['result'], True) self.assertEqual(response[3]['result'], False) def test_batch_of_commands_and_notifications_returns_some(self): self.core.tracklist.set_random(True).get() request = [ {'jsonrpc': '2.0', 'method': 'core.tracklist.get_repeat'}, {'jsonrpc': '2.0', 'method': 'core.tracklist.get_random', 'id': 2}, {'jsonrpc': '2.0', 'method': 'core.tracklist.get_single', 'id': 3}, ] response = self.jrw.handle_data(request) self.assertEqual(len(response), 2) response = dict((row['id'], row) for row in response) self.assertNotIn(1, response) self.assertEqual(response[2]['result'], True) self.assertEqual(response[3]['result'], False) def test_batch_of_only_notifications_returns_nothing(self): self.core.tracklist.set_random(True).get() request = [ {'jsonrpc': '2.0', 'method': 'core.tracklist.get_repeat'}, {'jsonrpc': '2.0', 'method': 'core.tracklist.get_random'}, {'jsonrpc': '2.0', 'method': 'core.tracklist.get_single'}, ] response = self.jrw.handle_data(request) self.assertIsNone(response) class JsonRpcSingleCommandErrorTest(JsonRpcTestBase): def test_application_error_response(self): request = { 'jsonrpc': '2.0', 'method': 'calc.fail', 'params': [], 'id': 1, } response = self.jrw.handle_data(request) self.assertNotIn('result', response) error = response['error'] self.assertEqual(error['code'], 0) self.assertEqual(error['message'], 'Application error') data = error['data'] self.assertEqual(data['type'], 'ValueError') self.assertIn('What did you expect?', data['message']) self.assertIn('traceback', data) self.assertIn('Traceback (most recent call last):', data['traceback']) def test_missing_jsonrpc_member_causes_invalid_request_error(self): request = { 'method': 'core.get_uri_schemes', 'id': 1, } response = self.jrw.handle_data(request) self.assertIsNone(response['id']) error = response['error'] self.assertEqual(error['code'], -32600) self.assertEqual(error['message'], 'Invalid Request') self.assertEqual(error['data'], '"jsonrpc" member must be included') def test_wrong_jsonrpc_version_causes_invalid_request_error(self): request = { 'jsonrpc': '3.0', 'method': 'core.get_uri_schemes', 'id': 1, } response = self.jrw.handle_data(request) self.assertIsNone(response['id']) error = response['error'] self.assertEqual(error['code'], -32600) self.assertEqual(error['message'], 'Invalid Request') self.assertEqual(error['data'], '"jsonrpc" value must be "2.0"') def test_missing_method_member_causes_invalid_request_error(self): request = { 'jsonrpc': '2.0', 'id': 1, } response = self.jrw.handle_data(request) self.assertIsNone(response['id']) error = response['error'] self.assertEqual(error['code'], -32600) self.assertEqual(error['message'], 'Invalid Request') self.assertEqual(error['data'], '"method" member must be included') def test_invalid_method_value_causes_invalid_request_error(self): request = { 'jsonrpc': '2.0', 'method': 1, 'id': 1, } response = self.jrw.handle_data(request) self.assertIsNone(response['id']) error = response['error'] self.assertEqual(error['code'], -32600) self.assertEqual(error['message'], 'Invalid Request') self.assertEqual(error['data'], '"method" must be a string') def test_invalid_params_value_causes_invalid_request_error(self): request = { 'jsonrpc': '2.0', 'method': 'core.get_uri_schemes', 'params': 'foobar', 'id': 1, } response = self.jrw.handle_data(request) self.assertIsNone(response['id']) error = response['error'] self.assertEqual(error['code'], -32600) self.assertEqual(error['message'], 'Invalid Request') self.assertEqual( error['data'], '"params", if given, must be an array or an object') def test_method_on_without_object_causes_unknown_method_error(self): request = { 'jsonrpc': '2.0', 'method': 'bogus', 'id': 1, } response = self.jrw.handle_data(request) error = response['error'] self.assertEqual(error['code'], -32601) self.assertEqual(error['message'], 'Method not found') self.assertEqual( error['data'], 'Could not find object mount in method name "bogus"') def test_method_on_unknown_object_causes_unknown_method_error(self): request = { 'jsonrpc': '2.0', 'method': 'bogus.bogus', 'id': 1, } response = self.jrw.handle_data(request) error = response['error'] self.assertEqual(error['code'], -32601) self.assertEqual(error['message'], 'Method not found') self.assertEqual(error['data'], 'No object found at "bogus"') def test_unknown_method_on_known_object_causes_unknown_method_error(self): request = { 'jsonrpc': '2.0', 'method': 'core.bogus', 'id': 1, } response = self.jrw.handle_data(request) error = response['error'] self.assertEqual(error['code'], -32601) self.assertEqual(error['message'], 'Method not found') self.assertEqual( error['data'], 'Object mounted at "core" has no member "bogus"') def test_private_method_causes_unknown_method_error(self): request = { 'jsonrpc': '2.0', 'method': 'core._secret', 'id': 1, } response = self.jrw.handle_data(request) error = response['error'] self.assertEqual(error['code'], -32601) self.assertEqual(error['message'], 'Method not found') self.assertEqual(error['data'], 'Private methods are not exported') def test_invalid_params_causes_invalid_params_error(self): request = { 'jsonrpc': '2.0', 'method': 'core.get_uri_schemes', 'params': ['bogus'], 'id': 1, } response = self.jrw.handle_data(request) error = response['error'] self.assertEqual(error['code'], -32602) self.assertEqual(error['message'], 'Invalid params') data = error['data'] self.assertEqual(data['type'], 'TypeError') self.assertEqual( data['message'], 'get_uri_schemes() takes exactly 1 argument (2 given)') self.assertIn('traceback', data) self.assertIn('Traceback (most recent call last):', data['traceback']) class JsonRpcBatchErrorTest(JsonRpcTestBase): def test_empty_batch_list_causes_invalid_request_error(self): request = [] response = self.jrw.handle_data(request) self.assertIsNone(response['id']) error = response['error'] self.assertEqual(error['code'], -32600) self.assertEqual(error['message'], 'Invalid Request') self.assertEqual(error['data'], 'Batch list cannot be empty') def test_batch_with_invalid_command_causes_invalid_request_error(self): request = [1] response = self.jrw.handle_data(request) self.assertEqual(len(response), 1) response = response[0] self.assertIsNone(response['id']) error = response['error'] self.assertEqual(error['code'], -32600) self.assertEqual(error['message'], 'Invalid Request') self.assertEqual(error['data'], 'Request must be an object') def test_batch_with_invalid_commands_causes_invalid_request_error(self): request = [1, 2, 3] response = self.jrw.handle_data(request) self.assertEqual(len(response), 3) response = response[2] self.assertIsNone(response['id']) error = response['error'] self.assertEqual(error['code'], -32600) self.assertEqual(error['message'], 'Invalid Request') self.assertEqual(error['data'], 'Request must be an object') def test_batch_of_both_successfull_and_failing_requests(self): request = [ # Call with positional params {'jsonrpc': '2.0', 'method': 'core.playback.seek', 'params': [47], 'id': '1'}, # Notification {'jsonrpc': '2.0', 'method': 'core.tracklist.set_consume', 'params': [True]}, # Call with positional params {'jsonrpc': '2.0', 'method': 'core.tracklist.set_repeat', 'params': [False], 'id': '2'}, # Invalid request {'foo': 'boo'}, # Unknown method {'jsonrpc': '2.0', 'method': 'foo.get', 'params': {'name': 'myself'}, 'id': '5'}, # Call without params {'jsonrpc': '2.0', 'method': 'core.tracklist.get_random', 'id': '9'}, ] response = self.jrw.handle_data(request) self.assertEqual(len(response), 5) response = dict((row['id'], row) for row in response) self.assertEqual(response['1']['result'], False) self.assertEqual(response['2']['result'], None) self.assertEqual(response[None]['error']['code'], -32600) self.assertEqual(response['5']['error']['code'], -32601) self.assertEqual(response['9']['result'], False) class JsonRpcInspectorTest(JsonRpcTestBase): def test_empty_object_mounts_is_not_allowed(self): with self.assertRaises(AttributeError): jsonrpc.JsonRpcInspector(objects={'': Calculator}) def test_can_describe_method_on_root(self): inspector = jsonrpc.JsonRpcInspector({ 'hello': lambda: 'Hello, world!', }) methods = inspector.describe() self.assertIn('hello', methods) self.assertEqual(len(methods['hello']['params']), 0) def test_inspector_can_describe_an_object_with_methods(self): inspector = jsonrpc.JsonRpcInspector({ 'calc': Calculator, }) methods = inspector.describe() self.assertIn('calc.add', methods) self.assertEqual( methods['calc.add']['description'], 'Returns the sum of the given numbers') self.assertIn('calc.sub', methods) self.assertIn('calc.take_it_all', methods) self.assertNotIn('calc._secret', methods) self.assertNotIn('calc.__init__', methods) method = methods['calc.take_it_all'] self.assertIn('params', method) params = method['params'] self.assertEqual(params[0]['name'], 'a') self.assertNotIn('default', params[0]) self.assertEqual(params[1]['name'], 'b') self.assertNotIn('default', params[1]) self.assertEqual(params[2]['name'], 'c') self.assertEqual(params[2]['default'], True) self.assertEqual(params[3]['name'], 'args') self.assertNotIn('default', params[3]) self.assertEqual(params[3]['varargs'], True) self.assertEqual(params[4]['name'], 'kwargs') self.assertNotIn('default', params[4]) self.assertEqual(params[4]['kwargs'], True) def test_inspector_can_describe_a_bunch_of_large_classes(self): inspector = jsonrpc.JsonRpcInspector({ 'core.get_uri_schemes': core.Core.get_uri_schemes, 'core.library': core.LibraryController, 'core.playback': core.PlaybackController, 'core.playlists': core.PlaylistsController, 'core.tracklist': core.TracklistController, }) methods = inspector.describe() self.assertIn('core.get_uri_schemes', methods) self.assertEqual(len(methods['core.get_uri_schemes']['params']), 0) self.assertIn('core.library.lookup', methods.keys()) self.assertEqual( methods['core.library.lookup']['params'][0]['name'], 'uri') self.assertIn('core.playback.next', methods) self.assertEqual(len(methods['core.playback.next']['params']), 0) self.assertIn('core.playlists.get_playlists', methods) self.assertEqual( len(methods['core.playlists.get_playlists']['params']), 1) self.assertIn('core.tracklist.filter', methods.keys()) self.assertEqual( methods['core.tracklist.filter']['params'][0]['name'], 'criteria') self.assertEqual( methods['core.tracklist.filter']['params'][1]['name'], 'kwargs') self.assertEqual( methods['core.tracklist.filter']['params'][1]['kwargs'], True) Mopidy-2.0.0/tests/internal/test_deps.py0000664000175000017500000001130512660436420020505 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import platform import sys import unittest import mock import pkg_resources from mopidy.internal import deps from mopidy.internal.gi import Gst, gi class DepsTest(unittest.TestCase): def test_format_dependency_list(self): adapters = [ lambda: dict(name='Python', version='FooPython 2.7.3'), lambda: dict(name='Platform', version='Loonix 4.0.1'), lambda: dict( name='Pykka', version='1.1', path='/foo/bar', other='Quux'), lambda: dict(name='Foo'), lambda: dict(name='Mopidy', version='0.13', dependencies=[ dict(name='pylast', version='0.5', dependencies=[ dict(name='setuptools', version='0.6') ]) ]) ] result = deps.format_dependency_list(adapters) self.assertIn('Python: FooPython 2.7.3', result) self.assertIn('Platform: Loonix 4.0.1', result) self.assertIn('Pykka: 1.1 from /foo/bar', result) self.assertNotIn('/baz.py', result) self.assertIn('Detailed information: Quux', result) self.assertIn('Foo: not found', result) self.assertIn('Mopidy: 0.13', result) self.assertIn(' pylast: 0.5', result) self.assertIn(' setuptools: 0.6', result) def test_executable_info(self): result = deps.executable_info() self.assertEqual('Executable', result['name']) self.assertIn(sys.argv[0], result['version']) def test_platform_info(self): result = deps.platform_info() self.assertEqual('Platform', result['name']) self.assertIn(platform.platform(), result['version']) def test_python_info(self): result = deps.python_info() self.assertEqual('Python', result['name']) self.assertIn(platform.python_implementation(), result['version']) self.assertIn(platform.python_version(), result['version']) self.assertIn('python', result['path']) self.assertNotIn('platform.py', result['path']) def test_gstreamer_info(self): result = deps.gstreamer_info() self.assertEqual('GStreamer', result['name']) self.assertEqual( '.'.join(map(str, Gst.version())), result['version']) self.assertIn('gi', result['path']) self.assertNotIn('__init__.py', result['path']) self.assertIn('Python wrapper: python-gi', result['other']) self.assertIn(gi.__version__, result['other']) self.assertIn('Relevant elements:', result['other']) @mock.patch('pkg_resources.get_distribution') def test_pkg_info(self, get_distribution_mock): dist_mopidy = mock.Mock() dist_mopidy.project_name = 'Mopidy' dist_mopidy.version = '0.13' dist_mopidy.location = '/tmp/example/mopidy' dist_mopidy.requires.return_value = ['Pykka'] dist_pykka = mock.Mock() dist_pykka.project_name = 'Pykka' dist_pykka.version = '1.1' dist_pykka.location = '/tmp/example/pykka' dist_pykka.requires.return_value = ['setuptools'] dist_setuptools = mock.Mock() dist_setuptools.project_name = 'setuptools' dist_setuptools.version = '0.6' dist_setuptools.location = '/tmp/example/setuptools' dist_setuptools.requires.return_value = [] get_distribution_mock.side_effect = [ dist_mopidy, dist_pykka, dist_setuptools] result = deps.pkg_info() self.assertEqual('Mopidy', result['name']) self.assertEqual('0.13', result['version']) self.assertIn('mopidy', result['path']) dep_info_pykka = result['dependencies'][0] self.assertEqual('Pykka', dep_info_pykka['name']) self.assertEqual('1.1', dep_info_pykka['version']) dep_info_setuptools = dep_info_pykka['dependencies'][0] self.assertEqual('setuptools', dep_info_setuptools['name']) self.assertEqual('0.6', dep_info_setuptools['version']) @mock.patch('pkg_resources.get_distribution') def test_pkg_info_for_missing_dist(self, get_distribution_mock): get_distribution_mock.side_effect = pkg_resources.DistributionNotFound result = deps.pkg_info() self.assertEqual('Mopidy', result['name']) self.assertNotIn('version', result) self.assertNotIn('path', result) @mock.patch('pkg_resources.get_distribution') def test_pkg_info_for_wrong_dist_version(self, get_distribution_mock): get_distribution_mock.side_effect = pkg_resources.VersionConflict result = deps.pkg_info() self.assertEqual('Mopidy', result['name']) self.assertNotIn('version', result) self.assertNotIn('path', result) Mopidy-2.0.0/tests/internal/test_xdg.py0000664000175000017500000000317612575004517020346 0ustar jodaljodal00000000000000from __future__ import unicode_literals import os import mock import pytest from mopidy.internal import xdg @pytest.yield_fixture def environ(): patcher = mock.patch.dict(os.environ, clear=True) yield patcher.start() patcher.stop() def test_cache_dir_default(environ): assert xdg.get_dirs()['XDG_CACHE_DIR'] == os.path.expanduser(b'~/.cache') def test_cache_dir_from_env(environ): os.environ['XDG_CACHE_HOME'] = '/foo/bar' assert xdg.get_dirs()['XDG_CACHE_DIR'] == '/foo/bar' def test_config_dir_default(environ): assert xdg.get_dirs()['XDG_CONFIG_DIR'] == os.path.expanduser(b'~/.config') def test_config_dir_from_env(environ): os.environ['XDG_CONFIG_HOME'] = '/foo/bar' assert xdg.get_dirs()['XDG_CONFIG_DIR'] == '/foo/bar' def test_data_dir_default(environ): assert xdg.get_dirs()['XDG_DATA_DIR'] == os.path.expanduser( b'~/.local/share') def test_data_dir_from_env(environ): os.environ['XDG_DATA_HOME'] = '/foo/bar' assert xdg.get_dirs()['XDG_DATA_DIR'] == '/foo/bar' def test_user_dirs(environ, tmpdir): os.environ['XDG_CONFIG_HOME'] = str(tmpdir) with open(os.path.join(str(tmpdir), 'user-dirs.dirs'), 'wb') as fh: fh.write('# Some comments\n') fh.write('XDG_MUSIC_DIR="$HOME/Music2"\n') result = xdg.get_dirs() assert result['XDG_MUSIC_DIR'] == os.path.expanduser(b'~/Music2') assert 'XDG_DOWNLOAD_DIR' not in result def test_user_dirs_when_no_dirs_file(environ, tmpdir): os.environ['XDG_CONFIG_HOME'] = str(tmpdir) result = xdg.get_dirs() assert 'XDG_MUSIC_DIR' not in result assert 'XDG_DOWNLOAD_DIR' not in result Mopidy-2.0.0/tests/internal/test_encoding.py0000664000175000017500000000252412575004517021346 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest import mock from mopidy.internal import encoding @mock.patch('mopidy.internal.encoding.locale.getpreferredencoding') class LocaleDecodeTest(unittest.TestCase): def test_can_decode_utf8_strings_with_french_content(self, mock): mock.return_value = 'UTF-8' result = encoding.locale_decode( b'[Errno 98] Adresse d\xc3\xa9j\xc3\xa0 utilis\xc3\xa9e') self.assertEqual('[Errno 98] Adresse d\xe9j\xe0 utilis\xe9e', result) def test_can_decode_an_ioerror_with_french_content(self, mock): mock.return_value = 'UTF-8' error = IOError(98, b'Adresse d\xc3\xa9j\xc3\xa0 utilis\xc3\xa9e') result = encoding.locale_decode(error) expected = '[Errno 98] Adresse d\xe9j\xe0 utilis\xe9e' self.assertEqual( expected, result, '%r decoded to %r does not match expected %r' % ( error, result, expected)) def test_does_not_use_locale_to_decode_unicode_strings(self, mock): mock.return_value = 'UTF-8' encoding.locale_decode('abc') self.assertFalse(mock.called) def test_does_not_use_locale_to_decode_ascii_bytestrings(self, mock): mock.return_value = 'UTF-8' encoding.locale_decode('abc') self.assertFalse(mock.called) Mopidy-2.0.0/tests/file/0000775000175000017500000000000012660436443015251 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/file/conftest.py0000664000175000017500000000066112575004517017451 0ustar jodaljodal00000000000000from __future__ import unicode_literals import pytest @pytest.fixture def file_config(): return { 'file': { } } @pytest.fixture def file_library(file_config): # Import library, thus scanner, thus gobject as late as possible to avoid # hard to track import errors during conftest setup. from mopidy.file import library return library.FileLibraryProvider(backend=None, config=file_config) Mopidy-2.0.0/tests/file/__init__.py0000664000175000017500000000000012575004517017346 0ustar jodaljodal00000000000000Mopidy-2.0.0/tests/file/test_lookup.py0000664000175000017500000000007612575004517020174 0ustar jodaljodal00000000000000from __future__ import unicode_literals # TODO Test lookup() Mopidy-2.0.0/tests/file/test_browse.py0000664000175000017500000000007612575004517020164 0ustar jodaljodal00000000000000from __future__ import unicode_literals # TODO Test browse() Mopidy-2.0.0/tests/test_mixer.py0000664000175000017500000000131512575004517017065 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals import unittest import mock from mopidy import mixer class MixerListenerTest(unittest.TestCase): def setUp(self): # noqa: N802 self.listener = mixer.MixerListener() def test_on_event_forwards_to_specific_handler(self): self.listener.volume_changed = mock.Mock() self.listener.on_event( 'volume_changed', volume=60) self.listener.volume_changed.assert_called_with(volume=60) def test_listener_has_default_impl_for_volume_changed(self): self.listener.volume_changed(volume=60) def test_listener_has_default_impl_for_mute_changed(self): self.listener.mute_changed(mute=True) Mopidy-2.0.0/tests/dummy_backend.py0000664000175000017500000001012512660436420017500 0ustar jodaljodal00000000000000"""A dummy backend for use in tests. This backend implements the backend API in the simplest way possible. It is used in tests of the frontends. """ from __future__ import absolute_import, unicode_literals import pykka from mopidy import backend from mopidy.models import Playlist, Ref, SearchResult def create_proxy(config=None, audio=None): return DummyBackend.start(config=config, audio=audio).proxy() class DummyBackend(pykka.ThreadingActor, backend.Backend): def __init__(self, config, audio): super(DummyBackend, self).__init__() self.library = DummyLibraryProvider(backend=self) if audio: self.playback = backend.PlaybackProvider(audio=audio, backend=self) else: self.playback = DummyPlaybackProvider(audio=audio, backend=self) self.playlists = DummyPlaylistsProvider(backend=self) self.uri_schemes = ['dummy'] class DummyLibraryProvider(backend.LibraryProvider): root_directory = Ref.directory(uri='dummy:/', name='dummy') def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) self.dummy_library = [] self.dummy_get_distinct_result = {} self.dummy_browse_result = {} self.dummy_find_exact_result = SearchResult() self.dummy_search_result = SearchResult() def browse(self, path): return self.dummy_browse_result.get(path, []) def get_distinct(self, field, query=None): return self.dummy_get_distinct_result.get(field, set()) def lookup(self, uri): return [t for t in self.dummy_library if uri == t.uri] def refresh(self, uri=None): pass def search(self, query=None, uris=None, exact=False): if exact: # TODO: remove uses of dummy_find_exact_result return self.dummy_find_exact_result return self.dummy_search_result class DummyPlaybackProvider(backend.PlaybackProvider): def __init__(self, *args, **kwargs): super(DummyPlaybackProvider, self).__init__(*args, **kwargs) self._uri = None self._time_position = 0 def pause(self): return True def play(self): return self._uri and self._uri != 'dummy:error' def change_track(self, track): """Pass a track with URI 'dummy:error' to force failure""" self._uri = track.uri self._time_position = 0 return True def prepare_change(self): pass def resume(self): return True def seek(self, time_position): self._time_position = time_position return True def stop(self): self._uri = None return True def get_time_position(self): return self._time_position class DummyPlaylistsProvider(backend.PlaylistsProvider): def __init__(self, backend): super(DummyPlaylistsProvider, self).__init__(backend) self._playlists = [] def set_dummy_playlists(self, playlists): """For tests using the dummy provider through an actor proxy.""" self._playlists = playlists def as_list(self): return [ Ref.playlist(uri=pl.uri, name=pl.name) for pl in self._playlists] def get_items(self, uri): playlist = self.lookup(uri) if playlist is None: return return [ Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] def lookup(self, uri): for playlist in self._playlists: if playlist.uri == uri: return playlist def refresh(self): pass def create(self, name): playlist = Playlist(name=name, uri='dummy:%s' % name) self._playlists.append(playlist) return playlist def delete(self, uri): playlist = self.lookup(uri) if playlist: self._playlists.remove(playlist) def save(self, playlist): old_playlist = self.lookup(playlist.uri) if old_playlist is not None: index = self._playlists.index(old_playlist) self._playlists[index] = playlist else: self._playlists.append(playlist) return playlist Mopidy-2.0.0/tests/m3u/0000775000175000017500000000000012660436443015036 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/m3u/test_playlists.py0000664000175000017500000003431012660436420020467 0ustar jodaljodal00000000000000# encoding: utf-8 from __future__ import absolute_import, unicode_literals import os import platform import shutil import tempfile import unittest import pykka from mopidy import core from mopidy.internal import deprecation from mopidy.m3u.backend import M3UBackend from mopidy.models import Playlist, Track from tests import dummy_audio, path_to_data_dir from tests.m3u import generate_song class M3UPlaylistsProviderTest(unittest.TestCase): backend_class = M3UBackend config = { 'm3u': { 'enabled': True, 'base_dir': None, 'default_encoding': 'latin-1', 'default_extension': '.m3u', 'playlists_dir': path_to_data_dir(''), } } def setUp(self): # noqa: N802 self.config['m3u']['playlists_dir'] = tempfile.mkdtemp() self.playlists_dir = self.config['m3u']['playlists_dir'] self.base_dir = self.config['m3u']['base_dir'] or self.playlists_dir audio = dummy_audio.create_proxy() backend = M3UBackend.start( config=self.config, audio=audio).proxy() self.core = core.Core(backends=[backend]) def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() if os.path.exists(self.playlists_dir): shutil.rmtree(self.playlists_dir) def test_created_playlist_is_persisted(self): uri = 'm3u:test.m3u' path = os.path.join(self.playlists_dir, b'test.m3u') self.assertFalse(os.path.exists(path)) playlist = self.core.playlists.create('test') self.assertEqual('test', playlist.name) self.assertEqual(uri, playlist.uri) self.assertTrue(os.path.exists(path)) def test_create_sanitizes_playlist_name(self): playlist = self.core.playlists.create(' ../../test FOO baR ') self.assertEqual('..|..|test FOO baR', playlist.name) path = os.path.join(self.playlists_dir, b'..|..|test FOO baR.m3u') self.assertEqual(self.playlists_dir, os.path.dirname(path)) self.assertTrue(os.path.exists(path)) def test_saved_playlist_is_persisted(self): uri1 = 'm3u:test1.m3u' uri2 = 'm3u:test2.m3u' path1 = os.path.join(self.playlists_dir, b'test1.m3u') path2 = os.path.join(self.playlists_dir, b'test2.m3u') playlist = self.core.playlists.create('test1') self.assertEqual('test1', playlist.name) self.assertEqual(uri1, playlist.uri) self.assertTrue(os.path.exists(path1)) self.assertFalse(os.path.exists(path2)) playlist = self.core.playlists.save(playlist.replace(name='test2')) self.assertEqual('test2', playlist.name) self.assertEqual(uri2, playlist.uri) self.assertFalse(os.path.exists(path1)) self.assertTrue(os.path.exists(path2)) def test_deleted_playlist_is_removed(self): uri = 'm3u:test.m3u' path = os.path.join(self.playlists_dir, b'test.m3u') self.assertFalse(os.path.exists(path)) playlist = self.core.playlists.create('test') self.assertEqual('test', playlist.name) self.assertEqual(uri, playlist.uri) self.assertTrue(os.path.exists(path)) self.core.playlists.delete(playlist.uri) self.assertFalse(os.path.exists(path)) def test_playlist_contents_is_written_to_disk(self): track = Track(uri=generate_song(1)) playlist = self.core.playlists.create('test') playlist = self.core.playlists.save(playlist.replace(tracks=[track])) path = os.path.join(self.playlists_dir, b'test.m3u') with open(path) as f: contents = f.read() self.assertEqual(track.uri, contents.strip()) def test_extended_playlist_contents_is_written_to_disk(self): track = Track(uri=generate_song(1), name='Test', length=60000) playlist = self.core.playlists.create('test') playlist = self.core.playlists.save(playlist.replace(tracks=[track])) path = os.path.join(self.playlists_dir, b'test.m3u') with open(path) as f: m3u = f.read().splitlines() self.assertEqual(['#EXTM3U', '#EXTINF:-1,Test', track.uri], m3u) def test_latin1_playlist_contents_is_written_to_disk(self): track = Track(uri=generate_song(1), name='Test\x9f', length=60000) playlist = self.core.playlists.create('test') playlist = self.core.playlists.save(playlist.copy(tracks=[track])) path = os.path.join(self.playlists_dir, b'test.m3u') with open(path, 'rb') as f: m3u = f.read().splitlines() self.assertEqual([b'#EXTM3U', b'#EXTINF:-1,Test\x9f', track.uri], m3u) def test_utf8_playlist_contents_is_replaced_and_written_to_disk(self): track = Track(uri=generate_song(1), name='Test\u07b4', length=60000) playlist = self.core.playlists.create('test') playlist = self.core.playlists.save(playlist.copy(tracks=[track])) path = os.path.join(self.playlists_dir, b'test.m3u') with open(path, 'rb') as f: m3u = f.read().splitlines() self.assertEqual([b'#EXTM3U', b'#EXTINF:-1,Test?', track.uri], m3u) def test_playlists_are_loaded_at_startup(self): track = Track(uri='dummy:track:path2') playlist = self.core.playlists.create('test') playlist = playlist.replace(tracks=[track]) playlist = self.core.playlists.save(playlist) self.assertEqual(len(self.core.playlists.as_list()), 1) result = self.core.playlists.lookup(playlist.uri) self.assertEqual(playlist.uri, result.uri) self.assertEqual(playlist.name, result.name) self.assertEqual(track.uri, result.tracks[0].uri) def test_load_playlist_with_nonfilesystem_encoding_of_filename(self): path = os.path.join(self.playlists_dir, 'øæå.m3u'.encode('latin-1')) with open(path, 'wb+') as f: f.write(b'#EXTM3U\n') self.core.playlists.refresh() self.assertEqual(len(self.core.playlists.as_list()), 1) result = self.core.playlists.as_list() if platform.system() == 'Darwin': self.assertEqual('%F8%E6%E5', result[0].name) else: self.assertEqual('\ufffd\ufffd\ufffd', result[0].name) @unittest.SkipTest def test_playlists_dir_is_created(self): pass def test_create_returns_playlist_with_name_set(self): playlist = self.core.playlists.create('test') self.assertEqual(playlist.name, 'test') def test_create_returns_playlist_with_uri_set(self): playlist = self.core.playlists.create('test') self.assert_(playlist.uri) def test_create_adds_playlist_to_playlists_collection(self): playlist = self.core.playlists.create('test') playlists = self.core.playlists.as_list() self.assertIn(playlist.uri, [ref.uri for ref in playlists]) def test_as_list_empty_to_start_with(self): self.assertEqual(len(self.core.playlists.as_list()), 0) def test_delete_non_existant_playlist(self): self.core.playlists.delete('m3u:unknown') def test_delete_playlist_removes_it_from_the_collection(self): playlist = self.core.playlists.create('test') self.assertEqual(playlist, self.core.playlists.lookup(playlist.uri)) self.core.playlists.delete(playlist.uri) self.assertIsNone(self.core.playlists.lookup(playlist.uri)) def test_delete_playlist_without_file(self): playlist = self.core.playlists.create('test') self.assertEqual(playlist, self.core.playlists.lookup(playlist.uri)) path = os.path.join(self.playlists_dir, b'test.m3u') self.assertTrue(os.path.exists(path)) os.remove(path) self.assertFalse(os.path.exists(path)) self.core.playlists.delete(playlist.uri) self.assertIsNone(self.core.playlists.lookup(playlist.uri)) def test_lookup_finds_playlist_by_uri(self): original_playlist = self.core.playlists.create('test') looked_up_playlist = self.core.playlists.lookup(original_playlist.uri) self.assertEqual(original_playlist, looked_up_playlist) def test_refresh(self): playlist = self.core.playlists.create('test') self.assertEqual(playlist, self.core.playlists.lookup(playlist.uri)) self.core.playlists.refresh() self.assertEqual(playlist, self.core.playlists.lookup(playlist.uri)) def test_save_replaces_existing_playlist_with_updated_playlist(self): playlist1 = self.core.playlists.create('test1') self.assertEqual(playlist1, self.core.playlists.lookup(playlist1.uri)) playlist2 = playlist1.replace(name='test2') playlist2 = self.core.playlists.save(playlist2) self.assertIsNone(self.core.playlists.lookup(playlist1.uri)) self.assertEqual(playlist2, self.core.playlists.lookup(playlist2.uri)) def test_create_replaces_existing_playlist_with_updated_playlist(self): track = Track(uri=generate_song(1)) playlist1 = self.core.playlists.create('test') playlist1 = self.core.playlists.save(playlist1.replace(tracks=[track])) self.assertEqual(playlist1, self.core.playlists.lookup(playlist1.uri)) playlist2 = self.core.playlists.create('test') self.assertEqual(playlist1.uri, playlist2.uri) self.assertNotEqual( playlist1, self.core.playlists.lookup(playlist1.uri)) self.assertEqual(playlist2, self.core.playlists.lookup(playlist1.uri)) def test_save_playlist_with_new_uri(self): uri = 'm3u:test.m3u' self.core.playlists.save(Playlist(uri=uri)) path = os.path.join(self.playlists_dir, b'test.m3u') self.assertTrue(os.path.exists(path)) def test_playlist_with_unknown_track(self): track = Track(uri='file:///dev/null') playlist = self.core.playlists.create('test') playlist = playlist.replace(tracks=[track]) playlist = self.core.playlists.save(playlist) self.assertEqual(len(self.core.playlists.as_list()), 1) result = self.core.playlists.lookup('m3u:test.m3u') self.assertEqual('m3u:test.m3u', result.uri) self.assertEqual(playlist.name, result.name) self.assertEqual(track.uri, result.tracks[0].uri) def test_playlist_with_absolute_path(self): track = Track(uri='/tmp/test.mp3') filepath = b'/tmp/test.mp3' playlist = self.core.playlists.create('test') playlist = playlist.replace(tracks=[track]) playlist = self.core.playlists.save(playlist) self.assertEqual(len(self.core.playlists.as_list()), 1) result = self.core.playlists.lookup('m3u:test.m3u') self.assertEqual('m3u:test.m3u', result.uri) self.assertEqual(playlist.name, result.name) self.assertEqual('file://' + filepath, result.tracks[0].uri) def test_playlist_with_relative_path(self): track = Track(uri='test.mp3') filepath = os.path.join(self.base_dir, b'test.mp3') playlist = self.core.playlists.create('test') playlist = playlist.replace(tracks=[track]) playlist = self.core.playlists.save(playlist) self.assertEqual(len(self.core.playlists.as_list()), 1) result = self.core.playlists.lookup('m3u:test.m3u') self.assertEqual('m3u:test.m3u', result.uri) self.assertEqual(playlist.name, result.name) self.assertEqual('file://' + filepath, result.tracks[0].uri) def test_playlist_sort_order(self): def check_order(playlists, names): self.assertEqual(names, [playlist.name for playlist in playlists]) self.core.playlists.create('c') self.core.playlists.create('a') self.core.playlists.create('b') check_order(self.core.playlists.as_list(), ['a', 'b', 'c']) self.core.playlists.refresh() check_order(self.core.playlists.as_list(), ['a', 'b', 'c']) playlist = self.core.playlists.lookup('m3u:a.m3u') playlist = playlist.replace(name='d') playlist = self.core.playlists.save(playlist) check_order(self.core.playlists.as_list(), ['b', 'c', 'd']) self.core.playlists.delete('m3u:c.m3u') check_order(self.core.playlists.as_list(), ['b', 'd']) def test_get_items_returns_item_refs(self): track = Track(uri='dummy:a', name='A', length=60000) playlist = self.core.playlists.create('test') playlist = self.core.playlists.save(playlist.replace(tracks=[track])) item_refs = self.core.playlists.get_items(playlist.uri) self.assertEqual(len(item_refs), 1) self.assertEqual(item_refs[0].type, 'track') self.assertEqual(item_refs[0].uri, 'dummy:a') self.assertEqual(item_refs[0].name, 'A') def test_get_items_of_unknown_playlist_returns_none(self): item_refs = self.core.playlists.get_items('dummy:unknown') self.assertIsNone(item_refs) class M3UPlaylistsProviderBaseDirectoryTest(M3UPlaylistsProviderTest): def setUp(self): # noqa: N802 self.config['m3u']['base_dir'] = tempfile.mkdtemp() super(M3UPlaylistsProviderBaseDirectoryTest, self).setUp() class DeprecatedM3UPlaylistsProviderTest(M3UPlaylistsProviderTest): def run(self, result=None): with deprecation.ignore(ids=['core.playlists.filter', 'core.playlists.filter:kwargs_criteria', 'core.playlists.get_playlists']): return super(DeprecatedM3UPlaylistsProviderTest, self).run(result) def test_filter_without_criteria(self): self.assertEqual(self.core.playlists.get_playlists(), self.core.playlists.filter()) def test_filter_with_wrong_criteria(self): self.assertEqual([], self.core.playlists.filter(name='foo')) def test_filter_with_right_criteria(self): playlist = self.core.playlists.create('test') playlists = self.core.playlists.filter(name='test') self.assertEqual([playlist], playlists) def test_filter_by_name_returns_single_match(self): self.core.playlists.create('a') playlist = self.core.playlists.create('b') self.assertEqual([playlist], self.core.playlists.filter(name='b')) def test_filter_by_name_returns_no_matches(self): self.core.playlists.create('a') self.core.playlists.create('b') self.assertEqual([], self.core.playlists.filter(name='c')) Mopidy-2.0.0/tests/m3u/test_translator.py0000664000175000017500000001055112660436420020635 0ustar jodaljodal00000000000000# encoding: utf-8 from __future__ import absolute_import, unicode_literals import io from mopidy.m3u import translator from mopidy.models import Playlist, Ref, Track def loads(s, basedir=b'.'): return translator.load_items(io.StringIO(s), basedir=basedir) def dumps(items): fp = io.StringIO() translator.dump_items(items, fp) return fp.getvalue() def test_path_to_uri(): from mopidy.m3u.translator import path_to_uri assert path_to_uri(b'test') == 'm3u:test' assert path_to_uri(b'test.m3u') == 'm3u:test.m3u' assert path_to_uri(b'./test.m3u') == 'm3u:test.m3u' assert path_to_uri(b'foo/../test.m3u') == 'm3u:test.m3u' assert path_to_uri(b'Test Playlist.m3u') == 'm3u:Test%20Playlist.m3u' assert path_to_uri(b'test.mp3', scheme='file') == 'file:///test.mp3' def test_latin1_path_to_uri(): path = 'æøå.m3u'.encode('latin-1') assert translator.path_to_uri(path) == 'm3u:%E6%F8%E5.m3u' def test_utf8_path_to_uri(): path = 'æøå.m3u'.encode('utf-8') assert translator.path_to_uri(path) == 'm3u:%C3%A6%C3%B8%C3%A5.m3u' def test_uri_to_path(): from mopidy.m3u.translator import uri_to_path assert uri_to_path('m3u:test.m3u') == b'test.m3u' assert uri_to_path(b'm3u:test.m3u') == b'test.m3u' assert uri_to_path('m3u:Test%20Playlist.m3u') == b'Test Playlist.m3u' assert uri_to_path(b'm3u:Test%20Playlist.m3u') == b'Test Playlist.m3u' assert uri_to_path('m3u:%E6%F8%E5.m3u') == b'\xe6\xf8\xe5.m3u' assert uri_to_path(b'm3u:%E6%F8%E5.m3u') == b'\xe6\xf8\xe5.m3u' assert uri_to_path('file:///test.mp3') == b'/test.mp3' assert uri_to_path(b'file:///test.mp3') == b'/test.mp3' def test_name_from_path(): from mopidy.m3u.translator import name_from_path assert name_from_path(b'test') == 'test' assert name_from_path(b'test.m3u') == 'test' assert name_from_path(b'../test.m3u') == 'test' def test_path_from_name(): from mopidy.m3u.translator import path_from_name assert path_from_name('test') == b'test' assert path_from_name('test', '.m3u') == b'test.m3u' assert path_from_name('foo/bar', sep='-') == b'foo-bar' def test_path_to_ref(): from mopidy.m3u.translator import path_to_ref assert path_to_ref(b'test.m3u') == Ref.playlist( uri='m3u:test.m3u', name='test' ) assert path_to_ref(b'Test Playlist.m3u') == Ref.playlist( uri='m3u:Test%20Playlist.m3u', name='Test Playlist' ) def test_load_items(): assert loads('') == [] assert loads('test.mp3', basedir=b'/playlists') == [ Ref.track(uri='file:///playlists/test.mp3', name='test') ] assert loads('../test.mp3', basedir=b'/playlists') == [ Ref.track(uri='file:///test.mp3', name='test') ] assert loads('/test.mp3') == [ Ref.track(uri='file:///test.mp3', name='test') ] assert loads('file:///test.mp3') == [ Ref.track(uri='file:///test.mp3') ] assert loads('http://example.com/stream') == [ Ref.track(uri='http://example.com/stream') ] assert loads('#EXTM3U\n#EXTINF:42,Test\nfile:///test.mp3\n') == [ Ref.track(uri='file:///test.mp3', name='Test') ] assert loads('#EXTM3U\n#EXTINF:-1,Test\nhttp://example.com/stream\n') == [ Ref.track(uri='http://example.com/stream', name='Test') ] def test_dump_items(): assert dumps([]) == '' assert dumps([Ref.track(uri='file:///test.mp3')]) == ( 'file:///test.mp3\n' ) assert dumps([Ref.track(uri='file:///test.mp3', name='test')]) == ( '#EXTM3U\n' '#EXTINF:-1,test\n' 'file:///test.mp3\n' ) assert dumps([Track(uri='file:///test.mp3', name='test', length=42)]) == ( '#EXTM3U\n' '#EXTINF:-1,test\n' 'file:///test.mp3\n' ) assert dumps([Track(uri='http://example.com/stream')]) == ( 'http://example.com/stream\n' ) assert dumps([Track(uri='http://example.com/stream', name='Test')]) == ( '#EXTM3U\n' '#EXTINF:-1,Test\n' 'http://example.com/stream\n' ) def test_playlist(): from mopidy.m3u.translator import playlist assert playlist(b'test.m3u') == Playlist( uri='m3u:test.m3u', name='test' ) assert playlist(b'test.m3u', [Ref(uri='file:///test.mp3')], 1) == Playlist( uri='m3u:test.m3u', name='test', tracks=[Track(uri='file:///test.mp3')], last_modified=1000 ) Mopidy-2.0.0/tests/m3u/__init__.py0000664000175000017500000000016512505224626017145 0ustar jodaljodal00000000000000from __future__ import absolute_import, unicode_literals def generate_song(i): return 'dummy:track:song%s' % i Mopidy-2.0.0/tests/data/0000775000175000017500000000000012660436443015243 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/data/song1.ogg0000644000175000017500000002073712441116637016774 0ustar jodaljodal00000000000000OggSb~|vorbis@@OggSb~X -vorbisXiph.Org libVorbis I 20090709vorbisBCV R!%SJcRR)cP[Gc9F!dSI{O*XJRX)ESLSIR)EcSH!S1esKI %lMtKc1FcZJc1EcRRIs:f%d:Fb|0:B(R-[S-KiasJjc1S(АU@BCV P EQАU@EqqG$BCV@((#IdYeYy/.FuL*CCc3C LcN4 23Ő2[,.!+(b 9dR"瘔NJQ(K[1Q(eBŌRT@@PhȊ 0)B)s1 1 d)NJ圓Ic1sNJrIɤ`!"0HY&gꉢZgigj뚪ʖ癦gꙦ꺦l.jۮkl+ʺʶKgnkۚjʮm-l,fl-,ڶ*˺/n,lںk,*˾1۶˺.'뙪몮k۪ںl-+۪,+˶,+ۦʲ*˾ʲn,*1̶*˺ʲn nʲ ˬۺ1﫲-, 2>ct]_WmYV}cuaYm[][gn nʭ ˲ڶ̺,.|[ڶ麺nʲ˺.uWF}ն}_e߷_ið,k/뺰/,m+0ۺܾ, ˪۾ҵue}+ p0 "@r)b BB*cR2dI)JIbLJ朔1)J)RZ*Ji-bJPJkJIbL1&%sNJ朔Rk%2(eJ J*b朤:+J*1b VJjc+1Z!KJZm1Z5bLJ朔9*%J*eI 9(b*)9I2ȨZ+Rc)b)CI-Z,bTS'ŒR%[j VJ[k1cK+ZlZ5TcJc1k=bM1ZՖ[̵NJkKJ1b1Ji[))Z\C)Z,bVcj[ZkV[.b=kXSm 8P Y D0F)ǜ(sR* RRʜPJKsJI)RjRRj lДXА@*q4MUu}_,QTUוmW,MUUvm[5QTU׵m~MUUveٶm۲n èk۲m먮ۺۺ/T]Ym[u׶uu]mn# G!tBOp*auBCV1J3H1cL1Ƙ@!+(9s9s9s9s1c1c1c1c1c1cLNNPhJ @!))RJSAI)RJJ)RuRJ)"RJ)II)RJ):J)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)TJ)RJ)RJ)`` +IgrBiTRJ%UA(%JJ)RJ*RJ)A(PJ)%RJ(%J TB+RJB)TJ)%A(!BIBHtTR)!RJ)%:RK-RZJtR)RjR VJI%ZI%JI%RK)TRI%R*%ZJRHRJRJ%JJRj%Rj!JI)J)TBIRZJ-JITRIJI)RJK)JJZI)RJ)RK%TJ %RIRK)R@FTZf\y(d@@ 0@P0 A0GќBCOggS]b~u^+)-+**+.+-+,+,+0/,/,*0/-**,-.,+/+**/)/++--+-,.+**...-/*-+'-*+,.0+,++/)./-..+//0(((-+**,,,++*+*":5Ct+:1V3IꙨ1o2 #a #vNYVIK.iܳd)Ԝ֦ $Z=l;_ gC[#%[Rus=*=zscP)n(7|pA.b՟){*Xڠc].##am6[J>t+lMn/{6p$' si);rqF#介Iy `$uB+<%w{C7"aQ] G\]l$YT͆a޻V_=ݮdc7j!-ٶʨmf4^0cdaLRIi];#(tx+年WZ9ziVIY'hkV=S:#YKZMziȇ:kƠXR}P |48Mz#Y$MJeϬ zQxTlHxR:c!V6?[#ꗪC3Ĉ+wۜw$@8K5ł,>'i`CQWy cNT:P#Y$e>Gڨ]񶘥/'qы]v2ó|nk$\R=4F}Zs}0xR/}3bOBt8ͶGr#EZsc Sj?w'bae ѭotYRFsu7#IAn ,PW,VxiiiȼʦJDV+.%@2gQi[[[s7)a*U:?<~$Im_.*=l+ i^22WJSqp $\jX6a{xYc;sž$c)GrpW%!Zo'¯e.+eʳL&K x$ZgNE 1N9m=< HT\FS\0ǝ$qZ/`j'ip6-qprqأ$)smTuflcL9]0O3 k7b@H|i.*i*w%|$!VzǻT \eUg.l}c%dfoST"m]ugG%4%tc)zL3dcIY;܋]^BXV>l+\\H!Z(nuZ{4{$qV)/>P3iMWKE ý|ɐO5#T[NY=.]MuOul'5Vq=Ԏtu#a SX<.n'm)?*gl~u]"VV/X # 9UޏÞA.u'__#N%ߊi#%vE?N TvI\xZˏ~+6sj# kuzU"#fS=.DCc.a$a [uk0۹GnAon;e-qqD$T3>1>TnctgLnFٵYUƢ&$!,\w=rwTOj|D+fn9St#Y$+ !@\(k"G@=dH71$ɩ簦. wY}3 ՘dYN9cRt[V6k#qm?1MW&fްn6Ja$m4(L$aZ̒6) ^b*1fةhiw^kgS$!R4/o])͗&ޫd]6g蓕 aݻczmdĹnP;RE U+;#,ak2Ek 0W˳CQr8-ۻBZaEQ5$:s5]zv]#GFI$mЍCrpYqʙ$!V;x\@b1km6Α|k}AqZ*e5$!V=T]+M᱙(rC2zߨ$h԰Vvbj U׊\&]Jƹ $ܨZ`θW(^m)kkg]믙A}e/<[$dM  ܱww BWMD[=ZPq$匩絃|F~]U&(0ڍ1H~KS>RZoVJP$(a,xlɈ˽V !n։ 8?9=vBF$bSSOs#wZ)Q7=np=()z/TKs1qX#YTVy9 թPZjO %+-nYFZe$\¦V0]Iw83oז8bdvb#!SsT}wB}}1K~_S8SB$Hlz'Tz.JzH9>S$֭y:8$h$]XyLQ+@MX8{  qx9G0j~ $!fTMz%_iVm~[$2#!º0+|15'');ni艘uڛ#YTd:w1~w< N|'EgÛZ`iz;,n'#qZOrE^Ҡ2BwiW8 ɴ 9 c!uZsϒGvmJ:CM8G%#YTxh޼M[ b ri.{%H8w rcc4c[Qwa5MSwwRj:82T OggSb~r-++31/**),00.-.).,+,/--/,2)*.+,,-11,*+.1-*.)+)$aRJ+JmТəUyKƝy X]ӹ@}"Y$yvfFm.qh-83$:!t׽2կj2f;Q[[R㜅%([7$!j@oAҿ4E3sj3J)#iJzdqv2(lrI41af IknKޝ^+ R4Y3aDp#\Z bzJ )HxeABn* ة]!$%MBbeoLD1g{0fc#aNOY1obO% fgٹo[Sm!g1c;{ -$(pX_gȥ Q3 3IQV:`0u$!֓2ǭ䧕 s͡mݝ&;N$a3wTxJש 1,rr~adjθ#Tmsp63=:V=N5-ѝOis'Y6N$Y$òoצxRBv{$ 77{+d?'c4X Ja b#vm^ס'&UU^;OVǶ Scv]~Mg^Smd;W:WFɂՕUR$I&54UЈTI]YK,e_o Qr #!ZnN[S#nDs kkjkBsv@$YTSvy;TJdxzwZW/Dqײz٫P?#aZfY ~zXfP{3dhd{I^#qZ#B|ehhD{*ZwJqŔr$a6gqb\߶6~j歝r[c2΍w#ٮZo8y~L3=NUf*z7v Hn$TC;~~ dUrs8wq̹$!T4Yq}LB+ڥ9_b.ffe#T"i:z|`^-N[&D]1wfu"8˚4$IE:O-嵫~rƞPJ$hDfL&>$(unU_[ŔiZֳsdIoXyA GKh'[!i=$ɩZJVVA łN9SI&؜M3Y({q)A$ٮZEtt,N9Jϳ=Skjw4M"N* ca6OYˮ3C3N3jbB6tΪC$ai͜@ևxD~/oAԅλ->]tu.&؛"$=<8v^ަi[2Z\Xt⸇Į?$YT'C\.\8i36rS24)Y[]f1%j ~jLؗ[VjUY" 2jKC7>:00$\6m_ѣ;DNwتĪ5giԷ}n{(n(D^AI11Jy{ui <|;w4fk}~NA&. >D!*Poцz4;B8lʮQVwygxjr{ʷ0g(, ((z8L $fFVmA&DbqAFI`M`d1LC#; ޥvvmHݻQL.IMJf*"g?=!uF9')bC%dH4<#:q98oEd:PvM]V>v~yVˏ뫿W[(aB: ,6if<7!"cH'-h|16(V$> BE˴SJ/PnjݩsxVfx]M|ӥ͔bq T-h  0ӹM݌M89\R#;ޖw5,:,,X(EklUu uZ;Tvҥt ̪fy(~8,;]:hY*{<2M)(4;8Rk/_P'C0Ԋ?^Jgu(f(YVy+$r5AoGN7\e8(28L7f^Y`H$ ,@dF04ER'zX֑f4ZiIzT彡1S {UVgz;[0qvʯgtDP"=esF֚G.Kd/9:FtPYْW5W}\83lӅT xa/';­gMmV(8L_捡'v.ހms@0` z&u9`+A1e Eq [ܷ -pĊY̓3JUEVuMVxw'(f8L8 H gqbWbVxZSY琔}rlpiq&PM˅@9X`BC rm~mXV^.g2->(r(dVh9ѵK%X駦I(I9X aF&E"PJR*Qd{%  > hVS*K,ql[hm?Fnt^cW]PfkSC³VxGu.ed|{%e4Ѻ$ *R /~:FTiQbKUOV`e`R':uAH5ҝ`x:( v8R i@T*:*t6 Jtkg#QY?23^L@_4H!Pq[:!١Q*I(h\dXM`BVxlN8. ;\e%[(r8RL C=v6ShG& !U+}6QTa `>f&`{BK nm|R\ v@WaVx/$ ֩*pZ$T FM^;(/Ar8 $fei%vgo~n)UlًnQ/wNU)9udGojާg?$W˟(4݃n\\ٷSkRY\DYDFLĮ&A29R8Lѫ@]A$UlQQ(t|ۄH5+`qqz|Rog( ^BL:^oOD-cs!vNMw(U*#bx O"hP6,5i"~L*GޤZ$yT-\ީA7*gy'Ո씼!l! ( 88 *pW gsA9j |6%bVYjT7W+Zu4ޚԎ3]֭UVxӓhwwJqSRk9eYHh9ٗzo7w <<"y>Z8 -ċ {±c+T9 j1e{q& eEFAEw)( ^P톏Adb"S"QT"*W!$!VPg(1}9Jp`j DjfHTu/f؏ `P1gJ@xQU Rgxʟ;6z{(!zBLls1:FN"2): a!=FJ;H "ei[4QI',, <Sg.co=qw8S¶MEFƨ{CMI|TKs˃}<*LК/hvK!V?!wGd (bIJJ0@u,y3Q8H|bwIYBY:8$P!gYBn^~} {ֵBjVwvU9P1RӢVy (Yb8L "/Tuµ܌@O<^A^!A`q-;J A5,@,asAаrw\tț|\TB$0`vr0ugx 6\zRCNUm!r" 7((q~RLP$p'HCFOFttig(겏Rh4<\S=,`H2XyީG)weloʁIr0Q3h,,IVz$nRU7 YEˁTTFnhCj(1z8(+ MJǔ 64 Tfļ+Hy'$"4znq/#TKrwp#.\V.|d'53%2= 1T8=bGHal-0N BQnPP$elf m__U=noz bŕVwziIKIf(fBLʌ6JN'ZԽ*ʓ O-;niJp|ZF `dr+y{AV":ׄf 6$Pz`b܄.pLVxxnLɖnt/TR(xҘ(ٺ8P 9LVա2mX֥*U8dCR:G HI ʁ[i}i`L}6bV{%sc,-P5"y7A"(.a:F`2aJAmN>@H[s,%"aAׁ1 Fs-s+Uqj\X&(K2 WWﮘ>][IFX0ӻ\!K痮[ڴtW^(ySA H%2qMaETԙe1L8+t>_@ Ejj=c^i&:zPȨ*&.%Wjm `B3-L7E`wsKbXX:O 8a 5#Cb H <A &+gG"0,R)')0`}2?bz?/(ՍpHe*VC:<ߍ/Ԩc3C.jLAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU*HUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUeHUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUTAGtitle artist album 2010 Mopidy-2.0.0/tests/data/comment-ext.m3u0000664000175000017500000000006312660436420020123 0ustar jodaljodal00000000000000#EXTM3U # test #EXTINF:-1,Song #1 # test song1.mp3 Mopidy-2.0.0/tests/data/comment.m3u0000644000175000017500000000002112441116637017317 0ustar jodaljodal00000000000000# test song1.mp3 Mopidy-2.0.0/tests/data/scanner/0000775000175000017500000000000012660436443016674 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/data/scanner/empty.wav0000644000175000017500000000007012441116637020541 0ustar jodaljodal00000000000000RIFFWAVEfmt  factdataMopidy-2.0.0/tests/data/scanner/simple/0000775000175000017500000000000012660436443020165 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/data/scanner/simple/song1.ogg0000644000175000017500000004012212441116637021704 0ustar jodaljodal00000000000000OggSѪJ}vorbis@WOggSѪJ} rvorbis-Xiph.Org libVorbis I 20101101 (Schaufenugget)title=trackname artist=namealbum=albumnamevorbisBCV R!%SJcRR)cP[Gc9F!dSI{O*XJRX)ESLSIR)EcSH!S1esKI %lMtKc1FcZJc1EcRRIs:f%d:Fb|0:B(R-[S-KiasJjc1S(АU@BCV P EQАU@EqqG$BCV@((#IdYeYy/.!I̐SI&)U99dRƘbQΐS 11)N9 "CHd K=b8"A!Ɛs J!rI D9)LJ(I -"眔NJ&RˤB+8XRH)ĔbN1R)ǐR9Řr1 T1H)sN9 d * 2B!+8$iihi(z(y陦zlyiz)k늪j˦ڶ骶ʲn۞ʶnml,ۺyꙦz麪ڲ꺲홦늪+ۦʲʶʲk麢ڮʮmʺʲ۶ 躶ʮ-lBT3MLuU׵mum[3M5]WEueՕu]ue[LuMWeUeYeveWE׵mU}]ue_meY}uu[eWeYe]Y}SU[7]WM}[}am]WUօUu}eu0,뾮00m ëƱ뾮ܾj۾1nƱm+loq,ʾo/ *˺ڲ˺. jںp̲. +ǯ Cնuo 7v@!+8!c* R !T1!cJJI!* dIJhJ(PJKRj-Z JiZj)Rlc2dI(VJi)sLJƠB*JIeIɠ9HRIPJkJJJmJi-ZIRmZ# dAɜRJIZ朔:*J)RA(%JIJ+JJRZk՘RK5ZIPJkS+5PR JiVkj-PBkK*1cmJi[)[XSK5blJ-9ZkJ-R[LXk %JiZJZJ*ZlZ5b))JlX[l5blXR1XsKՔZXK+5kn5R@ eА@` cAhr9)R9'%sB)eA!99B))[(%Z, M Y D ( c*sBcAsA)cA'%B)B( lДXА@` b 1 tR:)LJ'Z )eJ%ZH 2k%bFXb*B(4d%@c9gb9!41*ƜsBc9!9 BsBBA!RJ B)tBR *pQdsBCVy1J9'%F) [cRjb BJX1!b ZvRj-ZCJXk!b5Z{j-ZsιE6' *4d%@ c9b1CJ1Ƙs)s9c9s1s9Ƙs9s9砃9sAs9!t9 *pQdsBCV1RJ)RJRJ)R!RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ) gXI:+ .4d%9'%1tNJI%5A(sRJ)ZjRJb !Z Vk)R()KJ2$ZK9ZjBJRkuRRIZm-Zk-bl%ZkZL[K-bKb1 npHqBCV!2J9眃B!R1砃B!DJ1會B!1 B1B!R: PJ RJBRJ !B(RJ)!J)RJ)%B(RJ)B(RJ)B(RJ)BRJ)RBRJ)RJ(!RJ)RJ %RJ)RJ)!RJ)RJ)#$"l4LQH h  "$OggS.ѪJ}Ѯ/.7957;R]^_[Y^``]]d^ZX]ac`d]\_YY\`^\aceacc\]Z^cbzP*̀l;(_k| 4q~r?fLٜQIf zR*T.d17^X=cXDDga(}HRFǓ-|r7zQ*MrVv7y`Vҋ%٢R? eprd1cz'`MߔzV@eD~FeJOu|Q)Z}^6t&O 41zV`i0{P]dpqAq# #/YWvz+@`{-W[ !$W7S,ŶMJ5qW7ֹډǯȴ zMfqlQi|2x6ͧw[67!>t?pHb独ytQ7;y6z96_(#ꘙָ .w74.˟<9 2ahj꿹$Jug:-v%D=v.:즲w(=ꨋ~!Pb\$\/j>daI$vs`1KTq+$Y0{qd@m625%ӽaloK~]@-MTj  `A@?UN:fKP)"JJփm6ڬikU-~Sd_vx7| 2 C]NukIרxr/@â?vXV->VL6;FMX򺊡P9Uij ^kuW櫓1l`]s;mL$FKoid*^R(A#$OFo:Ii$JC(8Ǭ$_5 \xc;9ٙLsuŽJG< g28ޢ|[wXZ^ Vb?%?k Y` c4+"]󾶚 IbLxدfݰ_#jG9 KuqZ!RdŽo~^Pk<@X٬tOaYѾ{ldL&ϥ֐$ 䴼5**c'OmՒ0S_hBT#c|}4zywaYq|kOx);tI?폼hɇ$DOtFÞ?f+ tz\N!N{y<<гrMn~^ 啕I[(-(MFߥ|3 9.qO_*gۥI\$.1y< ~!. B㲢ݔ9asa7DqEZd%O{Pz'R0R?'G~}r>~ ( Nj$Z\7]MK( +3fыktzJ5 l>YB>إ+\P_w ~Yd2ZHSM*wmlsmػ@JTZ1G HYw'}]g`73)zl".+Hry~xMz\c@H[Lsu頋h b7k- ,. w$#X69Xǎv]p-ӧO9z X3_k?a[6\zRW{rnX\7z(<(NS9$)h:+ ~I=bÂ#s̳z\ @`p"&8Ky[2ʨA^ t`׿;{>vf=q+>9꽄p K5ʗ9켛 ~!XhQD?7Z*ezij".>7{-eȕ0R%LCw~@Z^8hUpR1K'"G:?Vq:: Y"h,zGn>Sֆyd ?[΍*dO$yp{};㪎vu8FU%{x  |::CS mgqyRQTN{4da& *68'%=/_rR; ~_ VJr`gf}ӹsfW1fMl9vLݰ8C&1gE՞4~װkݥm~N8zC ԧd߉l=nH;a윗oz2`xy8n$*H@1i E&Wϧ;[t[{f^SfAb^^@cgc*6;t37CMB,z0\teđ=ZhH7T( o;(0 W}[[mX^;1v HŕI7cN9pV6pȫ׀[=ЄӘ[*_A?s!c}IizDt{eSXHس y榫 .o~Uh6 1'NW:Y81gm]镔2+sxzSdWz] *+]ZMdEk;~ya+ t.A6 ٽ$U=QtYW Q*Zn':&z) VM%XYU8m=̂.s)א g, nNuYp~rbM,z]W @Z6mOSi*~\ @ };FGþqh7vwmZM6ʥݣ߹'Hn'Ɉ L<h:iT˯v|~} V_ԝuq9|~OJuQ[k+F:6yM+R<~jm,;=n`=қqㄫ>z@v tQ'̖fOk+;u+CJJݝL'z&w)̀{'/ йi69P= %ZFЫܧ~! @hֵ-^(\>O\LH'L눂C:=gB.dt*~Hxz]b ,zW E='Ʃ o ?kwq>do6EZx<|MMr1 C@3ܯ.ſdr|w~!H ZU/YMFE-ݳ̿ ;'!ǍIGF="Ƕ[뚆!Ғ$勈͛ll~R@5^ Ziз^Xz (_`@ z'Ϧ5C|Lv]ŋ"yM%h>ba րLCO<{_#1t~@h @dR6:F'gk/uZOeSVkd_[D=կ pI^QNIm1]z @v@1ɾ6 g0Sq& RQ8!z>^C܇4AAp$DlvuOctfs H.Lb6zS}h@Tሱ1[ö]NZXu.X/6;f# Q9"QIny^U6DĔcO>X_PQQoY%wΦ.%cvodi)mx,.  | pAiM@UQTSvSBwqwh PWպ^V6F)i. 9 G8KH%~ |ӟeS 5CfnHvKyIƚHdM)i5d+U8LA{L?pHyT֮^^]Љ긱fNv X8>dXm1DH[Y"cl4HmG~e0*p/Δ>Vad|_$o_z%[S=HhАlJY@*+M$ˋ0аjl fX ~@v ޔ21B IFGޖ4ц&wa~q92=]Չ;ޱY$vQz  bTŨw6l)i2\ l &ivF_J {ERR.A^t*\ݍqv uOcKl2FC]ZɤVa5S r!gher{Beuk5qSUڻ4`{N/W_cЁu>=j;*m1n{A=l}w\EfY$M:  }G1UP{0~_ߏ.dƫZtlNWwo#s>coSwhz  .g[kèqx{L#}͎VpkW|{tm2a)Ot9Iqg ^3?Pf%_~d?`23.b>2{Zu?5,}.3ox?M>] Z2J>V#]v.:=zXU4(>ʊVGFv.!~ݽﵺ^.I"DGG_- x$:uK Q='UkW~ 3A1_/7^%ɔ>'S.-e/Z $:g!y&̉SbYsx)\lS l Zf#ʫ~!a/~Zfuڲԍo&q9w lεZ۵c>~5_=]}MZL~_ժ':ήo0x )S`12Q^ad"oejHI0"Bo1,ܬ<#_&=]=W/q~_)꠽e_t։WIQ:'_I:6r9tMd':n0S]U ĶfWNSUb$wfly>O(fz @AojvivI`EϏ- :jc9.Q&dsYLu&ZM* хee vɏ~?~!j@c坆0icBqfM8A5mF"N.OBMA7bMy9Vsz +'8x4ȔM,ڑߥ:~^ hcB@̬?B=CvIgg=wWVc̤}9z=f Bc 1_%`/ ܄+L2k]  /y'~h}@rtYj|bx剄LyojKC& أ1Me J5w<=~M1J^<:;N٩qPzj m #1̇lb[f>%L$q7])x3a/E72NO^`0.c/>0_D'Q_Pu0?OOkok/'†HMO?$R8ٜD* 72*OO8`FgS~^zSeWŢ5Fn\~4Ds1i]r4 Y,8,eX!J$4&GzdW ĠQ`4RR>u  |?GB": ~^@.kgq.L\`ox]ѫF*T¨<ad:0AʫݰmIY`Ս]&n>μ?OggSѪJ}aD-^[``]\Y\^[X^[[\a`\^ZY_\Z]^`]]]_\X^Z\\\ae\Z_YYzX+ZG1fs)IiE9z)$6_k4C7, @,kxmn+$@įy7qg1` +=5O6u)G9e v=[0ҢHp䐺d, tgI^c<>I04OgvT1_$zS| D73} AO{F-S7CȘN~5$Ewgou(r):̡r}IDz{Ecr9[MB|/ LtcO'#/;t>]a?8HQ~}u~ ٚ]bϤkk,V26֩%Kek'9mLo/e ziu;R/?Z-Ӂj+y3:He8ї ?G~Sd%iUZ -d^I;{X@H隀y"uo.n19"งan0:^O t.nÝΙ_lM_߳'z3$AM|Q|W|[Ҫ{z/)Md: kzYq^m|5}N();;yR-q*.Dq1!t/.:2Y}5ӹ-#E 1"I$~^ DnaOa7Kڻtr|>BXP y.N gB0#'kE{R]ad!3gy1_1 ~a(Po#ޚ͢cg`ȡyd1Uc:'dbݝWiKr*v_NPw˞ z;]Pɖ\+ lwL-||mdX:|92(slqZXqB(`?:w`@ YwO/QjԤUT~ dh-CCL4X]Ξ3+UC/t%nTXȣ>/7d;N>瑵c9 D!ggQ&5X$I4;~_@H W!b`X)ƴ?JscXOn5ƢV \7x8J8tosy9"[};Ĩ|Sbx6rG߼ϫ{ǟ}=t"=~ ( @ョU*nWhb~ \]^lJ^Sw~qd='Mv!W`d;*=/&2_n}m6Tb6])Ey1{/h C[P^_)w ,KU^Ie9az ) +#36\5,rWxJ ˦&25)vHEO>S E]/ =l5m~ BAЌemҖgtb67uۺX^2&^]|Liqb1(7b"\#~9)j|F tFY:)!jcXFP6Qqi?=jKpsrjiG:S,uY"#,y渏zcf juL}am#7zX].N6 O(w[3{hQ-+[>O<&agh¯??Sw^Kq @cy;Ǖqy(#kv@39RK}w[cNByǧV1ٖHO< TR^xw'e6m/G_۵WZtt.q0{RW4Lzm( du,8;TEgxo0g!_4hc#D)ϓtT2mO"}H[^"Cқ晆0!1'`1q.[)Q:@<% ~ h49rRyYjiH6aJG -y뺩\UfLܕ/N `iA_Ð*i.S++yJ6ES,=Z%n-L M$Q;59HC{l9[s]oB@}a_P# VaYuG~KWDY}y_O5bJWc8RUJ7BA3V*kΈܭ!ljzڭOb>fk̗p6f0vR Ƶ/^zˇq.v~!D ."qm.Xs}>{3ɜ297IBP(|pCUr \ S> .NH<5B@w}dtzBbRsޡ-uΝZwVuD)긪EfGR)ˆtt}ᡂvp(fvͣzEdb$#zY#^ʎ3JN 6CIKf)Rfv[?G "oy_U<Y#3x9(y%~agD?Ӥc\>{3q/5+קE~Mt p ]PE<-1UNZ" 7+ƛDC/+( 4崊$.v P!d;\'O:$TЃb(k{gnt8{AA 9k԰<բb?nH‹_ku~! @ bHk6dYP+vKLuєrNkxt8 *8MuՐ-I\`/gF?l<ѧ%b;1z+QםKqazݳ/-p$Pzv=.9'[7_h4ıaC=@sK$:4lJy-zwaQle$~_j%X!8]S͒ݜsqzHtG = 2<"^w[qH ޽܎?a\OggS@ѪJ}.5 \a_]^`a_bH7 z^]cGQJx֬v_q7u3I4/o-AKeOx \p.e},a A | Q ~^ :Km\ t'?\V)eL8I_;He0k/_ HX_ H8" Ps;'y&ҺJ~_ @L)ey*tu8ζ ]Iyb}5_⭢c*0zAJL11q|2طЙ{|v vx`Sh1pM<^{Ñ^Y2کI9QZM8V9ރ)Jrze"e{fS]۩qqLzS}$jU5s{ʒ,'${lYzeH¼ hVHp^8Vg|A M}ﲡWif&zS|AЀG @ַ)oLd%ϧԜL^"\VBs_"!mF/26~.o$;r}cs/~! c0W0Rs_jrCƋMFj,4-zYmÂn=B͡s!vy3 !29v8>6~%z_+x@P< ?D63Mۥ۳iE}[fчK 2v^)Sd^cX!m1Aն55*Kj`nH%74wm~]~ S'gB] l-@4L3Uhڨ|wuHneo5iv[Yȳ*A>x έGKc<%w7ι^#v #K=s_C.}יp4b=yyzP*MC%8k󏝀RE\qM.z}Mopidy-2.0.0/tests/data/scanner/simple/song1.mp30000644000175000017500000002222012441116637021626 0ustar jodaljodal00000000000000ID3vTIT2 tracknameTPE1nameTALB albumnameTDRC2006TRCK01/02HXingA  ""%)),00577<_ѣ;DNwتĪ5giԷ}n{(n(D^AI11Jy{ui <|;w4fk}~NA&. >D!*Poцz4;B8lʮQVwygxjr{ʷ0g(, ((z8L $fFVmA&DbqAFI`M`d1LC#; ޥvvmHݻQL.IMJf*"g?=!uF9')bC%dH4<#:q98oEd:PvM]V>v~yVˏ뫿W[(aB: ,6if<7!"cH'-h|16(V$> BE˴SJ/PnjݩsxVfx]M|ӥ͔bq T-h  0ӹM݌M89\R#;ޖw5,:,,X(EklUu uZ;Tvҥt ̪fy(~8,;]:hY*{<2M)(4;8Rk/_P'C0Ԋ?^Jgu(f(YVy+$r5AoGN7\e8(28L7f^Y`H$ ,@dF04ER'zX֑f4ZiIzT彡1S {UVgz;[0qvʯgtDP"=esF֚G.Kd/9:FtPYْW5W}\83lӅT xa/';­gMmV(8L_捡'v.ހms@0` z&u9`+A1e Eq [ܷ -pĊY̓3JUEVuMVxw'(f8L8 H gqbWbVxZSY琔}rlpiq&PM˅@9X`BC rm~mXV^.g2->(r(dVh9ѵK%X駦I(I9X aF&E"PJR*Qd{%  > hVS*K,ql[hm?Fnt^cW]PfkSC³VxGu.ed|{%e4Ѻ$ *R /~:FTiQbKUOV`e`R':uAH5ҝ`x:( v8R i@T*:*t6 Jtkg#QY?23^L@_4H!Pq[:!١Q*I(h\dXM`BVxlN8. ;\e%[(r8RL C=v6ShG& !U+}6QTa `>f&`{BK nm|R\ v@WaVx/$ ֩*pZ$T FM^;(/Ar8 $fei%vgo~n)UlًnQ/wNU)9udGojާg?$W˟(4݃n\\ٷSkRY\DYDFLĮ&A29R8Lѫ@]A$UlQQ(t|ۄH5+`qqz|Rog( ^BL:^oOD-cs!vNMw(U*#bx O"hP6,5i"~L*GޤZ$yT-\ީA7*gy'Ո씼!l! ( 88 *pW gsA9j |6%bVYjT7W+Zu4ޚԎ3]֭UVxӓhwwJqSRk9eYHh9ٗzo7w <<"y>Z8 -ċ {±c+T9 j1e{q& eEFAEw)( ^P톏Adb"S"QT"*W!$!VPg(1}9Jp`j DjfHTu/f؏ `P1gJ@xQU Rgxʟ;6z{(!zBLls1:FN"2): a!=FJ;H "ei[4QI',, <Sg.co=qw8S¶MEFƨ{CMI|TKs˃}<*LК/hvK!V?!wGd (bIJJ0@u,y3Q8H|bwIYBY:8$P!gYBn^~} {ֵBjVwvU9P1RӢVy (Yb8L "/Tuµ܌@O<^A^!A`q-;J A5,@,asAаrw\tț|\TB$0`vr0ugx 6\zRCNUm!r" 7((q~RLP$p'HCFOFttig(겏Rh4<\S=,`H2XyީG)weloʁIr0Q3h,,IVz$nRU7 YEˁTTFnhCj(1z8(+ MJǔ 64 Tfļ+Hy'$"4znq/#TKrwp#.\V.|d'53%2= 1T8=bGHal-0N BQnPP$elf m__U=noz bŕVwziIKIf(fBLʌ6JN'ZԽ*ʓ O-;niJp|ZF `dr+y{AV":ׄf 6$Pz`b܄.pLVxxnLɖnt/TR(xҘ(ٺ8P 9LVա2mX֥*U8dCR:G HI ʁ[i}i`L}6bV{%sc,-P5"y7A"(.a:F`2aJAmN>@H[s,%"aAׁ1 Fs-s+Uqj\X&(K2 WWﮘ>][IFX0ӻ\!K痮[ڴtW^(ySA H%2qMaETԙe1L8+t>_@ Ejj=c^i&:zPȨ*&.%Wjm `B3-L7E`wsKbXX:O 8a 5#Cb H <A &+gG"0,R)')0`}2?bz?/(ՍpHe*VC:<ߍ/Ԩc3C.jLAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU*HUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUeHUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUTAGtrackname name albumname 2006 Mopidy-2.0.0/tests/data/scanner/plain.txt0000664000175000017500000000006112575504731020536 0ustar jodaljodal00000000000000Some plain text file with nothing special in it. Mopidy-2.0.0/tests/data/scanner/example.log0000644000175000017500000000031612441116637021025 0ustar jodaljodal00000000000000Exact Audio Copy V1.0 beta 3 from 29. August 2011 EAC extraction logfile from 14. May 2013, 13:26 Mopidy-2.0.0/tests/data/scanner/image/0000775000175000017500000000000012660436443017756 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/data/scanner/image/test.png0000644000175000017500000000026012441116637021434 0ustar jodaljodal00000000000000PNG  IHDRZsRGB pHYs  tIME 0UtEXtCommentCreated with GIMPWIDAT8cb@.`bj_ѣ;DNwتĪ5giԷ}n{(n(D^AI11Jy{ui <|;w4fk}~NA&. >D!*Poцz4;B8lʮQVwygxjr{ʷ0g(, ((z8L $fFVmA&DbqAFI`M`d1LC#; ޥvvmHݻQL.IMJf*"g?=!uF9')bC%dH4<#:q98oEd:PvM]V>v~yVˏ뫿W[(aB: ,6if<7!"cH'-h|16(V$> BE˴SJ/PnjݩsxVfx]M|ӥ͔bq T-h  0ӹM݌M89\R#;ޖw5,:,,X(EklUu uZ;Tvҥt ̪fy(~8,;]:hY*{<2M)(4;8Rk/_P'C0Ԋ?^Jgu(f(YVy+$r5AoGN7\e8(28L7f^Y`H$ ,@dF04ER'zX֑f4ZiIzT彡1S {UVgz;[0qvʯgtDP"=esF֚G.Kd/9:FtPYْW5W}\83lӅT xa/';­gMmV(8L_捡'v.ހms@0` z&u9`+A1e Eq [ܷ -pĊY̓3JUEVuMVxw'(f8L8 H gqbWbVxZSY琔}rlpiq&PM˅@9X`BC rm~mXV^.g2->(r(dVh9ѵK%X駦I(I9X aF&E"PJR*Qd{%  > hVS*K,ql[hm?Fnt^cW]PfkSC³VxGu.ed|{%e4Ѻ$ *R /~:FTiQbKUOV`e`R':uAH5ҝ`x:( v8R i@T*:*t6 Jtkg#QY?23^L@_4H!Pq[:!١Q*I(h\dXM`BVxlN8. ;\e%[(r8RL C=v6ShG& !U+}6QTa `>f&`{BK nm|R\ v@WaVx/$ ֩*pZ$T FM^;(/Ar8 $fei%vgo~n)UlًnQ/wNU)9udGojާg?$W˟(4݃n\\ٷSkRY\DYDFLĮ&A29R8Lѫ@]A$UlQQ(t|ۄH5+`qqz|Rog( ^BL:^oOD-cs!vNMw(U*#bx O"hP6,5i"~L*GޤZ$yT-\ީA7*gy'Ո씼!l! ( 88 *pW gsA9j |6%bVYjT7W+Zu4ޚԎ3]֭UVxӓhwwJqSRk9eYHh9ٗzo7w <<"y>Z8 -ċ {±c+T9 j1e{q& eEFAEw)( ^P톏Adb"S"QT"*W!$!VPg(1}9Jp`j DjfHTu/f؏ `P1gJ@xQU Rgxʟ;6z{(!zBLls1:FN"2): a!=FJ;H "ei[4QI',, <Sg.co=qw8S¶MEFƨ{CMI|TKs˃}<*LК/hvK!V?!wGd (bIJJ0@u,y3Q8H|bwIYBY:8$P!gYBn^~} {ֵBjVwvU9P1RӢVy (Yb8L "/Tuµ܌@O<^A^!A`q-;J A5,@,asAаrw\tț|\TB$0`vr0ugx 6\zRCNUm!r" 7((q~RLP$p'HCFOFttig(겏Rh4<\S=,`H2XyީG)weloʁIr0Q3h,,IVz$nRU7 YEˁTTFnhCj(1z8(+ MJǔ 64 Tfļ+Hy'$"4znq/#TKrwp#.\V.|d'53%2= 1T8=bGHal-0N BQnPP$elf m__U=noz bŕVwziIKIf(fBLʌ6JN'ZԽ*ʓ O-;niJp|ZF `dr+y{AV":ׄf 6$Pz`b܄.pLVxxnLɖnt/TR(xҘ(ٺ8P 9LVա2mX֥*U8dCR:G HI ʁ[i}i`L}6bV{%sc,-P5"y7A"(.a:F`2aJAmN>@H[s,%"aAׁ1 Fs-s+Uqj\X&(K2 WWﮘ>][IFX0ӻ\!K痮[ڴtW^(ySA H%2qMaETԙe1L8+t>_@ Ejj=c^i&:zPȨ*&.%Wjm `B3-L7E`wsKbXX:O 8a 5#Cb H <A &+gG"0,R)')0`}2?bz?/(ՍpHe*VC:<ߍ/Ԩc3C.jLAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU*HUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUeHUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUTAGtrackname name albumname 2006 Mopidy-2.0.0/tests/data/scanner/advanced/0000775000175000017500000000000012660436443020441 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/data/scanner/advanced/subdir2/0000775000175000017500000000000012660436443022013 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/data/scanner/advanced/subdir2/song6.mp30000644000175000017500000002222012441116637023461 0ustar jodaljodal00000000000000ID3vTIT2 tracknameTPE1nameTALB albumnameTDRC2006TRCK01/02HXingA  ""%)),00577<_ѣ;DNwتĪ5giԷ}n{(n(D^AI11Jy{ui <|;w4fk}~NA&. >D!*Poцz4;B8lʮQVwygxjr{ʷ0g(, ((z8L $fFVmA&DbqAFI`M`d1LC#; ޥvvmHݻQL.IMJf*"g?=!uF9')bC%dH4<#:q98oEd:PvM]V>v~yVˏ뫿W[(aB: ,6if<7!"cH'-h|16(V$> BE˴SJ/PnjݩsxVfx]M|ӥ͔bq T-h  0ӹM݌M89\R#;ޖw5,:,,X(EklUu uZ;Tvҥt ̪fy(~8,;]:hY*{<2M)(4;8Rk/_P'C0Ԋ?^Jgu(f(YVy+$r5AoGN7\e8(28L7f^Y`H$ ,@dF04ER'zX֑f4ZiIzT彡1S {UVgz;[0qvʯgtDP"=esF֚G.Kd/9:FtPYْW5W}\83lӅT xa/';­gMmV(8L_捡'v.ހms@0` z&u9`+A1e Eq [ܷ -pĊY̓3JUEVuMVxw'(f8L8 H gqbWbVxZSY琔}rlpiq&PM˅@9X`BC rm~mXV^.g2->(r(dVh9ѵK%X駦I(I9X aF&E"PJR*Qd{%  > hVS*K,ql[hm?Fnt^cW]PfkSC³VxGu.ed|{%e4Ѻ$ *R /~:FTiQbKUOV`e`R':uAH5ҝ`x:( v8R i@T*:*t6 Jtkg#QY?23^L@_4H!Pq[:!١Q*I(h\dXM`BVxlN8. ;\e%[(r8RL C=v6ShG& !U+}6QTa `>f&`{BK nm|R\ v@WaVx/$ ֩*pZ$T FM^;(/Ar8 $fei%vgo~n)UlًnQ/wNU)9udGojާg?$W˟(4݃n\\ٷSkRY\DYDFLĮ&A29R8Lѫ@]A$UlQQ(t|ۄH5+`qqz|Rog( ^BL:^oOD-cs!vNMw(U*#bx O"hP6,5i"~L*GޤZ$yT-\ީA7*gy'Ո씼!l! ( 88 *pW gsA9j |6%bVYjT7W+Zu4ޚԎ3]֭UVxӓhwwJqSRk9eYHh9ٗzo7w <<"y>Z8 -ċ {±c+T9 j1e{q& eEFAEw)( ^P톏Adb"S"QT"*W!$!VPg(1}9Jp`j DjfHTu/f؏ `P1gJ@xQU Rgxʟ;6z{(!zBLls1:FN"2): a!=FJ;H "ei[4QI',, <Sg.co=qw8S¶MEFƨ{CMI|TKs˃}<*LК/hvK!V?!wGd (bIJJ0@u,y3Q8H|bwIYBY:8$P!gYBn^~} {ֵBjVwvU9P1RӢVy (Yb8L "/Tuµ܌@O<^A^!A`q-;J A5,@,asAаrw\tț|\TB$0`vr0ugx 6\zRCNUm!r" 7((q~RLP$p'HCFOFttig(겏Rh4<\S=,`H2XyީG)weloʁIr0Q3h,,IVz$nRU7 YEˁTTFnhCj(1z8(+ MJǔ 64 Tfļ+Hy'$"4znq/#TKrwp#.\V.|d'53%2= 1T8=bGHal-0N BQnPP$elf m__U=noz bŕVwziIKIf(fBLʌ6JN'ZԽ*ʓ O-;niJp|ZF `dr+y{AV":ׄf 6$Pz`b܄.pLVxxnLɖnt/TR(xҘ(ٺ8P 9LVա2mX֥*U8dCR:G HI ʁ[i}i`L}6bV{%sc,-P5"y7A"(.a:F`2aJAmN>@H[s,%"aAׁ1 Fs-s+Uqj\X&(K2 WWﮘ>][IFX0ӻ\!K痮[ڴtW^(ySA H%2qMaETԙe1L8+t>_@ Ejj=c^i&:zPȨ*&.%Wjm `B3-L7E`wsKbXX:O 8a 5#Cb H <A &+gG"0,R)')0`}2?bz?/(ՍpHe*VC:<ߍ/Ԩc3C.jLAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU*HUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUeHUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUTAGtrackname name albumname 2006 Mopidy-2.0.0/tests/data/scanner/advanced/subdir2/song7.mp30000644000175000017500000002222012441116637023462 0ustar jodaljodal00000000000000ID3vTIT2 tracknameTPE1nameTALB albumnameTDRC2006TRCK01/02HXingA  ""%)),00577<_ѣ;DNwتĪ5giԷ}n{(n(D^AI11Jy{ui <|;w4fk}~NA&. >D!*Poцz4;B8lʮQVwygxjr{ʷ0g(, ((z8L $fFVmA&DbqAFI`M`d1LC#; ޥvvmHݻQL.IMJf*"g?=!uF9')bC%dH4<#:q98oEd:PvM]V>v~yVˏ뫿W[(aB: ,6if<7!"cH'-h|16(V$> BE˴SJ/PnjݩsxVfx]M|ӥ͔bq T-h  0ӹM݌M89\R#;ޖw5,:,,X(EklUu uZ;Tvҥt ̪fy(~8,;]:hY*{<2M)(4;8Rk/_P'C0Ԋ?^Jgu(f(YVy+$r5AoGN7\e8(28L7f^Y`H$ ,@dF04ER'zX֑f4ZiIzT彡1S {UVgz;[0qvʯgtDP"=esF֚G.Kd/9:FtPYْW5W}\83lӅT xa/';­gMmV(8L_捡'v.ހms@0` z&u9`+A1e Eq [ܷ -pĊY̓3JUEVuMVxw'(f8L8 H gqbWbVxZSY琔}rlpiq&PM˅@9X`BC rm~mXV^.g2->(r(dVh9ѵK%X駦I(I9X aF&E"PJR*Qd{%  > hVS*K,ql[hm?Fnt^cW]PfkSC³VxGu.ed|{%e4Ѻ$ *R /~:FTiQbKUOV`e`R':uAH5ҝ`x:( v8R i@T*:*t6 Jtkg#QY?23^L@_4H!Pq[:!١Q*I(h\dXM`BVxlN8. ;\e%[(r8RL C=v6ShG& !U+}6QTa `>f&`{BK nm|R\ v@WaVx/$ ֩*pZ$T FM^;(/Ar8 $fei%vgo~n)UlًnQ/wNU)9udGojާg?$W˟(4݃n\\ٷSkRY\DYDFLĮ&A29R8Lѫ@]A$UlQQ(t|ۄH5+`qqz|Rog( ^BL:^oOD-cs!vNMw(U*#bx O"hP6,5i"~L*GޤZ$yT-\ީA7*gy'Ո씼!l! ( 88 *pW gsA9j |6%bVYjT7W+Zu4ޚԎ3]֭UVxӓhwwJqSRk9eYHh9ٗzo7w <<"y>Z8 -ċ {±c+T9 j1e{q& eEFAEw)( ^P톏Adb"S"QT"*W!$!VPg(1}9Jp`j DjfHTu/f؏ `P1gJ@xQU Rgxʟ;6z{(!zBLls1:FN"2): a!=FJ;H "ei[4QI',, <Sg.co=qw8S¶MEFƨ{CMI|TKs˃}<*LК/hvK!V?!wGd (bIJJ0@u,y3Q8H|bwIYBY:8$P!gYBn^~} {ֵBjVwvU9P1RӢVy (Yb8L "/Tuµ܌@O<^A^!A`q-;J A5,@,asAаrw\tț|\TB$0`vr0ugx 6\zRCNUm!r" 7((q~RLP$p'HCFOFttig(겏Rh4<\S=,`H2XyީG)weloʁIr0Q3h,,IVz$nRU7 YEˁTTFnhCj(1z8(+ MJǔ 64 Tfļ+Hy'$"4znq/#TKrwp#.\V.|d'53%2= 1T8=bGHal-0N BQnPP$elf m__U=noz bŕVwziIKIf(fBLʌ6JN'ZԽ*ʓ O-;niJp|ZF `dr+y{AV":ׄf 6$Pz`b܄.pLVxxnLɖnt/TR(xҘ(ٺ8P 9LVա2mX֥*U8dCR:G HI ʁ[i}i`L}6bV{%sc,-P5"y7A"(.a:F`2aJAmN>@H[s,%"aAׁ1 Fs-s+Uqj\X&(K2 WWﮘ>][IFX0ӻ\!K痮[ڴtW^(ySA H%2qMaETԙe1L8+t>_@ Ejj=c^i&:zPȨ*&.%Wjm `B3-L7E`wsKbXX:O 8a 5#Cb H <A &+gG"0,R)')0`}2?bz?/(ՍpHe*VC:<ߍ/Ԩc3C.jLAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU*HUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUeHUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUTAGtrackname name albumname 2006 Mopidy-2.0.0/tests/data/scanner/advanced/song3.mp30000644000175000017500000002222012441116637022104 0ustar jodaljodal00000000000000ID3vTIT2 tracknameTPE1nameTALB albumnameTDRC2006TRCK01/02HXingA  ""%)),00577<_ѣ;DNwتĪ5giԷ}n{(n(D^AI11Jy{ui <|;w4fk}~NA&. >D!*Poцz4;B8lʮQVwygxjr{ʷ0g(, ((z8L $fFVmA&DbqAFI`M`d1LC#; ޥvvmHݻQL.IMJf*"g?=!uF9')bC%dH4<#:q98oEd:PvM]V>v~yVˏ뫿W[(aB: ,6if<7!"cH'-h|16(V$> BE˴SJ/PnjݩsxVfx]M|ӥ͔bq T-h  0ӹM݌M89\R#;ޖw5,:,,X(EklUu uZ;Tvҥt ̪fy(~8,;]:hY*{<2M)(4;8Rk/_P'C0Ԋ?^Jgu(f(YVy+$r5AoGN7\e8(28L7f^Y`H$ ,@dF04ER'zX֑f4ZiIzT彡1S {UVgz;[0qvʯgtDP"=esF֚G.Kd/9:FtPYْW5W}\83lӅT xa/';­gMmV(8L_捡'v.ހms@0` z&u9`+A1e Eq [ܷ -pĊY̓3JUEVuMVxw'(f8L8 H gqbWbVxZSY琔}rlpiq&PM˅@9X`BC rm~mXV^.g2->(r(dVh9ѵK%X駦I(I9X aF&E"PJR*Qd{%  > hVS*K,ql[hm?Fnt^cW]PfkSC³VxGu.ed|{%e4Ѻ$ *R /~:FTiQbKUOV`e`R':uAH5ҝ`x:( v8R i@T*:*t6 Jtkg#QY?23^L@_4H!Pq[:!١Q*I(h\dXM`BVxlN8. ;\e%[(r8RL C=v6ShG& !U+}6QTa `>f&`{BK nm|R\ v@WaVx/$ ֩*pZ$T FM^;(/Ar8 $fei%vgo~n)UlًnQ/wNU)9udGojާg?$W˟(4݃n\\ٷSkRY\DYDFLĮ&A29R8Lѫ@]A$UlQQ(t|ۄH5+`qqz|Rog( ^BL:^oOD-cs!vNMw(U*#bx O"hP6,5i"~L*GޤZ$yT-\ީA7*gy'Ո씼!l! ( 88 *pW gsA9j |6%bVYjT7W+Zu4ޚԎ3]֭UVxӓhwwJqSRk9eYHh9ٗzo7w <<"y>Z8 -ċ {±c+T9 j1e{q& eEFAEw)( ^P톏Adb"S"QT"*W!$!VPg(1}9Jp`j DjfHTu/f؏ `P1gJ@xQU Rgxʟ;6z{(!zBLls1:FN"2): a!=FJ;H "ei[4QI',, <Sg.co=qw8S¶MEFƨ{CMI|TKs˃}<*LК/hvK!V?!wGd (bIJJ0@u,y3Q8H|bwIYBY:8$P!gYBn^~} {ֵBjVwvU9P1RӢVy (Yb8L "/Tuµ܌@O<^A^!A`q-;J A5,@,asAаrw\tț|\TB$0`vr0ugx 6\zRCNUm!r" 7((q~RLP$p'HCFOFttig(겏Rh4<\S=,`H2XyީG)weloʁIr0Q3h,,IVz$nRU7 YEˁTTFnhCj(1z8(+ MJǔ 64 Tfļ+Hy'$"4znq/#TKrwp#.\V.|d'53%2= 1T8=bGHal-0N BQnPP$elf m__U=noz bŕVwziIKIf(fBLʌ6JN'ZԽ*ʓ O-;niJp|ZF `dr+y{AV":ׄf 6$Pz`b܄.pLVxxnLɖnt/TR(xҘ(ٺ8P 9LVա2mX֥*U8dCR:G HI ʁ[i}i`L}6bV{%sc,-P5"y7A"(.a:F`2aJAmN>@H[s,%"aAׁ1 Fs-s+Uqj\X&(K2 WWﮘ>][IFX0ӻ\!K痮[ڴtW^(ySA H%2qMaETԙe1L8+t>_@ Ejj=c^i&:zPȨ*&.%Wjm `B3-L7E`wsKbXX:O 8a 5#Cb H <A &+gG"0,R)')0`}2?bz?/(ՍpHe*VC:<ߍ/Ԩc3C.jLAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU*HUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUeHUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUTAGtrackname name albumname 2006 Mopidy-2.0.0/tests/data/scanner/advanced/subdir1/0000775000175000017500000000000012660436443022012 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/data/scanner/advanced/subdir1/song5.mp30000644000175000017500000002222012441116637023457 0ustar jodaljodal00000000000000ID3vTIT2 tracknameTPE1nameTALB albumnameTDRC2006TRCK01/02HXingA  ""%)),00577<_ѣ;DNwتĪ5giԷ}n{(n(D^AI11Jy{ui <|;w4fk}~NA&. >D!*Poцz4;B8lʮQVwygxjr{ʷ0g(, ((z8L $fFVmA&DbqAFI`M`d1LC#; ޥvvmHݻQL.IMJf*"g?=!uF9')bC%dH4<#:q98oEd:PvM]V>v~yVˏ뫿W[(aB: ,6if<7!"cH'-h|16(V$> BE˴SJ/PnjݩsxVfx]M|ӥ͔bq T-h  0ӹM݌M89\R#;ޖw5,:,,X(EklUu uZ;Tvҥt ̪fy(~8,;]:hY*{<2M)(4;8Rk/_P'C0Ԋ?^Jgu(f(YVy+$r5AoGN7\e8(28L7f^Y`H$ ,@dF04ER'zX֑f4ZiIzT彡1S {UVgz;[0qvʯgtDP"=esF֚G.Kd/9:FtPYْW5W}\83lӅT xa/';­gMmV(8L_捡'v.ހms@0` z&u9`+A1e Eq [ܷ -pĊY̓3JUEVuMVxw'(f8L8 H gqbWbVxZSY琔}rlpiq&PM˅@9X`BC rm~mXV^.g2->(r(dVh9ѵK%X駦I(I9X aF&E"PJR*Qd{%  > hVS*K,ql[hm?Fnt^cW]PfkSC³VxGu.ed|{%e4Ѻ$ *R /~:FTiQbKUOV`e`R':uAH5ҝ`x:( v8R i@T*:*t6 Jtkg#QY?23^L@_4H!Pq[:!١Q*I(h\dXM`BVxlN8. ;\e%[(r8RL C=v6ShG& !U+}6QTa `>f&`{BK nm|R\ v@WaVx/$ ֩*pZ$T FM^;(/Ar8 $fei%vgo~n)UlًnQ/wNU)9udGojާg?$W˟(4݃n\\ٷSkRY\DYDFLĮ&A29R8Lѫ@]A$UlQQ(t|ۄH5+`qqz|Rog( ^BL:^oOD-cs!vNMw(U*#bx O"hP6,5i"~L*GޤZ$yT-\ީA7*gy'Ո씼!l! ( 88 *pW gsA9j |6%bVYjT7W+Zu4ޚԎ3]֭UVxӓhwwJqSRk9eYHh9ٗzo7w <<"y>Z8 -ċ {±c+T9 j1e{q& eEFAEw)( ^P톏Adb"S"QT"*W!$!VPg(1}9Jp`j DjfHTu/f؏ `P1gJ@xQU Rgxʟ;6z{(!zBLls1:FN"2): a!=FJ;H "ei[4QI',, <Sg.co=qw8S¶MEFƨ{CMI|TKs˃}<*LК/hvK!V?!wGd (bIJJ0@u,y3Q8H|bwIYBY:8$P!gYBn^~} {ֵBjVwvU9P1RӢVy (Yb8L "/Tuµ܌@O<^A^!A`q-;J A5,@,asAаrw\tț|\TB$0`vr0ugx 6\zRCNUm!r" 7((q~RLP$p'HCFOFttig(겏Rh4<\S=,`H2XyީG)weloʁIr0Q3h,,IVz$nRU7 YEˁTTFnhCj(1z8(+ MJǔ 64 Tfļ+Hy'$"4znq/#TKrwp#.\V.|d'53%2= 1T8=bGHal-0N BQnPP$elf m__U=noz bŕVwziIKIf(fBLʌ6JN'ZԽ*ʓ O-;niJp|ZF `dr+y{AV":ׄf 6$Pz`b܄.pLVxxnLɖnt/TR(xҘ(ٺ8P 9LVա2mX֥*U8dCR:G HI ʁ[i}i`L}6bV{%sc,-P5"y7A"(.a:F`2aJAmN>@H[s,%"aAׁ1 Fs-s+Uqj\X&(K2 WWﮘ>][IFX0ӻ\!K痮[ڴtW^(ySA H%2qMaETԙe1L8+t>_@ Ejj=c^i&:zPȨ*&.%Wjm `B3-L7E`wsKbXX:O 8a 5#Cb H <A &+gG"0,R)')0`}2?bz?/(ՍpHe*VC:<ߍ/Ԩc3C.jLAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU*HUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUeHUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUTAGtrackname name albumname 2006 Mopidy-2.0.0/tests/data/scanner/advanced/subdir1/subsubdir/0000775000175000017500000000000012660436443024014 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/data/scanner/advanced/subdir1/subsubdir/song8.mp30000644000175000017500000002222012441116637025464 0ustar jodaljodal00000000000000ID3vTIT2 tracknameTPE1nameTALB albumnameTDRC2006TRCK01/02HXingA  ""%)),00577<_ѣ;DNwتĪ5giԷ}n{(n(D^AI11Jy{ui <|;w4fk}~NA&. >D!*Poцz4;B8lʮQVwygxjr{ʷ0g(, ((z8L $fFVmA&DbqAFI`M`d1LC#; ޥvvmHݻQL.IMJf*"g?=!uF9')bC%dH4<#:q98oEd:PvM]V>v~yVˏ뫿W[(aB: ,6if<7!"cH'-h|16(V$> BE˴SJ/PnjݩsxVfx]M|ӥ͔bq T-h  0ӹM݌M89\R#;ޖw5,:,,X(EklUu uZ;Tvҥt ̪fy(~8,;]:hY*{<2M)(4;8Rk/_P'C0Ԋ?^Jgu(f(YVy+$r5AoGN7\e8(28L7f^Y`H$ ,@dF04ER'zX֑f4ZiIzT彡1S {UVgz;[0qvʯgtDP"=esF֚G.Kd/9:FtPYْW5W}\83lӅT xa/';­gMmV(8L_捡'v.ހms@0` z&u9`+A1e Eq [ܷ -pĊY̓3JUEVuMVxw'(f8L8 H gqbWbVxZSY琔}rlpiq&PM˅@9X`BC rm~mXV^.g2->(r(dVh9ѵK%X駦I(I9X aF&E"PJR*Qd{%  > hVS*K,ql[hm?Fnt^cW]PfkSC³VxGu.ed|{%e4Ѻ$ *R /~:FTiQbKUOV`e`R':uAH5ҝ`x:( v8R i@T*:*t6 Jtkg#QY?23^L@_4H!Pq[:!١Q*I(h\dXM`BVxlN8. ;\e%[(r8RL C=v6ShG& !U+}6QTa `>f&`{BK nm|R\ v@WaVx/$ ֩*pZ$T FM^;(/Ar8 $fei%vgo~n)UlًnQ/wNU)9udGojާg?$W˟(4݃n\\ٷSkRY\DYDFLĮ&A29R8Lѫ@]A$UlQQ(t|ۄH5+`qqz|Rog( ^BL:^oOD-cs!vNMw(U*#bx O"hP6,5i"~L*GޤZ$yT-\ީA7*gy'Ո씼!l! ( 88 *pW gsA9j |6%bVYjT7W+Zu4ޚԎ3]֭UVxӓhwwJqSRk9eYHh9ٗzo7w <<"y>Z8 -ċ {±c+T9 j1e{q& eEFAEw)( ^P톏Adb"S"QT"*W!$!VPg(1}9Jp`j DjfHTu/f؏ `P1gJ@xQU Rgxʟ;6z{(!zBLls1:FN"2): a!=FJ;H "ei[4QI',, <Sg.co=qw8S¶MEFƨ{CMI|TKs˃}<*LК/hvK!V?!wGd (bIJJ0@u,y3Q8H|bwIYBY:8$P!gYBn^~} {ֵBjVwvU9P1RӢVy (Yb8L "/Tuµ܌@O<^A^!A`q-;J A5,@,asAаrw\tț|\TB$0`vr0ugx 6\zRCNUm!r" 7((q~RLP$p'HCFOFttig(겏Rh4<\S=,`H2XyީG)weloʁIr0Q3h,,IVz$nRU7 YEˁTTFnhCj(1z8(+ MJǔ 64 Tfļ+Hy'$"4znq/#TKrwp#.\V.|d'53%2= 1T8=bGHal-0N BQnPP$elf m__U=noz bŕVwziIKIf(fBLʌ6JN'ZԽ*ʓ O-;niJp|ZF `dr+y{AV":ׄf 6$Pz`b܄.pLVxxnLɖnt/TR(xҘ(ٺ8P 9LVա2mX֥*U8dCR:G HI ʁ[i}i`L}6bV{%sc,-P5"y7A"(.a:F`2aJAmN>@H[s,%"aAׁ1 Fs-s+Uqj\X&(K2 WWﮘ>][IFX0ӻ\!K痮[ڴtW^(ySA H%2qMaETԙe1L8+t>_@ Ejj=c^i&:zPȨ*&.%Wjm `B3-L7E`wsKbXX:O 8a 5#Cb H <A &+gG"0,R)')0`}2?bz?/(ՍpHe*VC:<ߍ/Ԩc3C.jLAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU*HUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUeHUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUTAGtrackname name albumname 2006 Mopidy-2.0.0/tests/data/scanner/advanced/subdir1/subsubdir/song9.mp30000644000175000017500000002222012441116637025465 0ustar jodaljodal00000000000000ID3vTIT2 tracknameTPE1nameTALB albumnameTDRC2006TRCK01/02HXingA  ""%)),00577<_ѣ;DNwتĪ5giԷ}n{(n(D^AI11Jy{ui <|;w4fk}~NA&. >D!*Poцz4;B8lʮQVwygxjr{ʷ0g(, ((z8L $fFVmA&DbqAFI`M`d1LC#; ޥvvmHݻQL.IMJf*"g?=!uF9')bC%dH4<#:q98oEd:PvM]V>v~yVˏ뫿W[(aB: ,6if<7!"cH'-h|16(V$> BE˴SJ/PnjݩsxVfx]M|ӥ͔bq T-h  0ӹM݌M89\R#;ޖw5,:,,X(EklUu uZ;Tvҥt ̪fy(~8,;]:hY*{<2M)(4;8Rk/_P'C0Ԋ?^Jgu(f(YVy+$r5AoGN7\e8(28L7f^Y`H$ ,@dF04ER'zX֑f4ZiIzT彡1S {UVgz;[0qvʯgtDP"=esF֚G.Kd/9:FtPYْW5W}\83lӅT xa/';­gMmV(8L_捡'v.ހms@0` z&u9`+A1e Eq [ܷ -pĊY̓3JUEVuMVxw'(f8L8 H gqbWbVxZSY琔}rlpiq&PM˅@9X`BC rm~mXV^.g2->(r(dVh9ѵK%X駦I(I9X aF&E"PJR*Qd{%  > hVS*K,ql[hm?Fnt^cW]PfkSC³VxGu.ed|{%e4Ѻ$ *R /~:FTiQbKUOV`e`R':uAH5ҝ`x:( v8R i@T*:*t6 Jtkg#QY?23^L@_4H!Pq[:!١Q*I(h\dXM`BVxlN8. ;\e%[(r8RL C=v6ShG& !U+}6QTa `>f&`{BK nm|R\ v@WaVx/$ ֩*pZ$T FM^;(/Ar8 $fei%vgo~n)UlًnQ/wNU)9udGojާg?$W˟(4݃n\\ٷSkRY\DYDFLĮ&A29R8Lѫ@]A$UlQQ(t|ۄH5+`qqz|Rog( ^BL:^oOD-cs!vNMw(U*#bx O"hP6,5i"~L*GޤZ$yT-\ީA7*gy'Ո씼!l! ( 88 *pW gsA9j |6%bVYjT7W+Zu4ޚԎ3]֭UVxӓhwwJqSRk9eYHh9ٗzo7w <<"y>Z8 -ċ {±c+T9 j1e{q& eEFAEw)( ^P톏Adb"S"QT"*W!$!VPg(1}9Jp`j DjfHTu/f؏ `P1gJ@xQU Rgxʟ;6z{(!zBLls1:FN"2): a!=FJ;H "ei[4QI',, <Sg.co=qw8S¶MEFƨ{CMI|TKs˃}<*LК/hvK!V?!wGd (bIJJ0@u,y3Q8H|bwIYBY:8$P!gYBn^~} {ֵBjVwvU9P1RӢVy (Yb8L "/Tuµ܌@O<^A^!A`q-;J A5,@,asAаrw\tț|\TB$0`vr0ugx 6\zRCNUm!r" 7((q~RLP$p'HCFOFttig(겏Rh4<\S=,`H2XyީG)weloʁIr0Q3h,,IVz$nRU7 YEˁTTFnhCj(1z8(+ MJǔ 64 Tfļ+Hy'$"4znq/#TKrwp#.\V.|d'53%2= 1T8=bGHal-0N BQnPP$elf m__U=noz bŕVwziIKIf(fBLʌ6JN'ZԽ*ʓ O-;niJp|ZF `dr+y{AV":ׄf 6$Pz`b܄.pLVxxnLɖnt/TR(xҘ(ٺ8P 9LVա2mX֥*U8dCR:G HI ʁ[i}i`L}6bV{%sc,-P5"y7A"(.a:F`2aJAmN>@H[s,%"aAׁ1 Fs-s+Uqj\X&(K2 WWﮘ>][IFX0ӻ\!K痮[ڴtW^(ySA H%2qMaETԙe1L8+t>_@ Ejj=c^i&:zPȨ*&.%Wjm `B3-L7E`wsKbXX:O 8a 5#Cb H <A &+gG"0,R)')0`}2?bz?/(ՍpHe*VC:<ߍ/Ԩc3C.jLAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU*HUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUeHUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUTAGtrackname name albumname 2006 Mopidy-2.0.0/tests/data/scanner/advanced/subdir1/song4.mp30000644000175000017500000002222012441116637023456 0ustar jodaljodal00000000000000ID3vTIT2 tracknameTPE1nameTALB albumnameTDRC2006TRCK01/02HXingA  ""%)),00577<_ѣ;DNwتĪ5giԷ}n{(n(D^AI11Jy{ui <|;w4fk}~NA&. >D!*Poцz4;B8lʮQVwygxjr{ʷ0g(, ((z8L $fFVmA&DbqAFI`M`d1LC#; ޥvvmHݻQL.IMJf*"g?=!uF9')bC%dH4<#:q98oEd:PvM]V>v~yVˏ뫿W[(aB: ,6if<7!"cH'-h|16(V$> BE˴SJ/PnjݩsxVfx]M|ӥ͔bq T-h  0ӹM݌M89\R#;ޖw5,:,,X(EklUu uZ;Tvҥt ̪fy(~8,;]:hY*{<2M)(4;8Rk/_P'C0Ԋ?^Jgu(f(YVy+$r5AoGN7\e8(28L7f^Y`H$ ,@dF04ER'zX֑f4ZiIzT彡1S {UVgz;[0qvʯgtDP"=esF֚G.Kd/9:FtPYْW5W}\83lӅT xa/';­gMmV(8L_捡'v.ހms@0` z&u9`+A1e Eq [ܷ -pĊY̓3JUEVuMVxw'(f8L8 H gqbWbVxZSY琔}rlpiq&PM˅@9X`BC rm~mXV^.g2->(r(dVh9ѵK%X駦I(I9X aF&E"PJR*Qd{%  > hVS*K,ql[hm?Fnt^cW]PfkSC³VxGu.ed|{%e4Ѻ$ *R /~:FTiQbKUOV`e`R':uAH5ҝ`x:( v8R i@T*:*t6 Jtkg#QY?23^L@_4H!Pq[:!١Q*I(h\dXM`BVxlN8. ;\e%[(r8RL C=v6ShG& !U+}6QTa `>f&`{BK nm|R\ v@WaVx/$ ֩*pZ$T FM^;(/Ar8 $fei%vgo~n)UlًnQ/wNU)9udGojާg?$W˟(4݃n\\ٷSkRY\DYDFLĮ&A29R8Lѫ@]A$UlQQ(t|ۄH5+`qqz|Rog( ^BL:^oOD-cs!vNMw(U*#bx O"hP6,5i"~L*GޤZ$yT-\ީA7*gy'Ո씼!l! ( 88 *pW gsA9j |6%bVYjT7W+Zu4ޚԎ3]֭UVxӓhwwJqSRk9eYHh9ٗzo7w <<"y>Z8 -ċ {±c+T9 j1e{q& eEFAEw)( ^P톏Adb"S"QT"*W!$!VPg(1}9Jp`j DjfHTu/f؏ `P1gJ@xQU Rgxʟ;6z{(!zBLls1:FN"2): a!=FJ;H "ei[4QI',, <Sg.co=qw8S¶MEFƨ{CMI|TKs˃}<*LК/hvK!V?!wGd (bIJJ0@u,y3Q8H|bwIYBY:8$P!gYBn^~} {ֵBjVwvU9P1RӢVy (Yb8L "/Tuµ܌@O<^A^!A`q-;J A5,@,asAаrw\tț|\TB$0`vr0ugx 6\zRCNUm!r" 7((q~RLP$p'HCFOFttig(겏Rh4<\S=,`H2XyީG)weloʁIr0Q3h,,IVz$nRU7 YEˁTTFnhCj(1z8(+ MJǔ 64 Tfļ+Hy'$"4znq/#TKrwp#.\V.|d'53%2= 1T8=bGHal-0N BQnPP$elf m__U=noz bŕVwziIKIf(fBLʌ6JN'ZԽ*ʓ O-;niJp|ZF `dr+y{AV":ׄf 6$Pz`b܄.pLVxxnLɖnt/TR(xҘ(ٺ8P 9LVա2mX֥*U8dCR:G HI ʁ[i}i`L}6bV{%sc,-P5"y7A"(.a:F`2aJAmN>@H[s,%"aAׁ1 Fs-s+Uqj\X&(K2 WWﮘ>][IFX0ӻ\!K痮[ڴtW^(ySA H%2qMaETԙe1L8+t>_@ Ejj=c^i&:zPȨ*&.%Wjm `B3-L7E`wsKbXX:O 8a 5#Cb H <A &+gG"0,R)')0`}2?bz?/(ՍpHe*VC:<ߍ/Ԩc3C.jLAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU*HUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUeHUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUTAGtrackname name albumname 2006 Mopidy-2.0.0/tests/data/scanner/advanced/song2.mp30000644000175000017500000002222012441116637022103 0ustar jodaljodal00000000000000ID3vTIT2 tracknameTPE1nameTALB albumnameTDRC2006TRCK01/02HXingA  ""%)),00577<_ѣ;DNwتĪ5giԷ}n{(n(D^AI11Jy{ui <|;w4fk}~NA&. >D!*Poцz4;B8lʮQVwygxjr{ʷ0g(, ((z8L $fFVmA&DbqAFI`M`d1LC#; ޥvvmHݻQL.IMJf*"g?=!uF9')bC%dH4<#:q98oEd:PvM]V>v~yVˏ뫿W[(aB: ,6if<7!"cH'-h|16(V$> BE˴SJ/PnjݩsxVfx]M|ӥ͔bq T-h  0ӹM݌M89\R#;ޖw5,:,,X(EklUu uZ;Tvҥt ̪fy(~8,;]:hY*{<2M)(4;8Rk/_P'C0Ԋ?^Jgu(f(YVy+$r5AoGN7\e8(28L7f^Y`H$ ,@dF04ER'zX֑f4ZiIzT彡1S {UVgz;[0qvʯgtDP"=esF֚G.Kd/9:FtPYْW5W}\83lӅT xa/';­gMmV(8L_捡'v.ހms@0` z&u9`+A1e Eq [ܷ -pĊY̓3JUEVuMVxw'(f8L8 H gqbWbVxZSY琔}rlpiq&PM˅@9X`BC rm~mXV^.g2->(r(dVh9ѵK%X駦I(I9X aF&E"PJR*Qd{%  > hVS*K,ql[hm?Fnt^cW]PfkSC³VxGu.ed|{%e4Ѻ$ *R /~:FTiQbKUOV`e`R':uAH5ҝ`x:( v8R i@T*:*t6 Jtkg#QY?23^L@_4H!Pq[:!١Q*I(h\dXM`BVxlN8. ;\e%[(r8RL C=v6ShG& !U+}6QTa `>f&`{BK nm|R\ v@WaVx/$ ֩*pZ$T FM^;(/Ar8 $fei%vgo~n)UlًnQ/wNU)9udGojާg?$W˟(4݃n\\ٷSkRY\DYDFLĮ&A29R8Lѫ@]A$UlQQ(t|ۄH5+`qqz|Rog( ^BL:^oOD-cs!vNMw(U*#bx O"hP6,5i"~L*GޤZ$yT-\ީA7*gy'Ո씼!l! ( 88 *pW gsA9j |6%bVYjT7W+Zu4ޚԎ3]֭UVxӓhwwJqSRk9eYHh9ٗzo7w <<"y>Z8 -ċ {±c+T9 j1e{q& eEFAEw)( ^P톏Adb"S"QT"*W!$!VPg(1}9Jp`j DjfHTu/f؏ `P1gJ@xQU Rgxʟ;6z{(!zBLls1:FN"2): a!=FJ;H "ei[4QI',, <Sg.co=qw8S¶MEFƨ{CMI|TKs˃}<*LК/hvK!V?!wGd (bIJJ0@u,y3Q8H|bwIYBY:8$P!gYBn^~} {ֵBjVwvU9P1RӢVy (Yb8L "/Tuµ܌@O<^A^!A`q-;J A5,@,asAаrw\tț|\TB$0`vr0ugx 6\zRCNUm!r" 7((q~RLP$p'HCFOFttig(겏Rh4<\S=,`H2XyީG)weloʁIr0Q3h,,IVz$nRU7 YEˁTTFnhCj(1z8(+ MJǔ 64 Tfļ+Hy'$"4znq/#TKrwp#.\V.|d'53%2= 1T8=bGHal-0N BQnPP$elf m__U=noz bŕVwziIKIf(fBLʌ6JN'ZԽ*ʓ O-;niJp|ZF `dr+y{AV":ׄf 6$Pz`b܄.pLVxxnLɖnt/TR(xҘ(ٺ8P 9LVա2mX֥*U8dCR:G HI ʁ[i}i`L}6bV{%sc,-P5"y7A"(.a:F`2aJAmN>@H[s,%"aAׁ1 Fs-s+Uqj\X&(K2 WWﮘ>][IFX0ӻ\!K痮[ڴtW^(ySA H%2qMaETԙe1L8+t>_@ Ejj=c^i&:zPȨ*&.%Wjm `B3-L7E`wsKbXX:O 8a 5#Cb H <A &+gG"0,R)')0`}2?bz?/(ՍpHe*VC:<ߍ/Ԩc3C.jLAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU*HUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUeHUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUTAGtrackname name albumname 2006 Mopidy-2.0.0/tests/data/scanner/advanced/song1.mp30000644000175000017500000002222012441116637022102 0ustar jodaljodal00000000000000ID3vTIT2 tracknameTPE1nameTALB albumnameTDRC2006TRCK01/02HXingA  ""%)),00577<_ѣ;DNwتĪ5giԷ}n{(n(D^AI11Jy{ui <|;w4fk}~NA&. >D!*Poцz4;B8lʮQVwygxjr{ʷ0g(, ((z8L $fFVmA&DbqAFI`M`d1LC#; ޥvvmHݻQL.IMJf*"g?=!uF9')bC%dH4<#:q98oEd:PvM]V>v~yVˏ뫿W[(aB: ,6if<7!"cH'-h|16(V$> BE˴SJ/PnjݩsxVfx]M|ӥ͔bq T-h  0ӹM݌M89\R#;ޖw5,:,,X(EklUu uZ;Tvҥt ̪fy(~8,;]:hY*{<2M)(4;8Rk/_P'C0Ԋ?^Jgu(f(YVy+$r5AoGN7\e8(28L7f^Y`H$ ,@dF04ER'zX֑f4ZiIzT彡1S {UVgz;[0qvʯgtDP"=esF֚G.Kd/9:FtPYْW5W}\83lӅT xa/';­gMmV(8L_捡'v.ހms@0` z&u9`+A1e Eq [ܷ -pĊY̓3JUEVuMVxw'(f8L8 H gqbWbVxZSY琔}rlpiq&PM˅@9X`BC rm~mXV^.g2->(r(dVh9ѵK%X駦I(I9X aF&E"PJR*Qd{%  > hVS*K,ql[hm?Fnt^cW]PfkSC³VxGu.ed|{%e4Ѻ$ *R /~:FTiQbKUOV`e`R':uAH5ҝ`x:( v8R i@T*:*t6 Jtkg#QY?23^L@_4H!Pq[:!١Q*I(h\dXM`BVxlN8. ;\e%[(r8RL C=v6ShG& !U+}6QTa `>f&`{BK nm|R\ v@WaVx/$ ֩*pZ$T FM^;(/Ar8 $fei%vgo~n)UlًnQ/wNU)9udGojާg?$W˟(4݃n\\ٷSkRY\DYDFLĮ&A29R8Lѫ@]A$UlQQ(t|ۄH5+`qqz|Rog( ^BL:^oOD-cs!vNMw(U*#bx O"hP6,5i"~L*GޤZ$yT-\ީA7*gy'Ո씼!l! ( 88 *pW gsA9j |6%bVYjT7W+Zu4ޚԎ3]֭UVxӓhwwJqSRk9eYHh9ٗzo7w <<"y>Z8 -ċ {±c+T9 j1e{q& eEFAEw)( ^P톏Adb"S"QT"*W!$!VPg(1}9Jp`j DjfHTu/f؏ `P1gJ@xQU Rgxʟ;6z{(!zBLls1:FN"2): a!=FJ;H "ei[4QI',, <Sg.co=qw8S¶MEFƨ{CMI|TKs˃}<*LК/hvK!V?!wGd (bIJJ0@u,y3Q8H|bwIYBY:8$P!gYBn^~} {ֵBjVwvU9P1RӢVy (Yb8L "/Tuµ܌@O<^A^!A`q-;J A5,@,asAаrw\tț|\TB$0`vr0ugx 6\zRCNUm!r" 7((q~RLP$p'HCFOFttig(겏Rh4<\S=,`H2XyީG)weloʁIr0Q3h,,IVz$nRU7 YEˁTTFnhCj(1z8(+ MJǔ 64 Tfļ+Hy'$"4znq/#TKrwp#.\V.|d'53%2= 1T8=bGHal-0N BQnPP$elf m__U=noz bŕVwziIKIf(fBLʌ6JN'ZԽ*ʓ O-;niJp|ZF `dr+y{AV":ׄf 6$Pz`b܄.pLVxxnLɖnt/TR(xҘ(ٺ8P 9LVա2mX֥*U8dCR:G HI ʁ[i}i`L}6bV{%sc,-P5"y7A"(.a:F`2aJAmN>@H[s,%"aAׁ1 Fs-s+Uqj\X&(K2 WWﮘ>][IFX0ӻ\!K痮[ڴtW^(ySA H%2qMaETԙe1L8+t>_@ Ejj=c^i&:zPȨ*&.%Wjm `B3-L7E`wsKbXX:O 8a 5#Cb H <A &+gG"0,R)')0`}2?bz?/(ՍpHe*VC:<ߍ/Ԩc3C.jLAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU*HUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUeHUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUTAGtrackname name albumname 2006 Mopidy-2.0.0/tests/data/scanner/empty/0000775000175000017500000000000012660436443020032 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/data/scanner/empty/.gitignore0000644000175000017500000000000012441116637022003 0ustar jodaljodal00000000000000Mopidy-2.0.0/tests/data/scanner/playlist.m3u0000664000175000017500000000002412575504731021160 0ustar jodaljodal00000000000000http://example.com/ Mopidy-2.0.0/tests/data/song2.mp30000644000175000017500000002222012441116637016705 0ustar jodaljodal00000000000000ID3vTIT2titleTPE1artistTALBalbumTDRC2010TRCK01/02HXingA  ""%)),00577<_ѣ;DNwتĪ5giԷ}n{(n(D^AI11Jy{ui <|;w4fk}~NA&. >D!*Poцz4;B8lʮQVwygxjr{ʷ0g(, ((z8L $fFVmA&DbqAFI`M`d1LC#; ޥvvmHݻQL.IMJf*"g?=!uF9')bC%dH4<#:q98oEd:PvM]V>v~yVˏ뫿W[(aB: ,6if<7!"cH'-h|16(V$> BE˴SJ/PnjݩsxVfx]M|ӥ͔bq T-h  0ӹM݌M89\R#;ޖw5,:,,X(EklUu uZ;Tvҥt ̪fy(~8,;]:hY*{<2M)(4;8Rk/_P'C0Ԋ?^Jgu(f(YVy+$r5AoGN7\e8(28L7f^Y`H$ ,@dF04ER'zX֑f4ZiIzT彡1S {UVgz;[0qvʯgtDP"=esF֚G.Kd/9:FtPYْW5W}\83lӅT xa/';­gMmV(8L_捡'v.ހms@0` z&u9`+A1e Eq [ܷ -pĊY̓3JUEVuMVxw'(f8L8 H gqbWbVxZSY琔}rlpiq&PM˅@9X`BC rm~mXV^.g2->(r(dVh9ѵK%X駦I(I9X aF&E"PJR*Qd{%  > hVS*K,ql[hm?Fnt^cW]PfkSC³VxGu.ed|{%e4Ѻ$ *R /~:FTiQbKUOV`e`R':uAH5ҝ`x:( v8R i@T*:*t6 Jtkg#QY?23^L@_4H!Pq[:!١Q*I(h\dXM`BVxlN8. ;\e%[(r8RL C=v6ShG& !U+}6QTa `>f&`{BK nm|R\ v@WaVx/$ ֩*pZ$T FM^;(/Ar8 $fei%vgo~n)UlًnQ/wNU)9udGojާg?$W˟(4݃n\\ٷSkRY\DYDFLĮ&A29R8Lѫ@]A$UlQQ(t|ۄH5+`qqz|Rog( ^BL:^oOD-cs!vNMw(U*#bx O"hP6,5i"~L*GޤZ$yT-\ީA7*gy'Ո씼!l! ( 88 *pW gsA9j |6%bVYjT7W+Zu4ޚԎ3]֭UVxӓhwwJqSRk9eYHh9ٗzo7w <<"y>Z8 -ċ {±c+T9 j1e{q& eEFAEw)( ^P톏Adb"S"QT"*W!$!VPg(1}9Jp`j DjfHTu/f؏ `P1gJ@xQU Rgxʟ;6z{(!zBLls1:FN"2): a!=FJ;H "ei[4QI',, <Sg.co=qw8S¶MEFƨ{CMI|TKs˃}<*LК/hvK!V?!wGd (bIJJ0@u,y3Q8H|bwIYBY:8$P!gYBn^~} {ֵBjVwvU9P1RӢVy (Yb8L "/Tuµ܌@O<^A^!A`q-;J A5,@,asAаrw\tț|\TB$0`vr0ugx 6\zRCNUm!r" 7((q~RLP$p'HCFOFttig(겏Rh4<\S=,`H2XyީG)weloʁIr0Q3h,,IVz$nRU7 YEˁTTFnhCj(1z8(+ MJǔ 64 Tfļ+Hy'$"4znq/#TKrwp#.\V.|d'53%2= 1T8=bGHal-0N BQnPP$elf m__U=noz bŕVwziIKIf(fBLʌ6JN'ZԽ*ʓ O-;niJp|ZF `dr+y{AV":ׄf 6$Pz`b܄.pLVxxnLɖnt/TR(xҘ(ٺ8P 9LVա2mX֥*U8dCR:G HI ʁ[i}i`L}6bV{%sc,-P5"y7A"(.a:F`2aJAmN>@H[s,%"aAׁ1 Fs-s+Uqj\X&(K2 WWﮘ>][IFX0ӻ\!K痮[ڴtW^(ySA H%2qMaETԙe1L8+t>_@ Ejj=c^i&:zPȨ*&.%Wjm `B3-L7E`wsKbXX:O 8a 5#Cb H <A &+gG"0,R)')0`}2?bz?/(ՍpHe*VC:<ߍ/Ԩc3C.jLAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU*HUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUeHUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUTAGtitle artist album 2010 Mopidy-2.0.0/tests/data/song2.flac0000644000175000017500000003454312441116637017126 0ustar jodaljodal00000000000000fLaC"pl&>r3( reference libFLAC 1.2.1 20070917 ?~=ӻ/w?o~gfzs>g9gysݹϿ33]9?^ztϥ.ɜ>|3f۝~wMMs{^?gsY󛞶M>}IO/|Od&{399f7_>o$/?ϖ3˙M=̳>O'$s'Zug{/ϧ%w?ξ{z{\}9w2w?7<~y˯?ys?';s>u}׿Ϲss+93?+l9=ɧo>N\|UL?r_s_m_wy6???_tܷouzs&tͺynow2r5s濓漥_yNi=3j/9^<=?|g>yϒ-'Nߙ{_>~?O5|_/>o3f_s?s<\_ӟgs;?S﹙?'{s?:'3yO3<ϻ>Y߽vgY<Z~?ӿ~lܛOw9L$_Ogrrϛ^S^͟$?>O}/w|3OL?=N>K|ys?'og~y~fݟ?7+Lӿ9}OgO%w_}ɓgy}'?9>_g|*|?Iw>jsrs&W'g۾s_[<-Oגs<<9)|3em>[>Oϓ%45gϷ7Lw~O'.:>3wS9yW?s]?o?}62Lv˧yyfRK'|3͟sʗ6?O7߳}ygy=/?e?i>[}fWylOOzM}穻9jgwv99tLJwy~:~~||[yoӓ???k>ӼM_w?o&sy'ݖ3Ͽd?~n&/w=>zL/ϝOۓ_;}~y}'?9?e|;ߟ9Y?'2rwwI}Ot%g3yg?}?^k'}6gI[sK\g3?uK?d]~~'߿fwf缟WsϟOryfӜ7\gɧ?|Ͽ?7-ϙ94i|:}湙|~5^}͛[sOoo-O=r|u?OI$ig$;{&w'9~ue?9>W|w3wyL\'MwϤgs.}yy3d?}?mg'?ڒVz?%y[K6O)9g<>y_~w-3ww'lf2e{_?{OϗvOv~|~7y$ϟӟ秜wyw|}7<}d۵?L~g=K'3O7w=߹|es<}w|wsOV~o5t'?r+eYvO92~y~Y7e|9=f}y̟7<{|yo}{^f<ݞso9r65?NNo'4mM{eIMs[?_Sg$e]^o9{z~Il|?>߿y|<92O\~I;>~|3濺~3}yJ~dLgyܿ<_߼oZ~w?_<>gʛo~[O+gܿ}?oRdߓ3yNg}Y䟟_|s&%O7n}ϙ?_2noY>?ܙ^Լ?rm矹~o3/ng:O>}|~stw79'4gɛ{9O3w6[s3RuOgww?rJYly?i3_lf?˼ϓ?ݜ_w5~_ϟ?vw?;s3|vwϛy?Ov97loϓ俞yO̜9O%7d˟>?-Ϸ}>O??ܟgng?fϳ}3r_s~}^j~of&{s읝/gIΟ;?&N~=~{r~Ϝ)v?RsM?/ts|d>MoϜ?}Ҧwi4N2翙3?ϳΖyolg:'O˖|?_=?7ϙ*~ɓgϞo$y3do&{}3{Ϫ\<{Ϝ?OS9۟>w?/ygwy3>ϼs̗}?|?O|Ӿly{8ssy-rN\Y;黺e|Κyym|Ԟg;/??'oMoLsZgK~yO矿g^~>\s^g?y~]K?m?fnsyϞIL?i-y˿3?ϳ%fy~=zr|{;K糥o3J{w_~Wh]t jUVJUUWJTꪺꪪJRJ]TUU.U}+*R*UTtWJUIUUtvjUUI|U_UUUU*RU+UWTUUU*T˥UJ.UJUjRꫪꪫꪕꮥU*UUUګ_UUuTRꪩuUiUUKU*UU*UJUTUjRJ]]}TUJWJUUUUUUJU_TTWU%UUUUU]JZUU*]UZJU*ժzTRJZIUIJ)tUUuW)JJUZK%UwuU)uJRUjUWWJ*RTWTUUUJU]}uJJJU*UUUU*IwUW_UU֥RU]U]UUWTU]]UUU*.R.wU]UZ]UU*UUW]RTJ_UUUԹUrJJ*ꪤW}%UJJUjUuURUUU*UUW]RU*ꪥW}TU*J]UҪUUuU]WU]TԾUVIuUKTRKKWRWUWUKꪥ)WZuIWuuUUUuUUIUZU]TU)WuRꪪWU]vUK]*PMopidy-2.0.0/tests/data/one.m3u0000644000175000017500000000001212441116637016436 0ustar jodaljodal00000000000000song1.mp3 Mopidy-2.0.0/tests/data/file2.conf0000644000175000017500000000002112441116637017077 0ustar jodaljodal00000000000000[foo2] bar = baz Mopidy-2.0.0/tests/data/song1.wav0000644000175000017500000010473412441116637017015 0ustar jodaljodal00000000000000RIFFԉWAVEfmt @@dataMopidy-2.0.0/tests/data/two-ext.m3u0000664000175000017500000000010212660436420017264 0ustar jodaljodal00000000000000#EXTM3U #EXTINF:-1,Song #1 song1.mp3 #EXTINF:60,Song #2 song2.mp3 Mopidy-2.0.0/tests/data/blank.mp30000644000175000017500000002222012441116637016744 0ustar jodaljodal00000000000000ID3vTIT2titleTPE1artistTALBalbumTDRC2010TRCK01/02HXingA  ""%)),00577<_ѣ;DNwتĪ5giԷ}n{(n(D^AI11Jy{ui <|;w4fk}~NA&. >D!*Poцz4;B8lʮQVwygxjr{ʷ0g(, ((z8L $fFVmA&DbqAFI`M`d1LC#; ޥvvmHݻQL.IMJf*"g?=!uF9')bC%dH4<#:q98oEd:PvM]V>v~yVˏ뫿W[(aB: ,6if<7!"cH'-h|16(V$> BE˴SJ/PnjݩsxVfx]M|ӥ͔bq T-h  0ӹM݌M89\R#;ޖw5,:,,X(EklUu uZ;Tvҥt ̪fy(~8,;]:hY*{<2M)(4;8Rk/_P'C0Ԋ?^Jgu(f(YVy+$r5AoGN7\e8(28L7f^Y`H$ ,@dF04ER'zX֑f4ZiIzT彡1S {UVgz;[0qvʯgtDP"=esF֚G.Kd/9:FtPYْW5W}\83lӅT xa/';­gMmV(8L_捡'v.ހms@0` z&u9`+A1e Eq [ܷ -pĊY̓3JUEVuMVxw'(f8L8 H gqbWbVxZSY琔}rlpiq&PM˅@9X`BC rm~mXV^.g2->(r(dVh9ѵK%X駦I(I9X aF&E"PJR*Qd{%  > hVS*K,ql[hm?Fnt^cW]PfkSC³VxGu.ed|{%e4Ѻ$ *R /~:FTiQbKUOV`e`R':uAH5ҝ`x:( v8R i@T*:*t6 Jtkg#QY?23^L@_4H!Pq[:!١Q*I(h\dXM`BVxlN8. ;\e%[(r8RL C=v6ShG& !U+}6QTa `>f&`{BK nm|R\ v@WaVx/$ ֩*pZ$T FM^;(/Ar8 $fei%vgo~n)UlًnQ/wNU)9udGojާg?$W˟(4݃n\\ٷSkRY\DYDFLĮ&A29R8Lѫ@]A$UlQQ(t|ۄH5+`qqz|Rog( ^BL:^oOD-cs!vNMw(U*#bx O"hP6,5i"~L*GޤZ$yT-\ީA7*gy'Ո씼!l! ( 88 *pW gsA9j |6%bVYjT7W+Zu4ޚԎ3]֭UVxӓhwwJqSRk9eYHh9ٗzo7w <<"y>Z8 -ċ {±c+T9 j1e{q& eEFAEw)( ^P톏Adb"S"QT"*W!$!VPg(1}9Jp`j DjfHTu/f؏ `P1gJ@xQU Rgxʟ;6z{(!zBLls1:FN"2): a!=FJ;H "ei[4QI',, <Sg.co=qw8S¶MEFƨ{CMI|TKs˃}<*LК/hvK!V?!wGd (bIJJ0@u,y3Q8H|bwIYBY:8$P!gYBn^~} {ֵBjVwvU9P1RӢVy (Yb8L "/Tuµ܌@O<^A^!A`q-;J A5,@,asAаrw\tț|\TB$0`vr0ugx 6\zRCNUm!r" 7((q~RLP$p'HCFOFttig(겏Rh4<\S=,`H2XyީG)weloʁIr0Q3h,,IVz$nRU7 YEˁTTFnhCj(1z8(+ MJǔ 64 Tfļ+Hy'$"4znq/#TKrwp#.\V.|d'53%2= 1T8=bGHal-0N BQnPP$elf m__U=noz bŕVwziIKIf(fBLʌ6JN'ZԽ*ʓ O-;niJp|ZF `dr+y{AV":ׄf 6$Pz`b܄.pLVxxnLɖnt/TR(xҘ(ٺ8P 9LVա2mX֥*U8dCR:G HI ʁ[i}i`L}6bV{%sc,-P5"y7A"(.a:F`2aJAmN>@H[s,%"aAׁ1 Fs-s+Uqj\X&(K2 WWﮘ>][IFX0ӻ\!K痮[ڴtW^(ySA H%2qMaETԙe1L8+t>_@ Ejj=c^i&:zPȨ*&.%Wjm `B3-L7E`wsKbXX:O 8a 5#Cb H <A &+gG"0,R)')0`}2?bz?/(ՍpHe*VC:<ߍ/Ԩc3C.jLAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU*HUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUeHUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUTAGtitle artist album 2010 Mopidy-2.0.0/tests/data/empty.m3u0000644000175000017500000000000012441116637017010 0ustar jodaljodal00000000000000Mopidy-2.0.0/tests/data/song3.ogg0000644000175000017500000002073712441116637016776 0ustar jodaljodal00000000000000OggSb~|vorbis@@OggSb~X -vorbisXiph.Org libVorbis I 20090709vorbisBCV R!%SJcRR)cP[Gc9F!dSI{O*XJRX)ESLSIR)EcSH!S1esKI %lMtKc1FcZJc1EcRRIs:f%d:Fb|0:B(R-[S-KiasJjc1S(АU@BCV P EQАU@EqqG$BCV@((#IdYeYy/.FuL*CCc3C LcN4 23Ő2[,.!+(b 9dR"瘔NJQ(K[1Q(eBŌRT@@PhȊ 0)B)s1 1 d)NJ圓Ic1sNJrIɤ`!"0HY&gꉢZgigj뚪ʖ癦gꙦ꺦l.jۮkl+ʺʶKgnkۚjʮm-l,fl-,ڶ*˺/n,lںk,*˾1۶˺.'뙪몮k۪ںl-+۪,+˶,+ۦʲ*˾ʲn,*1̶*˺ʲn nʲ ˬۺ1﫲-, 2>ct]_WmYV}cuaYm[][gn nʭ ˲ڶ̺,.|[ڶ麺nʲ˺.uWF}ն}_e߷_ið,k/뺰/,m+0ۺܾ, ˪۾ҵue}+ p0 "@r)b BB*cR2dI)JIbLJ朔1)J)RZ*Ji-bJPJkJIbL1&%sNJ朔Rk%2(eJ J*b朤:+J*1b VJjc+1Z!KJZm1Z5bLJ朔9*%J*eI 9(b*)9I2ȨZ+Rc)b)CI-Z,bTS'ŒR%[j VJ[k1cK+ZlZ5TcJc1k=bM1ZՖ[̵NJkKJ1b1Ji[))Z\C)Z,bVcj[ZkV[.b=kXSm 8P Y D0F)ǜ(sR* RRʜPJKsJI)RjRRj lДXА@*q4MUu}_,QTUוmW,MUUvm[5QTU׵m~MUUveٶm۲n èk۲m먮ۺۺ/T]Ym[u׶uu]mn# G!tBOp*auBCV1J3H1cL1Ƙ@!+(9s9s9s9s1c1c1c1c1c1cLNNPhJ @!))RJSAI)RJJ)RuRJ)"RJ)II)RJ):J)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)TJ)RJ)RJ)`` +IgrBiTRJ%UA(%JJ)RJ*RJ)A(PJ)%RJ(%J TB+RJB)TJ)%A(!BIBHtTR)!RJ)%:RK-RZJtR)RjR VJI%ZI%JI%RK)TRI%R*%ZJRHRJRJ%JJRj%Rj!JI)J)TBIRZJ-JITRIJI)RJK)JJZI)RJ)RK%TJ %RIRK)R@FTZf\y(d@@ 0@P0 A0GќBCOggS]b~u^+)-+**+.+-+,+,+0/,/,*0/-**,-.,+/+**/)/++--+-,.+**...-/*-+'-*+,.0+,++/)./-..+//0(((-+**,,,++*+*":5Ct+:1V3IꙨ1o2 #a #vNYVIK.iܳd)Ԝ֦ $Z=l;_ gC[#%[Rus=*=zscP)n(7|pA.b՟){*Xڠc].##am6[J>t+lMn/{6p$' si);rqF#介Iy `$uB+<%w{C7"aQ] G\]l$YT͆a޻V_=ݮdc7j!-ٶʨmf4^0cdaLRIi];#(tx+年WZ9ziVIY'hkV=S:#YKZMziȇ:kƠXR}P |48Mz#Y$MJeϬ zQxTlHxR:c!V6?[#ꗪC3Ĉ+wۜw$@8K5ł,>'i`CQWy cNT:P#Y$e>Gڨ]񶘥/'qы]v2ó|nk$\R=4F}Zs}0xR/}3bOBt8ͶGr#EZsc Sj?w'bae ѭotYRFsu7#IAn ,PW,VxiiiȼʦJDV+.%@2gQi[[[s7)a*U:?<~$Im_.*=l+ i^22WJSqp $\jX6a{xYc;sž$c)GrpW%!Zo'¯e.+eʳL&K x$ZgNE 1N9m=< HT\FS\0ǝ$qZ/`j'ip6-qprqأ$)smTuflcL9]0O3 k7b@H|i.*i*w%|$!VzǻT \eUg.l}c%dfoST"m]ugG%4%tc)zL3dcIY;܋]^BXV>l+\\H!Z(nuZ{4{$qV)/>P3iMWKE ý|ɐO5#T[NY=.]MuOul'5Vq=Ԏtu#a SX<.n'm)?*gl~u]"VV/X # 9UޏÞA.u'__#N%ߊi#%vE?N TvI\xZˏ~+6sj# kuzU"#fS=.DCc.a$a [uk0۹GnAon;e-qqD$T3>1>TnctgLnFٵYUƢ&$!,\w=rwTOj|D+fn9St#Y$+ !@\(k"G@=dH71$ɩ簦. wY}3 ՘dYN9cRt[V6k#qm?1MW&fްn6Ja$m4(L$aZ̒6) ^b*1fةhiw^kgS$!R4/o])͗&ޫd]6g蓕 aݻczmdĹnP;RE U+;#,ak2Ek 0W˳CQr8-ۻBZaEQ5$:s5]zv]#GFI$mЍCrpYqʙ$!V;x\@b1km6Α|k}AqZ*e5$!V=T]+M᱙(rC2zߨ$h԰Vvbj U׊\&]Jƹ $ܨZ`θW(^m)kkg]믙A}e/<[$dM  ܱww BWMD[=ZPq$匩絃|F~]U&(0ڍ1H~KS>RZoVJP$(a,xlɈ˽V !n։ 8?9=vBF$bSSOs#wZ)Q7=np=()z/TKs1qX#YTVy9 թPZjO %+-nYFZe$\¦V0]Iw83oז8bdvb#!SsT}wB}}1K~_S8SB$Hlz'Tz.JzH9>S$֭y:8$h$]XyLQ+@MX8{  qx9G0j~ $!fTMz%_iVm~[$2#!º0+|15'');ni艘uڛ#YTd:w1~w< N|'EgÛZ`iz;,n'#qZOrE^Ҡ2BwiW8 ɴ 9 c!uZsϒGvmJ:CM8G%#YTxh޼M[ b ri.{%H8w rcc4c[Qwa5MSwwRj:82T OggSb~r-++31/**),00.-.).,+,/--/,2)*.+,,-11,*+.1-*.)+)$aRJ+JmТəUyKƝy X]ӹ@}"Y$yvfFm.qh-83$:!t׽2կj2f;Q[[R㜅%([7$!j@oAҿ4E3sj3J)#iJzdqv2(lrI41af IknKޝ^+ R4Y3aDp#\Z bzJ )HxeABn* ة]!$%MBbeoLD1g{0fc#aNOY1obO% fgٹo[Sm!g1c;{ -$(pX_gȥ Q3 3IQV:`0u$!֓2ǭ䧕 s͡mݝ&;N$a3wTxJש 1,rr~adjθ#Tmsp63=:V=N5-ѝOis'Y6N$Y$òoצxRBv{$ 77{+d?'c4X Ja b#vm^ס'&UU^;OVǶ Scv]~Mg^Smd;W:WFɂՕUR$I&54UЈTI]YK,e_o Qr #!ZnN[S#nDs kkjkBsv@$YTSvy;TJdxzwZW/Dqײz٫P?#aZfY ~zXfP{3dhd{I^#qZ#B|ehhD{*ZwJqŔr$a6gqb\߶6~j歝r[c2΍w#ٮZo8y~L3=NUf*z7v Hn$TC;~~ dUrs8wq̹$!T4Yq}LB+ڥ9_b.ffe#T"i:z|`^-N[&D]1wfu"8˚4$IE:O-嵫~rƞPJ$hDfL&>$(unU_[ŔiZֳsdIoXyA GKh'[!i=$ɩZJVVA łN9SI&؜M3Y({q)A$ٮZEtt,N9Jϳ=Skjw4M"N* ca6OYˮ3C3N3jbB6tΪC$ai͜@ևxD~/oAԅλ->]tu.&؛"$=<8v^ަi[2Z\Xt⸇Į?$YT'C\.\8i36rS24)Y[]f1%j ~jLؗ[VjUY" 2jKC7>:00$\6mr3( reference libFLAC 1.2.1 20070917 ?~=ӻ/w?o~gfzs>g9gysݹϿ33]9?^ztϥ.ɜ>|3f۝~wMMs{^?gsY󛞶M>}IO/|Od&{399f7_>o$/?ϖ3˙M=̳>O'$s'Zug{/ϧ%w?ξ{z{\}9w2w?7<~y˯?ys?';s>u}׿Ϲss+93?+l9=ɧo>N\|UL?r_s_m_wy6???_tܷouzs&tͺynow2r5s濓漥_yNi=3j/9^<=?|g>yϒ-'Nߙ{_>~?O5|_/>o3f_s?s<\_ӟgs;?S﹙?'{s?:'3yO3<ϻ>Y߽vgY<Z~?ӿ~lܛOw9L$_Ogrrϛ^S^͟$?>O}/w|3OL?=N>K|ys?'og~y~fݟ?7+Lӿ9}OgO%w_}ɓgy}'?9>_g|*|?Iw>jsrs&W'g۾s_[<-Oגs<<9)|3em>[>Oϓ%45gϷ7Lw~O'.:>3wS9yW?s]?o?}62Lv˧yyfRK'|3͟sʗ6?O7߳}ygy=/?e?i>[}fWylOOzM}穻9jgwv99tLJwy~:~~||[yoӓ???k>ӼM_w?o&sy'ݖ3Ͽd?~n&/w=>zL/ϝOۓ_;}~y}'?9?e|;ߟ9Y?'2rwwI}Ot%g3yg?}?^k'}6gI[sK\g3?uK?d]~~'߿fwf缟WsϟOryfӜ7\gɧ?|Ͽ?7-ϙ94i|:}湙|~5^}͛[sOoo-O=r|u?OI$ig$;{&w'9~ue?9>W|w3wyL\'MwϤgs.}yy3d?}?mg'?ڒVz?%y[K6O)9g<>y_~w-3ww'lf2e{_?{OϗvOv~|~7y$ϟӟ秜wyw|}7<}d۵?L~g=K'3O7w=߹|es<}w|wsOV~o5t'?r+eYvO92~y~Y7e|9=f}y̟7<{|yo}{^f<ݞso9r65?NNo'4mM{eIMs[?_Sg$e]^o9{z~Il|?>߿y|<92O\~I;>~|3濺~3}yJ~dLgyܿ<_߼oZ~w?_<>gʛo~[O+gܿ}?oRdߓ3yNg}Y䟟_|s&%O7n}ϙ?_2noY>?ܙ^Լ?rm矹~o3/ng:O>}|~stw79'4gɛ{9O3w6[s3RuOgww?rJYly?i3_lf?˼ϓ?ݜ_w5~_ϟ?vw?;s3|vwϛy?Ov97loϓ俞yO̜9O%7d˟>?-Ϸ}>O??ܟgng?fϳ}3r_s~}^j~of&{s읝/gIΟ;?&N~=~{r~Ϝ)v?RsM?/ts|d>MoϜ?}Ҧwi4N2翙3?ϳΖyolg:'O˖|?_=?7ϙ*~ɓgϞo$y3do&{}3{Ϫ\<{Ϝ?OS9۟>w?/ygwy3>ϼs̗}?|?O|Ӿly{8ssy-rN\Y;黺e|Κyym|Ԟg;/??'oMoLsZgK~yO矿g^~>\s^g?y~]K?m?fnsyϞIL?i-y˿3?ϳ%fy~=zr|{;K糥o3J{w_~Wh]t jUVJUUWJTꪺꪪJRJ]TUU.U}+*R*UTtWJUIUUtvjUUI|U_UUUU*RU+UWTUUU*T˥UJ.UJUjRꫪꪫꪕꮥU*UUUګ_UUuTRꪩuUiUUKU*UU*UJUTUjRJ]]}TUJWJUUUUUUJU_TTWU%UUUUU]JZUU*]UZJU*ժzTRJZIUIJ)tUUuW)JJUZK%UwuU)uJRUjUWWJ*RTWTUUUJU]}uJJJU*UUUU*IwUW_UU֥RU]U]UUWTU]]UUU*.R.wU]UZ]UU*UUW]RTJ_UUUԹUrJJ*ꪤW}%UJJUjUuURUUU*UUW]RU*ꪥW}TU*J]UҪUUuU]WU]TԾUVIuUKTRKKWRWUWUKꪥ)WZuIWuuUUUuUUIUZU]TU)WuRꪪWU]vUK]*PMopidy-2.0.0/tests/data/song3.flac0000644000175000017500000003454312441116637017127 0ustar jodaljodal00000000000000fLaC"pl&>r3( reference libFLAC 1.2.1 20070917 ?~=ӻ/w?o~gfzs>g9gysݹϿ33]9?^ztϥ.ɜ>|3f۝~wMMs{^?gsY󛞶M>}IO/|Od&{399f7_>o$/?ϖ3˙M=̳>O'$s'Zug{/ϧ%w?ξ{z{\}9w2w?7<~y˯?ys?';s>u}׿Ϲss+93?+l9=ɧo>N\|UL?r_s_m_wy6???_tܷouzs&tͺynow2r5s濓漥_yNi=3j/9^<=?|g>yϒ-'Nߙ{_>~?O5|_/>o3f_s?s<\_ӟgs;?S﹙?'{s?:'3yO3<ϻ>Y߽vgY<Z~?ӿ~lܛOw9L$_Ogrrϛ^S^͟$?>O}/w|3OL?=N>K|ys?'og~y~fݟ?7+Lӿ9}OgO%w_}ɓgy}'?9>_g|*|?Iw>jsrs&W'g۾s_[<-Oגs<<9)|3em>[>Oϓ%45gϷ7Lw~O'.:>3wS9yW?s]?o?}62Lv˧yyfRK'|3͟sʗ6?O7߳}ygy=/?e?i>[}fWylOOzM}穻9jgwv99tLJwy~:~~||[yoӓ???k>ӼM_w?o&sy'ݖ3Ͽd?~n&/w=>zL/ϝOۓ_;}~y}'?9?e|;ߟ9Y?'2rwwI}Ot%g3yg?}?^k'}6gI[sK\g3?uK?d]~~'߿fwf缟WsϟOryfӜ7\gɧ?|Ͽ?7-ϙ94i|:}湙|~5^}͛[sOoo-O=r|u?OI$ig$;{&w'9~ue?9>W|w3wyL\'MwϤgs.}yy3d?}?mg'?ڒVz?%y[K6O)9g<>y_~w-3ww'lf2e{_?{OϗvOv~|~7y$ϟӟ秜wyw|}7<}d۵?L~g=K'3O7w=߹|es<}w|wsOV~o5t'?r+eYvO92~y~Y7e|9=f}y̟7<{|yo}{^f<ݞso9r65?NNo'4mM{eIMs[?_Sg$e]^o9{z~Il|?>߿y|<92O\~I;>~|3濺~3}yJ~dLgyܿ<_߼oZ~w?_<>gʛo~[O+gܿ}?oRdߓ3yNg}Y䟟_|s&%O7n}ϙ?_2noY>?ܙ^Լ?rm矹~o3/ng:O>}|~stw79'4gɛ{9O3w6[s3RuOgww?rJYly?i3_lf?˼ϓ?ݜ_w5~_ϟ?vw?;s3|vwϛy?Ov97loϓ俞yO̜9O%7d˟>?-Ϸ}>O??ܟgng?fϳ}3r_s~}^j~of&{s읝/gIΟ;?&N~=~{r~Ϝ)v?RsM?/ts|d>MoϜ?}Ҧwi4N2翙3?ϳΖyolg:'O˖|?_=?7ϙ*~ɓgϞo$y3do&{}3{Ϫ\<{Ϝ?OS9۟>w?/ygwy3>ϼs̗}?|?O|Ӿly{8ssy-rN\Y;黺e|Κyym|Ԟg;/??'oMoLsZgK~yO矿g^~>\s^g?y~]K?m?fnsyϞIL?i-y˿3?ϳ%fy~=zr|{;K糥o3J{w_~Wh]t jUVJUUWJTꪺꪪJRJ]TUU.U}+*R*UTtWJUIUUtvjUUI|U_UUUU*RU+UWTUUU*T˥UJ.UJUjRꫪꪫꪕꮥU*UUUګ_UUuTRꪩuUiUUKU*UU*UJUTUjRJ]]}TUJWJUUUUUUJU_TTWU%UUUUU]JZUU*]UZJU*ժzTRJZIUIJ)tUUuW)JJUZK%UwuU)uJRUjUWWJ*RTWTUUUJU]}uJJJU*UUUU*IwUW_UU֥RU]U]UUWTU]]UUU*.R.wU]UZ]UU*UUW]RTJ_UUUԹUrJJ*ꪤW}%UJJUjUuURUUU*UUW]RU*ꪥW}TU*J]UҪUUuU]WU]TԾUVIuUKTRKKWRWUWUKꪥ)WZuIWuuUUUuUUIUZU]TU)WuRꪪWU]vUK]*PMopidy-2.0.0/tests/data/encoding.m3u0000644000175000017500000000001012441116637017441 0ustar jodaljodal00000000000000.mp3 Mopidy-2.0.0/tests/data/local/0000775000175000017500000000000012660436443016335 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/data/local/library.json.gz0000664000175000017500000000063312575504731021316 0ustar jodaljodal00000000000000رRlibrary.jsonŕn } Xnﰻ1LwRE?c;UzZG%2o>TF#eY%֬2#룜V ؉0B.8]_q;cP| ZʟM+i]7 hR\rOnQ2; jS3.;R'{{[SmS1B "O|qx;_<oC%d$YrYlr#ّo6UŸn MrM.rzwdGrGFx$xy$ct]_WmYV}cuaYm[][gn nʭ ˲ڶ̺,.|[ڶ麺nʲ˺.uWF}ն}_e߷_ið,k/뺰/,m+0ۺܾ, ˪۾ҵue}+ p0 "@r)b BB*cR2dI)JIbLJ朔1)J)RZ*Ji-bJPJkJIbL1&%sNJ朔Rk%2(eJ J*b朤:+J*1b VJjc+1Z!KJZm1Z5bLJ朔9*%J*eI 9(b*)9I2ȨZ+Rc)b)CI-Z,bTS'ŒR%[j VJ[k1cK+ZlZ5TcJc1k=bM1ZՖ[̵NJkKJ1b1Ji[))Z\C)Z,bVcj[ZkV[.b=kXSm 8P Y D0F)ǜ(sR* RRʜPJKsJI)RjRRj lДXА@*q4MUu}_,QTUוmW,MUUvm[5QTU׵m~MUUveٶm۲n èk۲m먮ۺۺ/T]Ym[u׶uu]mn# G!tBOp*auBCV1J3H1cL1Ƙ@!+(9s9s9s9s1c1c1c1c1c1cLNNPhJ @!))RJSAI)RJJ)RuRJ)"RJ)II)RJ):J)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)TJ)RJ)RJ)`` +IgrBiTRJ%UA(%JJ)RJ*RJ)A(PJ)%RJ(%J TB+RJB)TJ)%A(!BIBHtTR)!RJ)%:RK-RZJtR)RjR VJI%ZI%JI%RK)TRI%R*%ZJRHRJRJ%JJRj%Rj!JI)J)TBIRZJ-JITRIJI)RJK)JJZI)RJ)RK%TJ %RIRK)R@FTZf\y(d@@ 0@P0 A0GќBCOggS]b~u^+)-+**+.+-+,+,+0/,/,*0/-**,-.,+/+**/)/++--+-,.+**...-/*-+'-*+,.0+,++/)./-..+//0(((-+**,,,++*+*":5Ct+:1V3IꙨ1o2 #a #vNYVIK.iܳd)Ԝ֦ $Z=l;_ gC[#%[Rus=*=zscP)n(7|pA.b՟){*Xڠc].##am6[J>t+lMn/{6p$' si);rqF#介Iy `$uB+<%w{C7"aQ] G\]l$YT͆a޻V_=ݮdc7j!-ٶʨmf4^0cdaLRIi];#(tx+年WZ9ziVIY'hkV=S:#YKZMziȇ:kƠXR}P |48Mz#Y$MJeϬ zQxTlHxR:c!V6?[#ꗪC3Ĉ+wۜw$@8K5ł,>'i`CQWy cNT:P#Y$e>Gڨ]񶘥/'qы]v2ó|nk$\R=4F}Zs}0xR/}3bOBt8ͶGr#EZsc Sj?w'bae ѭotYRFsu7#IAn ,PW,VxiiiȼʦJDV+.%@2gQi[[[s7)a*U:?<~$Im_.*=l+ i^22WJSqp $\jX6a{xYc;sž$c)GrpW%!Zo'¯e.+eʳL&K x$ZgNE 1N9m=< HT\FS\0ǝ$qZ/`j'ip6-qprqأ$)smTuflcL9]0O3 k7b@H|i.*i*w%|$!VzǻT \eUg.l}c%dfoST"m]ugG%4%tc)zL3dcIY;܋]^BXV>l+\\H!Z(nuZ{4{$qV)/>P3iMWKE ý|ɐO5#T[NY=.]MuOul'5Vq=Ԏtu#a SX<.n'm)?*gl~u]"VV/X # 9UޏÞA.u'__#N%ߊi#%vE?N TvI\xZˏ~+6sj# kuzU"#fS=.DCc.a$a [uk0۹GnAon;e-qqD$T3>1>TnctgLnFٵYUƢ&$!,\w=rwTOj|D+fn9St#Y$+ !@\(k"G@=dH71$ɩ簦. wY}3 ՘dYN9cRt[V6k#qm?1MW&fްn6Ja$m4(L$aZ̒6) ^b*1fةhiw^kgS$!R4/o])͗&ޫd]6g蓕 aݻczmdĹnP;RE U+;#,ak2Ek 0W˳CQr8-ۻBZaEQ5$:s5]zv]#GFI$mЍCrpYqʙ$!V;x\@b1km6Α|k}AqZ*e5$!V=T]+M᱙(rC2zߨ$h԰Vvbj U׊\&]Jƹ $ܨZ`θW(^m)kkg]믙A}e/<[$dM  ܱww BWMD[=ZPq$匩絃|F~]U&(0ڍ1H~KS>RZoVJP$(a,xlɈ˽V !n։ 8?9=vBF$bSSOs#wZ)Q7=np=()z/TKs1qX#YTVy9 թPZjO %+-nYFZe$\¦V0]Iw83oז8bdvb#!SsT}wB}}1K~_S8SB$Hlz'Tz.JzH9>S$֭y:8$h$]XyLQ+@MX8{  qx9G0j~ $!fTMz%_iVm~[$2#!º0+|15'');ni艘uڛ#YTd:w1~w< N|'EgÛZ`iz;,n'#qZOrE^Ҡ2BwiW8 ɴ 9 c!uZsϒGvmJ:CM8G%#YTxh޼M[ b ri.{%H8w rcc4c[Qwa5MSwwRj:82T OggSb~r-++31/**),00.-.).,+,/--/,2)*.+,,-11,*+.1-*.)+)$aRJ+JmТəUyKƝy X]ӹ@}"Y$yvfFm.qh-83$:!t׽2կj2f;Q[[R㜅%([7$!j@oAҿ4E3sj3J)#iJzdqv2(lrI41af IknKޝ^+ R4Y3aDp#\Z bzJ )HxeABn* ة]!$%MBbeoLD1g{0fc#aNOY1obO% fgٹo[Sm!g1c;{ -$(pX_gȥ Q3 3IQV:`0u$!֓2ǭ䧕 s͡mݝ&;N$a3wTxJש 1,rr~adjθ#Tmsp63=:V=N5-ѝOis'Y6N$Y$òoצxRBv{$ 77{+d?'c4X Ja b#vm^ס'&UU^;OVǶ Scv]~Mg^Smd;W:WFɂՕUR$I&54UЈTI]YK,e_o Qr #!ZnN[S#nDs kkjkBsv@$YTSvy;TJdxzwZW/Dqײz٫P?#aZfY ~zXfP{3dhd{I^#qZ#B|ehhD{*ZwJqŔr$a6gqb\߶6~j歝r[c2΍w#ٮZo8y~L3=NUf*z7v Hn$TC;~~ dUrs8wq̹$!T4Yq}LB+ڥ9_b.ffe#T"i:z|`^-N[&D]1wfu"8˚4$IE:O-嵫~rƞPJ$hDfL&>$(unU_[ŔiZֳsdIoXyA GKh'[!i=$ɩZJVVA łN9SI&؜M3Y({q)A$ٮZEtt,N9Jϳ=Skjw4M"N* ca6OYˮ3C3N3jbB6tΪC$ai͜@ևxD~/oAԅλ->]tu.&؛"$=<8v^ަi[2Z\Xt⸇Į?$YT'C\.\8i36rS24)Y[]f1%j ~jLؗ[VjUY" 2jKC7>:00$\6mr3( reference libFLAC 1.2.1 20070917 ?~=ӻ/w?o~gfzs>g9gysݹϿ33]9?^ztϥ.ɜ>|3f۝~wMMs{^?gsY󛞶M>}IO/|Od&{399f7_>o$/?ϖ3˙M=̳>O'$s'Zug{/ϧ%w?ξ{z{\}9w2w?7<~y˯?ys?';s>u}׿Ϲss+93?+l9=ɧo>N\|UL?r_s_m_wy6???_tܷouzs&tͺynow2r5s濓漥_yNi=3j/9^<=?|g>yϒ-'Nߙ{_>~?O5|_/>o3f_s?s<\_ӟgs;?S﹙?'{s?:'3yO3<ϻ>Y߽vgY<Z~?ӿ~lܛOw9L$_Ogrrϛ^S^͟$?>O}/w|3OL?=N>K|ys?'og~y~fݟ?7+Lӿ9}OgO%w_}ɓgy}'?9>_g|*|?Iw>jsrs&W'g۾s_[<-Oגs<<9)|3em>[>Oϓ%45gϷ7Lw~O'.:>3wS9yW?s]?o?}62Lv˧yyfRK'|3͟sʗ6?O7߳}ygy=/?e?i>[}fWylOOzM}穻9jgwv99tLJwy~:~~||[yoӓ???k>ӼM_w?o&sy'ݖ3Ͽd?~n&/w=>zL/ϝOۓ_;}~y}'?9?e|;ߟ9Y?'2rwwI}Ot%g3yg?}?^k'}6gI[sK\g3?uK?d]~~'߿fwf缟WsϟOryfӜ7\gɧ?|Ͽ?7-ϙ94i|:}湙|~5^}͛[sOoo-O=r|u?OI$ig$;{&w'9~ue?9>W|w3wyL\'MwϤgs.}yy3d?}?mg'?ڒVz?%y[K6O)9g<>y_~w-3ww'lf2e{_?{OϗvOv~|~7y$ϟӟ秜wyw|}7<}d۵?L~g=K'3O7w=߹|es<}w|wsOV~o5t'?r+eYvO92~y~Y7e|9=f}y̟7<{|yo}{^f<ݞso9r65?NNo'4mM{eIMs[?_Sg$e]^o9{z~Il|?>߿y|<92O\~I;>~|3濺~3}yJ~dLgyܿ<_߼oZ~w?_<>gʛo~[O+gܿ}?oRdߓ3yNg}Y䟟_|s&%O7n}ϙ?_2noY>?ܙ^Լ?rm矹~o3/ng:O>}|~stw79'4gɛ{9O3w6[s3RuOgww?rJYly?i3_lf?˼ϓ?ݜ_w5~_ϟ?vw?;s3|vwϛy?Ov97loϓ俞yO̜9O%7d˟>?-Ϸ}>O??ܟgng?fϳ}3r_s~}^j~of&{s읝/gIΟ;?&N~=~{r~Ϝ)v?RsM?/ts|d>MoϜ?}Ҧwi4N2翙3?ϳΖyolg:'O˖|?_=?7ϙ*~ɓgϞo$y3do&{}3{Ϫ\<{Ϝ?OS9۟>w?/ygwy3>ϼs̗}?|?O|Ӿly{8ssy-rN\Y;黺e|Κyym|Ԟg;/??'oMoLsZgK~yO矿g^~>\s^g?y~]K?m?fnsyϞIL?i-y˿3?ϳ%fy~=zr|{;K糥o3J{w_~Wh]t jUVJUUWJTꪺꪪJRJ]TUU.U}+*R*UTtWJUIUUtvjUUI|U_UUUU*RU+UWTUUU*T˥UJ.UJUjRꫪꪫꪕꮥU*UUUګ_UUuTRꪩuUiUUKU*UU*UJUTUjRJ]]}TUJWJUUUUUUJU_TTWU%UUUUU]JZUU*]UZJU*ժzTRJZIUIJ)tUUuW)JJUZK%UwuU)uJRUjUWWJ*RTWTUUUJU]}uJJJU*UUUU*IwUW_UU֥RU]U]UUWTU]]UUU*.R.wU]UZ]UU*UUW]RTJ_UUUԹUrJJ*ꪤW}%UJJUjUuURUUU*UUW]RU*ꪥW}TU*J]UҪUUuU]WU]TԾUVIuUKTRKKWRWUWUKꪥ)WZuIWuuUUUuUUIUZU]TU)WuRꪪWU]vUK]*PMopidy-2.0.0/tests/data/song2.wav0000644000175000017500000010473412441116637017016 0ustar jodaljodal00000000000000RIFFԉWAVEfmt @@dataMopidy-2.0.0/tests/data/song3.wav0000644000175000017500000010473412441116637017017 0ustar jodaljodal00000000000000RIFFԉWAVEfmt @@dataMopidy-2.0.0/tests/data/song4.wav0000644000175000017500000010473412441116637017020 0ustar jodaljodal00000000000000RIFFԉWAVEfmt @@dataMopidy-2.0.0/tests/data/conf1.d/0000775000175000017500000000000012660436443016473 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/data/conf1.d/file2.conf0000664000175000017500000000002112441116637020331 0ustar jodaljodal00000000000000[foo2] bar = baz Mopidy-2.0.0/tests/data/conf1.d/file1.conf0000664000175000017500000000002012441116637020327 0ustar jodaljodal00000000000000[foo] bar = baz Mopidy-2.0.0/tests/data/empty-ext.m3u0000644000175000017500000000001012441116637017607 0ustar jodaljodal00000000000000#EXTM3U Mopidy-2.0.0/tests/data/file4.conf0000644000175000017500000000002712441116637017107 0ustar jodaljodal00000000000000[foo] bar = baz foobar Mopidy-2.0.0/tests/data/file1.conf0000644000175000017500000000002012441116637017075 0ustar jodaljodal00000000000000[foo] bar = baz Mopidy-2.0.0/tests/data/song1.mp30000644000175000017500000002222012441116637016704 0ustar jodaljodal00000000000000ID3vTIT2titleTPE1artistTALBalbumTDRC2010TRCK01/02HXingA  ""%)),00577<_ѣ;DNwتĪ5giԷ}n{(n(D^AI11Jy{ui <|;w4fk}~NA&. >D!*Poцz4;B8lʮQVwygxjr{ʷ0g(, ((z8L $fFVmA&DbqAFI`M`d1LC#; ޥvvmHݻQL.IMJf*"g?=!uF9')bC%dH4<#:q98oEd:PvM]V>v~yVˏ뫿W[(aB: ,6if<7!"cH'-h|16(V$> BE˴SJ/PnjݩsxVfx]M|ӥ͔bq T-h  0ӹM݌M89\R#;ޖw5,:,,X(EklUu uZ;Tvҥt ̪fy(~8,;]:hY*{<2M)(4;8Rk/_P'C0Ԋ?^Jgu(f(YVy+$r5AoGN7\e8(28L7f^Y`H$ ,@dF04ER'zX֑f4ZiIzT彡1S {UVgz;[0qvʯgtDP"=esF֚G.Kd/9:FtPYْW5W}\83lӅT xa/';­gMmV(8L_捡'v.ހms@0` z&u9`+A1e Eq [ܷ -pĊY̓3JUEVuMVxw'(f8L8 H gqbWbVxZSY琔}rlpiq&PM˅@9X`BC rm~mXV^.g2->(r(dVh9ѵK%X駦I(I9X aF&E"PJR*Qd{%  > hVS*K,ql[hm?Fnt^cW]PfkSC³VxGu.ed|{%e4Ѻ$ *R /~:FTiQbKUOV`e`R':uAH5ҝ`x:( v8R i@T*:*t6 Jtkg#QY?23^L@_4H!Pq[:!١Q*I(h\dXM`BVxlN8. ;\e%[(r8RL C=v6ShG& !U+}6QTa `>f&`{BK nm|R\ v@WaVx/$ ֩*pZ$T FM^;(/Ar8 $fei%vgo~n)UlًnQ/wNU)9udGojާg?$W˟(4݃n\\ٷSkRY\DYDFLĮ&A29R8Lѫ@]A$UlQQ(t|ۄH5+`qqz|Rog( ^BL:^oOD-cs!vNMw(U*#bx O"hP6,5i"~L*GޤZ$yT-\ީA7*gy'Ո씼!l! ( 88 *pW gsA9j |6%bVYjT7W+Zu4ޚԎ3]֭UVxӓhwwJqSRk9eYHh9ٗzo7w <<"y>Z8 -ċ {±c+T9 j1e{q& eEFAEw)( ^P톏Adb"S"QT"*W!$!VPg(1}9Jp`j DjfHTu/f؏ `P1gJ@xQU Rgxʟ;6z{(!zBLls1:FN"2): a!=FJ;H "ei[4QI',, <Sg.co=qw8S¶MEFƨ{CMI|TKs˃}<*LК/hvK!V?!wGd (bIJJ0@u,y3Q8H|bwIYBY:8$P!gYBn^~} {ֵBjVwvU9P1RӢVy (Yb8L "/Tuµ܌@O<^A^!A`q-;J A5,@,asAаrw\tț|\TB$0`vr0ugx 6\zRCNUm!r" 7((q~RLP$p'HCFOFttig(겏Rh4<\S=,`H2XyީG)weloʁIr0Q3h,,IVz$nRU7 YEˁTTFnhCj(1z8(+ MJǔ 64 Tfļ+Hy'$"4znq/#TKrwp#.\V.|d'53%2= 1T8=bGHal-0N BQnPP$elf m__U=noz bŕVwziIKIf(fBLʌ6JN'ZԽ*ʓ O-;niJp|ZF `dr+y{AV":ׄf 6$Pz`b܄.pLVxxnLɖnt/TR(xҘ(ٺ8P 9LVա2mX֥*U8dCR:G HI ʁ[i}i`L}6bV{%sc,-P5"y7A"(.a:F`2aJAmN>@H[s,%"aAׁ1 Fs-s+Uqj\X&(K2 WWﮘ>][IFX0ӻ\!K痮[ڴtW^(ySA H%2qMaETԙe1L8+t>_@ Ejj=c^i&:zPȨ*&.%Wjm `B3-L7E`wsKbXX:O 8a 5#Cb H <A &+gG"0,R)')0`}2?bz?/(ՍpHe*VC:<ߍ/Ԩc3C.jLAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU*HUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUeHUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUTAGtitle artist album 2010 Mopidy-2.0.0/tests/data/conf2.d/0000775000175000017500000000000012660436443016474 5ustar jodaljodal00000000000000Mopidy-2.0.0/tests/data/conf2.d/file2.conf.disabled0000664000175000017500000000002112441116637022100 0ustar jodaljodal00000000000000[foo2] bar = baz Mopidy-2.0.0/tests/data/conf2.d/file1.conf0000664000175000017500000000002012441116637020330 0ustar jodaljodal00000000000000[foo] bar = baz Mopidy-2.0.0/tests/data/file3.conf0000644000175000017500000000006712441116637017112 0ustar jodaljodal00000000000000# Should contain valid utf-8 values [foo] bar = æøå Mopidy-2.0.0/tests/data/song2.ogg0000644000175000017500000002073712441116637016775 0ustar jodaljodal00000000000000OggSb~|vorbis@@OggSb~X -vorbisXiph.Org libVorbis I 20090709vorbisBCV R!%SJcRR)cP[Gc9F!dSI{O*XJRX)ESLSIR)EcSH!S1esKI %lMtKc1FcZJc1EcRRIs:f%d:Fb|0:B(R-[S-KiasJjc1S(АU@BCV P EQАU@EqqG$BCV@((#IdYeYy/.FuL*CCc3C LcN4 23Ő2[,.!+(b 9dR"瘔NJQ(K[1Q(eBŌRT@@PhȊ 0)B)s1 1 d)NJ圓Ic1sNJrIɤ`!"0HY&gꉢZgigj뚪ʖ癦gꙦ꺦l.jۮkl+ʺʶKgnkۚjʮm-l,fl-,ڶ*˺/n,lںk,*˾1۶˺.'뙪몮k۪ںl-+۪,+˶,+ۦʲ*˾ʲn,*1̶*˺ʲn nʲ ˬۺ1﫲-, 2>ct]_WmYV}cuaYm[][gn nʭ ˲ڶ̺,.|[ڶ麺nʲ˺.uWF}ն}_e߷_ið,k/뺰/,m+0ۺܾ, ˪۾ҵue}+ p0 "@r)b BB*cR2dI)JIbLJ朔1)J)RZ*Ji-bJPJkJIbL1&%sNJ朔Rk%2(eJ J*b朤:+J*1b VJjc+1Z!KJZm1Z5bLJ朔9*%J*eI 9(b*)9I2ȨZ+Rc)b)CI-Z,bTS'ŒR%[j VJ[k1cK+ZlZ5TcJc1k=bM1ZՖ[̵NJkKJ1b1Ji[))Z\C)Z,bVcj[ZkV[.b=kXSm 8P Y D0F)ǜ(sR* RRʜPJKsJI)RjRRj lДXА@*q4MUu}_,QTUוmW,MUUvm[5QTU׵m~MUUveٶm۲n èk۲m먮ۺۺ/T]Ym[u׶uu]mn# G!tBOp*auBCV1J3H1cL1Ƙ@!+(9s9s9s9s1c1c1c1c1c1cLNNPhJ @!))RJSAI)RJJ)RuRJ)"RJ)II)RJ):J)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)TJ)RJ)RJ)`` +IgrBiTRJ%UA(%JJ)RJ*RJ)A(PJ)%RJ(%J TB+RJB)TJ)%A(!BIBHtTR)!RJ)%:RK-RZJtR)RjR VJI%ZI%JI%RK)TRI%R*%ZJRHRJRJ%JJRj%Rj!JI)J)TBIRZJ-JITRIJI)RJK)JJZI)RJ)RK%TJ %RIRK)R@FTZf\y(d@@ 0@P0 A0GќBCOggS]b~u^+)-+**+.+-+,+,+0/,/,*0/-**,-.,+/+**/)/++--+-,.+**...-/*-+'-*+,.0+,++/)./-..+//0(((-+**,,,++*+*":5Ct+:1V3IꙨ1o2 #a #vNYVIK.iܳd)Ԝ֦ $Z=l;_ gC[#%[Rus=*=zscP)n(7|pA.b՟){*Xڠc].##am6[J>t+lMn/{6p$' si);rqF#介Iy `$uB+<%w{C7"aQ] G\]l$YT͆a޻V_=ݮdc7j!-ٶʨmf4^0cdaLRIi];#(tx+年WZ9ziVIY'hkV=S:#YKZMziȇ:kƠXR}P |48Mz#Y$MJeϬ zQxTlHxR:c!V6?[#ꗪC3Ĉ+wۜw$@8K5ł,>'i`CQWy cNT:P#Y$e>Gڨ]񶘥/'qы]v2ó|nk$\R=4F}Zs}0xR/}3bOBt8ͶGr#EZsc Sj?w'bae ѭotYRFsu7#IAn ,PW,VxiiiȼʦJDV+.%@2gQi[[[s7)a*U:?<~$Im_.*=l+ i^22WJSqp $\jX6a{xYc;sž$c)GrpW%!Zo'¯e.+eʳL&K x$ZgNE 1N9m=< HT\FS\0ǝ$qZ/`j'ip6-qprqأ$)smTuflcL9]0O3 k7b@H|i.*i*w%|$!VzǻT \eUg.l}c%dfoST"m]ugG%4%tc)zL3dcIY;܋]^BXV>l+\\H!Z(nuZ{4{$qV)/>P3iMWKE ý|ɐO5#T[NY=.]MuOul'5Vq=Ԏtu#a SX<.n'm)?*gl~u]"VV/X # 9UޏÞA.u'__#N%ߊi#%vE?N TvI\xZˏ~+6sj# kuzU"#fS=.DCc.a$a [uk0۹GnAon;e-qqD$T3>1>TnctgLnFٵYUƢ&$!,\w=rwTOj|D+fn9St#Y$+ !@\(k"G@=dH71$ɩ簦. wY}3 ՘dYN9cRt[V6k#qm?1MW&fްn6Ja$m4(L$aZ̒6) ^b*1fةhiw^kgS$!R4/o])͗&ޫd]6g蓕 aݻczmdĹnP;RE U+;#,ak2Ek 0W˳CQr8-ۻBZaEQ5$:s5]zv]#GFI$mЍCrpYqʙ$!V;x\@b1km6Α|k}AqZ*e5$!V=T]+M᱙(rC2zߨ$h԰Vvbj U׊\&]Jƹ $ܨZ`θW(^m)kkg]믙A}e/<[$dM  ܱww BWMD[=ZPq$匩絃|F~]U&(0ڍ1H~KS>RZoVJP$(a,xlɈ˽V !n։ 8?9=vBF$bSSOs#wZ)Q7=np=()z/TKs1qX#YTVy9 թPZjO %+-nYFZe$\¦V0]Iw83oז8bdvb#!SsT}wB}}1K~_S8SB$Hlz'Tz.JzH9>S$֭y:8$h$]XyLQ+@MX8{  qx9G0j~ $!fTMz%_iVm~[$2#!º0+|15'');ni艘uڛ#YTd:w1~w< N|'EgÛZ`iz;,n'#qZOrE^Ҡ2BwiW8 ɴ 9 c!uZsϒGvmJ:CM8G%#YTxh޼M[ b ri.{%H8w rcc4c[Qwa5MSwwRj:82T OggSb~r-++31/**),00.-.).,+,/--/,2)*.+,,-11,*+.1-*.)+)$aRJ+JmТəUyKƝy X]ӹ@}"Y$yvfFm.qh-83$:!t׽2կj2f;Q[[R㜅%([7$!j@oAҿ4E3sj3J)#iJzdqv2(lrI41af IknKޝ^+ R4Y3aDp#\Z bzJ )HxeABn* ة]!$%MBbeoLD1g{0fc#aNOY1obO% fgٹo[Sm!g1c;{ -$(pX_gȥ Q3 3IQV:`0u$!֓2ǭ䧕 s͡mݝ&;N$a3wTxJש 1,rr~adjθ#Tmsp63=:V=N5-ѝOis'Y6N$Y$òoצxRBv{$ 77{+d?'c4X Ja b#vm^ס'&UU^;OVǶ Scv]~Mg^Smd;W:WFɂՕUR$I&54UЈTI]YK,e_o Qr #!ZnN[S#nDs kkjkBsv@$YTSvy;TJdxzwZW/Dqײz٫P?#aZfY ~zXfP{3dhd{I^#qZ#B|ehhD{*ZwJqŔr$a6gqb\߶6~j歝r[c2΍w#ٮZo8y~L3=NUf*z7v Hn$TC;~~ dUrs8wq̹$!T4Yq}LB+ڥ9_b.ffe#T"i:z|`^-N[&D]1wfu"8˚4$IE:O-嵫~rƞPJ$hDfL&>$(unU_[ŔiZֳsdIoXyA GKh'[!i=$ɩZJVVA łN9SI&؜M3Y({q)A$ٮZEtt,N9Jϳ=Skjw4M"N* ca6OYˮ3C3N3jbB6tΪC$ai͜@ևxD~/oAԅλ->]tu.&؛"$=<8v^ަi[2Z\Xt⸇Į?$YT'C\.\8i36rS24)Y[]f1%j ~jLؗ[VjUY" 2jKC7>:00$\6m