pax_global_header00006660000000000000000000000064122643376310014521gustar00rootroot0000000000000052 comment=58940a78b22925e8356b40c949df4dc6907adc1e mopidy-soundcloud-1.0.18/000077500000000000000000000000001226433763100152665ustar00rootroot00000000000000mopidy-soundcloud-1.0.18/.coveragerc000066400000000000000000000001151226433763100174040ustar00rootroot00000000000000[report] omit = */pyshared/* */python?.?/* */site-packages/nose/*mopidy-soundcloud-1.0.18/.gitignore000066400000000000000000000001071226433763100172540ustar00rootroot00000000000000*.egg-info *.pyc *.swp .coverage MANIFEST build/ dist/ .tox .idea covermopidy-soundcloud-1.0.18/.travis.yml000066400000000000000000000021441226433763100174000ustar00rootroot00000000000000language: python install: - "wget -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add -" - "sudo wget -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list" - "sudo apt-get update || true" - "sudo apt-get install $(apt-cache depends mopidy-soundcloud | awk '$2 !~ /mopidy-soundcloud|python:any/ {print $2}')" - "pip install coveralls flake8 mopidy==dev" before_script: - "rm $VIRTUAL_ENV/lib/python$TRAVIS_PYTHON_VERSION/no-global-site-packages.txt" script: - "flake8 mopidy_soundcloud tests" - "nosetests --with-coverage --cover-package=mopidy_soundcloud" after_success: - "coveralls" branches: except: - debian notifications: irc: channels: - "irc.freenode.org#mopidy" on_success: change on_failure: change use_notice: true skip_join: true deploy: provider: pypi user: janez.troha password: secure: JCJ8yvOeytrzODupwDLfUIVV8fAJVq7Bm09B0vo4YhWEn90XUhBk2A3s/+nSaEZ9rAgbAerddsyCGFV6K9svzP5h1+6PLbU+s775JfjU/KqyJxaJmRdrVp33Cvadukuq8mV8d+z+tTI6acK/Wq9XpmYLRFVH4TX829TN1fzox8U= on: tags: true repo: mopidy/mopidy-soundcloudmopidy-soundcloud-1.0.18/LICENSE000066400000000000000000000020571226433763100162770ustar00rootroot00000000000000Copyright (C) 2013 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.mopidy-soundcloud-1.0.18/MANIFEST.in000066400000000000000000000001321226433763100170200ustar00rootroot00000000000000include LICENSE include MANIFEST.in include README.rst include mopidy_soundcloud/ext.conf mopidy-soundcloud-1.0.18/README.rst000066400000000000000000000051621226433763100167610ustar00rootroot00000000000000***************** Mopidy-SoundCloud ***************** .. image:: https://pypip.in/v/Mopidy-SoundCloud/badge.png :target: https://pypi.python.org/pypi/Mopidy-SoundCloud/ :alt: Latest PyPI version .. image:: https://pypip.in/d/Mopidy-SoundCloud/badge.png :target: https://pypi.python.org/pypi/Mopidy-SoundCloud/ :alt: Number of PyPI downloads .. image:: https://travis-ci.org/mopidy/mopidy-soundcloud.png?branch=master :target: https://travis-ci.org/mopidy/mopidy-soundcloud :alt: Travis CI build status .. image:: https://coveralls.io/repos/mopidy/mopidy-soundcloud/badge.png?branch=master :target: https://coveralls.io/r/mopidy/mopidy-soundcloud?branch=master :alt: Test coverage `Mopidy `_ extension for playing music from `SoundCloud `_. Installation ============ Install by running:: pip install Mopidy-SoundCloud Or, if available, install the Debian/Ubuntu package from `apt.mopidy.com `_. Configuration ============= #. You must register for a user account at http://www.soundcloud.com/ #. You need a SoundCloud authentication token for Mopidy from http://www.mopidy.com/authenticate #. Add the authentication token to the ``mopidy.conf`` config file:: [soundcloud] auth_token = 1-1111-1111111 #. Extra playlists from http://www.soundcloud.com/explore can be retrieved by setting the ``soundcloud/explore`` config value. For example, if you want Smooth Jazz from https://soundcloud.com/explore/jazz%2Bblues your entry would be "jazz%2Bblues/Smooth Jazz". Example config:: [soundcloud] auth_token = 1-1111-1111111 explore = electronic/Ambient, pop/New Wave, rock/Indie Project resources ================= - `Source code `_ - `Issue tracker `_ - `Download development snapshot `_ Changelog ========= v1.0.18 (2014-01-11) -------------------- - Use proper logger namespaced to ``mopidy_soundcloud`` instead of ``mopidy``. - Fix wrong use of ``raise`` when the SoundCloud API doesn't respond as expected. v1.0.17 (2013-12-21) -------------------- - Don't cache the user request. - Require Requests >= 2.0. (Fixes #3) v1.0.16 (2013-10-22) -------------------- - Require Mopidy >= 0.14. - Fix crash when SoundCloud returns 404 on track lookup. (Fixes #7) - Add some tests. v1.0.15 (2013-07-31) -------------------- - Import code from old repo. - Handle authentication errors without crashing. (Fixes #3 and #4) mopidy-soundcloud-1.0.18/mopidy_soundcloud/000077500000000000000000000000001226433763100210265ustar00rootroot00000000000000mopidy-soundcloud-1.0.18/mopidy_soundcloud/__init__.py000066400000000000000000000026361226433763100231460ustar00rootroot00000000000000from __future__ import unicode_literals import os from mopidy import ext, config from mopidy.exceptions import ExtensionError __version__ = '1.0.18' __url__ = 'https://github.com/mopidy/mopidy-soundcloud' class SoundCloudExtension(ext.Extension): dist_name = 'Mopidy-SoundCloud' ext_name = 'soundcloud' 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(SoundCloudExtension, self).get_config_schema() schema['explore'] = config.List() schema['explore_pages'] = config.Integer() schema['auth_token'] = config.Secret() return schema def validate_config(self, config): if not config.getboolean('soundcloud', 'enabled'): return if not config.get('soundcloud', 'auth_token'): raise ExtensionError("In order to use SoundCloud extension you\ must provide auth_token, for more information referrer to \ https://github.com/mopidy/mopidy-soundcloud/") def validate_environment(self): try: import requests # noqa except ImportError as e: raise ExtensionError('Library requests not found', e) def get_backend_classes(self): from .actor import SoundCloudBackend return [SoundCloudBackend] mopidy-soundcloud-1.0.18/mopidy_soundcloud/actor.py000066400000000000000000000020431226433763100225070ustar00rootroot00000000000000from __future__ import unicode_literals import logging import pykka from mopidy.backends import base from .library import SoundCloudLibraryProvider from .playlists import SoundCloudPlaylistsProvider from .soundcloud import SoundCloudClient logger = logging.getLogger(__name__) class SoundCloudBackend(pykka.ThreadingActor, base.Backend): def __init__(self, config, audio): super(SoundCloudBackend, self).__init__() self.config = config self.sc_api = SoundCloudClient(config['soundcloud']['auth_token']) self.library = SoundCloudLibraryProvider(backend=self) self.playback = SoundCloudPlaybackProvider(audio=audio, backend=self) self.playlists = SoundCloudPlaylistsProvider(backend=self) self.uri_schemes = ['soundcloud'] class SoundCloudPlaybackProvider(base.BasePlaybackProvider): def play(self, track): id = self.backend.sc_api.parse_track_uri(track) track = self.backend.sc_api.get_track(id, True) return super(SoundCloudPlaybackProvider, self).play(track) mopidy-soundcloud-1.0.18/mopidy_soundcloud/ext.conf000066400000000000000000000005111226433763100224720ustar00rootroot00000000000000[soundcloud] enabled = True # Your SoundCloud auth token, you can get yours at http://www.mopidy.com/authenticate auth_token = # Extra playlists from http://www.soundcloud.com/explore explore = pop/Easy Listening, rock/Indie, electronic/Ambient # Number of pages (which roughly translates to hours) to fetch explore_pages = 1mopidy-soundcloud-1.0.18/mopidy_soundcloud/library.py000066400000000000000000000024731226433763100230520ustar00rootroot00000000000000from __future__ import unicode_literals import logging from mopidy.backends import base from mopidy.models import SearchResult logger = logging.getLogger(__name__) class SoundCloudLibraryProvider(base.BaseLibraryProvider): def __init__(self, *args, **kwargs): super(SoundCloudLibraryProvider, self).__init__(*args, **kwargs) def find_exact(self, **query): return self.search(**query) def search(self, **query): if not query: return for (field, val) in query.iteritems(): # TODO: Devise method for searching SoundCloud via artists if field == "album" and query['album'] == "SoundCloud": return SearchResult( uri='soundcloud:search', tracks=self.backend.sc_api.search(query['artist']) or []) elif field == "any": return SearchResult( uri='soundcloud:search', tracks=self.backend.sc_api.search(val[0]) or []) else: return [] def lookup(self, uri): try: id = self.backend.sc_api.parse_track_uri(uri) return [self.backend.sc_api.get_track(id)] except Exception as error: logger.error(u'Failed to lookup %s: %s', uri, error) return [] mopidy-soundcloud-1.0.18/mopidy_soundcloud/playlists.py000066400000000000000000000071241226433763100234300ustar00rootroot00000000000000from __future__ import unicode_literals import logging from mopidy.backends import base, listener from mopidy.models import Playlist logger = logging.getLogger(__name__) class SoundCloudPlaylistsProvider(base.BasePlaylistsProvider): def __init__(self, *args, **kwargs): super(SoundCloudPlaylistsProvider, self).__init__(*args, **kwargs) self.config = self.backend.config self._playlists = [] self.refresh() def create(self, name): pass # TODO def delete(self, uri): pass # TODO def lookup_get_tracks(self, uri): # TODO: Figure out why some sort of internal cache is used for # retrieving track-list on mobile clients. If you want this to work # with mobile clients change defaults to streamable=True if 'soundcloud:exp-' in uri: return self.create_explore_playlist(uri, True) elif 'soundcloud:user-liked' in uri: return self.create_user_liked_playlist(True) elif 'soundcloud:user-stream' in uri: return self.create_user_stream_playlist(True) else: return [] def lookup(self, uri): for playlist in self._playlists: if playlist.uri == uri: # Special case with sets, which already contain all data if 'soundcloud:set-' in uri: return playlist logger.debug('Resolving with %s', playlist.name) return self.lookup_get_tracks(uri) def create_explore_playlist(self, uri, streamable=False): uri = uri.replace('soundcloud:exp-', '') pages = self.config['soundcloud']['explore_pages'] (category, section) = uri.split(';') logger.debug('Fetching Explore playlist %s from SoundCloud' % section) return Playlist( uri='soundcloud:exp-%s' % uri, name='Explore %s on SoundCloud' % section, tracks=self.backend.sc_api.get_explore_category( category, section, pages) if streamable else [] ) def create_user_liked_playlist(self, streamable=False): username = self.backend.sc_api.get_user().get('username') logger.debug('Fetching Liked playlist for %s' % username) return Playlist( uri='soundcloud:user-liked', name="%s's liked on SoundCloud" % username, tracks= self.backend.sc_api.get_user_favorites() if streamable else [] ) def create_user_stream_playlist(self, streamable=False): username = self.backend.sc_api.get_user().get('username') logger.debug('Fetching Stream playlist for %s' % username) return Playlist( uri='soundcloud:user-stream', name="%s's stream on SoundCloud" % username, tracks=self.backend.sc_api.get_user_stream() if streamable else [] ) def refresh(self): self._playlists.append(self.create_user_liked_playlist()) self._playlists.append(self.create_user_stream_playlist()) for (name, uri, tracks) in self.backend.sc_api.get_sets(): scset = Playlist( uri='soundcloud:set-%s' % uri, name=name, tracks=tracks ) self._playlists.append(scset) for cat in self.config['soundcloud']['explore']: exp = self.create_explore_playlist(cat.replace('/', ';')) self._playlists.append(exp) logger.info('Loaded %d SoundCloud playlist(s)', len(self._playlists)) listener.BackendListener.send('playlists_loaded') def save(self, playlist): pass # TODO mopidy-soundcloud-1.0.18/mopidy_soundcloud/soundcloud.py000066400000000000000000000201261226433763100235600ustar00rootroot00000000000000from __future__ import unicode_literals import logging import time from urllib import quote_plus import requests from requests.exceptions import RequestException from mopidy.models import Track, Artist, Album logger = logging.getLogger(__name__) class cache(object): # TODO: merge this to util library def __init__(self, ctl=8, ttl=3600): self.cache = {} self.ctl = ctl self.ttl = ttl self._call_count = 1 def __call__(self, func): def _memoized(*args): self.func = func now = time.time() try: value, last_update = self.cache[args] age = now - last_update if (self._call_count >= self.ctrl or age > self.ttl): self._call_count = 1 raise AttributeError self._call_count += 1 return value except (KeyError, AttributeError): value = self.func(*args) self.cache[args] = (value, now) return value except TypeError: return self.func(*args) return _memoized class SoundCloudClient(object): CLIENT_ID = '93e33e327fd8a9b77becd179652272e2' def __init__(self, token): super(SoundCloudClient, self).__init__() self.http_client = requests.Session() self.http_client.headers.update({'Authorization': 'OAuth %s' % token}) self.user = self.get_user() try: logger.debug('User id for username %s is %s' % ( self.user.get('username'), self.user.get('id'))) except Exception as e: logger.error( 'Authentication error: %s. Check your auth_token!' % e) def get_user(self): return self._get('me.json') # Private @cache() def get_user_stream(self): # User timeline like playlist which uses undocumented api # https://api.soundcloud.com/e1/me/stream.json?offset=0 # returns five elements per request tracks = [] for sid in xrange(0, 2): stream = self._get('e1/me/stream.json?offset=%s' % sid * 5) for data in stream.get('collection'): try: kind = data.get('type') # multiple types of track with same data if 'track' in kind: tracks.append(self.parse_track(data.get('track'))) if kind == 'playlist': tracks.extend(self.parse_results( data.get('playlist').get('tracks'))) except Exception: # Type not supported or SC changed API pass return self.sanitize_tracks(tracks) @cache() def get_sets(self): playlists = self._get('users/%s/playlists.json' % self.user.get('id')) tplaylists = [] for playlist in playlists: name = '%s on SoundCloud' % playlist.get('title') uri = playlist.get('permalink') tracks = self.parse_results(playlist.get('tracks')) logger.debug('Fetched set %s with id %s' % (name, uri)) tplaylists.append((name, uri, tracks)) return tplaylists def get_user_favorites(self): favorites = self._get('users/%s/favorites.json' % self.user.get('id')) return self.parse_results(favorites) # Public def get_track(self, id, streamable=False): try: # TODO better way to handle deleted tracks return self.parse_track( self._get('tracks/%s.json' % id), streamable) except Exception: return Track() def parse_track_uri(self, track): if hasattr(track, "uri"): track = track.uri id = track.split(';')[1] logger.info('Getting info for track %s with id %s' % (track, id)) return id @cache() def get_explore_category(self, category, section, pages=1): logger.debug("get_explore_category %s %s" % (category, section)) # Most liked by category in explore section tracks = [] for sid in xrange(0, int(pages) + 1): stream = self._get('explore/sounds/category/%s?offset=%s' % ( category.lower(), sid * 20)) for data in stream.get('collection'): if data.get('name') == section: for track in data.get('tracks'): tracks.append(self.get_track(track.get('id'))) return self.sanitize_tracks(tracks) @cache() def search(self, query): 'SoundCloud API only supports basic query no artist,' 'album queries are possible' # TODO: add genre filter res = self._get( 'tracks.json?q=%s&filter=streamable&order=hotness' % quote_plus(query)) tracks = [] for track in res: tracks.append(self.parse_track(track, False, True)) return self.sanitize_tracks(tracks) def parse_results(self, res): tracks = [] for track in res: tracks.append(self.parse_track(track)) return self.sanitize_tracks(tracks) def _get(self, url): # TODO: Optimize if '?' in url: url = '%s&client_id=%s' % (url, self.CLIENT_ID) else: url = '%s?client_id=%s' % (url, self.CLIENT_ID) url = 'https://api.soundcloud.com/%s' % url logger.debug('Requesting %s' % url) res = self.http_client.get(url) res.raise_for_status() return res.json() def sanitize_tracks(self, tracks): return filter(None, tracks) def parse_track(self, data, remote_url=False, is_search=False): if not data: return [] if not data['streamable']: return [] if not data['kind'] == 'track': return [] if not self.can_be_streamed(data['stream_url']): return [] # NOTE kwargs dict keys must be bytestrings to work on Python < 2.6.5 # See https://github.com/mopidy/mopidy/issues/302 for details. track_kwargs = {} artist_kwargs = {} album_kwargs = {} if 'title' in data: name = data['title'] # NOTE On some clients search UI would group results by artist # thus prevent user from selecting track if ' - ' in name: name = name.split(' - ') track_kwargs[b'name'] = name[1] artist_kwargs[b'name'] = name[0] elif 'label_name' in data and data['label_name'] != '': track_kwargs[b'name'] = name artist_kwargs[b'name'] = data['label_name'] else: track_kwargs[b'name'] = name artist_kwargs[b'name'] = data.get('user').get('username') album_kwargs[b'name'] = 'SoundCloud' #album_kwargs[b'url'] = data.get('permalink_url') if 'date' in data: track_kwargs[b'date'] = data['date'] if remote_url: track_kwargs[b'uri'] = self.get_streamble_url(data['stream_url']) else: track_kwargs[b'uri'] = 'soundcloud:song;%s' % data['id'] track_kwargs[b'length'] = int(data.get('duration', 0)) if artist_kwargs: artist = Artist(**artist_kwargs) track_kwargs[b'artists'] = [artist] if album_kwargs: if 'artwork_url' in data and data['artwork_url']: album_kwargs[b'images'] = [data['artwork_url']] else: image = data.get('user').get('avatar_url') if image: album_kwargs[b'images'] = [image] else: album_kwargs[b'images'] = [] album = Album(**album_kwargs) track_kwargs[b'album'] = album track = Track(**track_kwargs) return track def can_be_streamed(self, url): req = self.http_client.head(self.get_streamble_url(url)) return req.status_code == 302 def get_streamble_url(self, url): return '%s?client_id=%s' % (url, self.CLIENT_ID) mopidy-soundcloud-1.0.18/setup.py000066400000000000000000000024031226433763100167770ustar00rootroot00000000000000from __future__ 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-SoundCloud', version=get_version('mopidy_soundcloud/__init__.py'), url='https://github.com/mopidy/mopidy-soundcloud', license='MIT', author='dz0ny', author_email='dz0ny@ubuntu.si', description='SoundCloud extension for Mopidy', 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', 'requests >= 2.0.0', ], entry_points={ b'mopidy.ext': [ 'soundcloud = mopidy_soundcloud:SoundCloudExtension', ], }, classifiers=[ 'Environment :: No Input/Output (Daemon)', 'Intended Audience :: End Users/Desktop', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Programming Language :: Python :: 2', 'Topic :: Multimedia :: Sound/Audio :: Players', ], ) mopidy-soundcloud-1.0.18/tests/000077500000000000000000000000001226433763100164305ustar00rootroot00000000000000mopidy-soundcloud-1.0.18/tests/__init__.py000066400000000000000000000000001226433763100205270ustar00rootroot00000000000000mopidy-soundcloud-1.0.18/tests/__main__.py000066400000000000000000000001021226433763100205130ustar00rootroot00000000000000from __future__ import unicode_literals import nose nose.main() mopidy-soundcloud-1.0.18/tests/test_api.py000066400000000000000000000025411226433763100206140ustar00rootroot00000000000000from __future__ import unicode_literals import unittest from mopidy.models import Track from mopidy_soundcloud.soundcloud import SoundCloudClient class ApiTest(unittest.TestCase): # using this user http://maildrop.cc/inbox/mopidytestuser api = SoundCloudClient("1-35204-61921957-55796ebef403996") def test_resolves_string(self): id = self.api.parse_track_uri("soundcloud:song;38720262") self.assertEquals(id, "38720262") def test_resolves_object(self): trackc = {} trackc[b'uri'] = 'soundcloud:song;38720262' track = Track(**trackc) id = self.api.parse_track_uri(track) self.assertEquals(id, '38720262') def test_resolves_emptyTrack(self): track = self.api.get_track('s38720262') self.assertIsInstance(track, Track) self.assertEquals(track.uri, None) def test_resolves_Track(self): track = self.api.get_track('38720262') self.assertIsInstance(track, Track) self.assertEquals(track.uri, 'soundcloud:song;38720262') def test_resolves_stream_Track(self): track = self.api.get_track('38720262', True) self.assertIsInstance(track, Track) self.assertEquals( track.uri, 'https://api.soundcloud.com/tracks/' '38720262/stream?client_id=93e33e327fd8a9b77becd179652272e2' ) mopidy-soundcloud-1.0.18/tests/test_cache.py000066400000000000000000000014671226433763100211140ustar00rootroot00000000000000from __future__ import unicode_literals from mock import Mock import unittest from mopidy_soundcloud.soundcloud import cache class CacheTest(unittest.TestCase): def test_decorator(self): func = Mock() decorated_func = cache() decorated_func(func) func() self.assertEquals(func.called, True) self.assertEquals(decorated_func._call_count, 1) def test_set_default_cache(self): @cache() def returnstring(): return "ok" self.assertEquals(returnstring(), "ok") def test_set_ttl_cache(self): func = Mock() decorated_func = cache(func, ttl=5) func() self.assertEquals(func.called, True) self.assertEquals(decorated_func._call_count, 1) self.assertEquals(decorated_func.ttl, 5) mopidy-soundcloud-1.0.18/tests/test_extension.py000066400000000000000000000011461226433763100220570ustar00rootroot00000000000000from __future__ import unicode_literals import unittest from mopidy_soundcloud import SoundCloudExtension class ExtensionTest(unittest.TestCase): def test_get_default_config(self): ext = SoundCloudExtension() config = ext.get_default_config() self.assertIn('[soundcloud]', config) self.assertIn('enabled = True', config) def test_get_config_schema(self): ext = SoundCloudExtension() schema = ext.get_config_schema() self.assertIn('auth_token', schema) self.assertIn('explore', schema) self.assertIn('explore_pages', schema)