├── .gitignore ├── manage.py ├── music_browser ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py ├── readme.md ├── requirements.txt └── spotify_proxy ├── __init__.py ├── api_wrapper.py ├── apps.py ├── static └── spotify_proxy │ └── style.css ├── templates └── spotify_proxy │ └── index.html ├── tests.py ├── urls.py └── views.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "music_browser.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | # The above import may fail for some other reason. Ensure that the 11 | # issue is really that Django is missing to avoid masking other 12 | # exceptions on Python 2. 13 | try: 14 | import django 15 | except ImportError: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | "available on your PYTHONPATH environment variable? Did you " 19 | "forget to activate a virtual environment?" 20 | ) 21 | raise 22 | execute_from_command_line(sys.argv) 23 | -------------------------------------------------------------------------------- /music_browser/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aditbiswas1/spotify-browser/6415dbe2d6bb9977598d9d8b6912f31f78f5b910/music_browser/__init__.py -------------------------------------------------------------------------------- /music_browser/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for music_browser project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.11. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.11/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'rh9q^!o02ysms89gqbz%+@^r1_5aatqqo5o)4h@y3!oh6#md9h' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | LOCAL_APPS = [ 33 | 'spotify_proxy' 34 | ] 35 | 36 | INSTALLED_APPS = [ 37 | 'django.contrib.admin', 38 | 'django.contrib.auth', 39 | 'django.contrib.contenttypes', 40 | 'django.contrib.sessions', 41 | 'django.contrib.messages', 42 | 'django.contrib.staticfiles', 43 | ] + LOCAL_APPS 44 | 45 | MIDDLEWARE = [ 46 | 'django.middleware.security.SecurityMiddleware', 47 | 'django.contrib.sessions.middleware.SessionMiddleware', 48 | 'django.middleware.common.CommonMiddleware', 49 | 'django.middleware.csrf.CsrfViewMiddleware', 50 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 51 | 'django.contrib.messages.middleware.MessageMiddleware', 52 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 53 | ] 54 | 55 | ROOT_URLCONF = 'music_browser.urls' 56 | 57 | TEMPLATES = [ 58 | { 59 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 60 | 'DIRS': [], 61 | 'APP_DIRS': True, 62 | 'OPTIONS': { 63 | 'context_processors': [ 64 | 'django.template.context_processors.debug', 65 | 'django.template.context_processors.request', 66 | 'django.contrib.auth.context_processors.auth', 67 | 'django.contrib.messages.context_processors.messages', 68 | ], 69 | }, 70 | }, 71 | ] 72 | 73 | WSGI_APPLICATION = 'music_browser.wsgi.application' 74 | 75 | 76 | # Database 77 | # https://docs.djangoproject.com/en/1.11/ref/settings/#databases 78 | 79 | DATABASES = { 80 | 'default': { 81 | 'ENGINE': 'django.db.backends.sqlite3', 82 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 83 | } 84 | } 85 | 86 | 87 | # Password validation 88 | # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators 89 | 90 | AUTH_PASSWORD_VALIDATORS = [ 91 | { 92 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 93 | }, 94 | { 95 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 96 | }, 97 | { 98 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 99 | }, 100 | { 101 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 102 | }, 103 | ] 104 | 105 | 106 | # Internationalization 107 | # https://docs.djangoproject.com/en/1.11/topics/i18n/ 108 | 109 | LANGUAGE_CODE = 'en-us' 110 | 111 | TIME_ZONE = 'UTC' 112 | 113 | USE_I18N = True 114 | 115 | USE_L10N = True 116 | 117 | USE_TZ = True 118 | 119 | 120 | # Static files (CSS, JavaScript, Images) 121 | # https://docs.djangoproject.com/en/1.11/howto/static-files/ 122 | 123 | STATIC_URL = '/static/' 124 | -------------------------------------------------------------------------------- /music_browser/urls.py: -------------------------------------------------------------------------------- 1 | """music_browser URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.11/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.conf.urls import url, include 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) 15 | """ 16 | from django.conf.urls import include, url 17 | urlpatterns = [ 18 | url(r'^', include('spotify_proxy.urls'), name='spotify_proxy'), 19 | ] 20 | -------------------------------------------------------------------------------- /music_browser/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for music_browser project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "music_browser.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Simple Spotify Music Browser 2 | 3 | This is a simple django application which consumes the spotify web search api to to display names and images of tracks, albums, artists and playlists. 4 | 5 | ## Software Requirements: 6 | * Python3.4+ 7 | * Django 1.11 8 | * requests 2.13 9 | 10 | ## Installation 11 | The entire application can be setup with the following steps: 12 | 1. create and activate a virtualenv 13 | 2. pip install -r requirements.txt 14 | 3. python manage.py test (optional) 15 | 4. python manage.py runserver 16 | 5. open the browser and visit http://localhost:8000 17 | 18 | ## Design: 19 | 20 | The main parts of the application can be found in the module called spotify_proxy. 21 | 22 | There is a single view called index_view with parses get parameters q and filter, calls the api_wrapper for spotify and renders the html page with the results. I chose to keep the api_wrapper seperately from the view to make the view more easily extensible for future cases when we'd require to expose a json api instead of an HTML page. 23 | 24 | The api_wrapper is a simple abstraction over the spotify web api using the python requests library. 25 | The get_track_list function validates query parameters and then calls the spotify api. It then parses the response json to remove all the unecessary fields and return a list of items and the total count of the query present with spotify. 26 | 27 | The result is then rendered as html using a template defined using django's default templating language. The template contains a an html form which sends get requests to the same index url. 28 | 29 | I have also included a set of tests in the spotify_proxy app which validates the context of each possible query on the server and ensures that valid responses are retreived. 30 | 31 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==1.11 2 | requests==2.13.0 -------------------------------------------------------------------------------- /spotify_proxy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aditbiswas1/spotify-browser/6415dbe2d6bb9977598d9d8b6912f31f78f5b910/spotify_proxy/__init__.py -------------------------------------------------------------------------------- /spotify_proxy/api_wrapper.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | SPOTIFY_ENDPOINT = 'https://api.spotify.com' 5 | SEARCH_ENDPOINT = '/v1/search' 6 | 7 | request_headers = { 8 | 'content-type': "application/json" 9 | } 10 | 11 | 12 | def parse_track(item): 13 | ''' 14 | This function parses an individual track response. The track response is 15 | slightly different from the other filters because the images 16 | of a track are contained in the albums instead images. 17 | ''' 18 | name = item['name'] 19 | thumbnail_links = item.get('album', {}).get('images') 20 | smallest_image_width = 640 21 | image = None 22 | for thumb in thumbnail_links: 23 | if not thumb['width'] or not thumb['height']: 24 | continue 25 | if thumb['width'] < smallest_image_width and thumb['width'] >= 64 and thumb['height'] >= 64: 26 | image = thumb['url'] 27 | smallest_image_width = thumb['width'] 28 | return { 29 | 'name': name, 30 | 'image': image, 31 | } 32 | 33 | def parse_other_items(item): 34 | ''' 35 | This function parses the individual item responses for all the filter types 36 | except track. 37 | ''' 38 | name = item['name'] 39 | image = None 40 | smallest_image_width = 640 41 | thumbnail_links = item.get('images', []) 42 | for thumb in thumbnail_links: 43 | if not thumb['width'] or not thumb['height']: 44 | continue 45 | if thumb['width'] < smallest_image_width and thumb['width'] >= 64 and thumb['height'] >= 64: 46 | image = thumb['url'] 47 | smallest_image_width = thumb['width'] 48 | 49 | return { 50 | 'name': name, 51 | 'image': image 52 | } 53 | 54 | 55 | def parse_item(item, item_type): 56 | ''' 57 | This function returns the parsed item of the specific item_type 58 | it returns a dictionary 59 | { 60 | 'name': 'entity_name', 61 | 'image': 'url' or None 62 | } 63 | ''' 64 | if item_type == 'track': 65 | return parse_track(item) 66 | else: 67 | return parse_other_items(item) 68 | 69 | 70 | 71 | def get_track_list(search_query, query_filter='track', limit=20, offset=0): 72 | ''' 73 | This function validates query parameters and calls the spotify webapi if all the validations pass 74 | 75 | NOTE: the spotify webapi has a maximum limit parameter of 100,000 as defined in the doc. while a query 76 | can have a greater count, spotify will not return results after 100,000 77 | 78 | in case of any failure from the api such as 429 or 500, the function fails gracefully and returns empty results 79 | ''' 80 | if search_query is None or '': 81 | return 0, [] 82 | 83 | if limit < offset: 84 | return 0, [] 85 | 86 | if limit > 100000: 87 | return 0, [] 88 | 89 | valid_query_filters = ['track', 'album', 'playlist', 'artist'] 90 | if query_filter not in valid_query_filters: 91 | return 0, [] 92 | 93 | request_params = { 94 | 'q' : search_query, 95 | 'type' : query_filter, 96 | 'limit': limit, 97 | 'offset': offset, 98 | } 99 | 100 | api_endpoint = SPOTIFY_ENDPOINT + SEARCH_ENDPOINT 101 | response = requests.get(api_endpoint, headers=request_headers, params=request_params) 102 | 103 | if response.status_code == requests.codes.ok: 104 | response_object = response.json() 105 | key = query_filter + 's' 106 | count = response_object[key].get('total', 0) 107 | response_items = response_object[key].get('items') 108 | 109 | items = [parse_item(item, item_type=query_filter) for item in response_items] 110 | return count, items 111 | 112 | else: 113 | return 0, [] -------------------------------------------------------------------------------- /spotify_proxy/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SpotifyProxyConfig(AppConfig): 5 | name = 'spotify_proxy' 6 | -------------------------------------------------------------------------------- /spotify_proxy/static/spotify_proxy/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #f5f7f9; 3 | } 4 | 5 | .navbar-default .navbar-brand:focus, .navbar-default .navbar-brand:hover { 6 | color: #777; 7 | } 8 | 9 | a.search-icon { 10 | position: absolute; 11 | top: 1px; 12 | right: 30px; 13 | font-size: 130%; 14 | border-left: 1px solid #ddd; 15 | padding-left: 11px; 16 | color: #555; 17 | line-height: 46px; 18 | height: 44px; 19 | } 20 | 21 | a.search-icon:hover { 22 | color: #777; 23 | } 24 | 25 | input.search-input { 26 | background: #fff; 27 | padding: 0px; 28 | border:none; 29 | } 30 | 31 | li.counter { 32 | padding: 15px 10px 15px 10px; 33 | background-color: white; 34 | color: #555; 35 | } 36 | 37 | ul.results { 38 | list-style-type: none; 39 | padding-left: 0px; 40 | padding-top: 25px; 41 | } 42 | 43 | ul.results li { 44 | border-bottom: 1px solid #ddd; 45 | padding: 8px 0px 8px 0px; 46 | white-space: nowrap; 47 | overflow: hidden; 48 | text-overflow: ellipsis; 49 | } 50 | 51 | ul.results li:last-child { 52 | border-bottom: 0px; 53 | } 54 | 55 | p.message { 56 | padding-top: 10px; 57 | } 58 | p.error { 59 | color: red; 60 | } 61 | 62 | img.thumb { 63 | padding-right: 15px; 64 | max-width: 64px; 65 | max-height: 64px; 66 | } 67 | 68 | .form-control:focus { 69 | -webkit-appearance: none; 70 | -ms-appearance: none; 71 | -o-appearance: none; 72 | appearance: none; 73 | outline: none; 74 | box-shadow: none; 75 | } -------------------------------------------------------------------------------- /spotify_proxy/templates/spotify_proxy/index.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Mylittlejob developer test 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 25 | 26 | 27 | 60 |
61 |
62 |
63 |
64 | 65 | 66 | 67 | {% if not q %} 68 |

Please fill out the form.

69 | {% else %} 70 |

Found {{ count }} results for "{{ q }}" in {{ filter }}s

71 | {% endif %} 72 | 73 |
74 |
75 |
76 |
77 |
78 |
79 | 84 |
85 |
86 | 87 | 88 | 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /spotify_proxy/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, Client 2 | from django.urls import reverse 3 | # Create your tests here. 4 | 5 | class IndexViewTest(TestCase): 6 | 7 | def setUp(self): 8 | self.view_endpoint = reverse('index') 9 | self.client = Client() 10 | 11 | def generic_valid_search(self, q, query_filter): 12 | ''' 13 | Tests the context of any valid query with any valid filter 14 | response filter should match the input filter 15 | response query should match the input query 16 | results count and number of items should be greater than 0 17 | ''' 18 | params = { 19 | 'q' : q, 20 | 'filter': query_filter, 21 | } 22 | 23 | response = self.client.get(self.view_endpoint, params) 24 | 25 | self.assertEqual(response.status_code, 200) 26 | 27 | self.assertTrue(response.context['count']>0) 28 | self.assertTrue(len(response.context['items'])>0) 29 | self.assertEqual(response.context['q'], params['q']) 30 | self.assertEqual(response.context['filter'], params['filter']) 31 | 32 | message = '

Found {count} results for "{q}" in {filter}s

'.format( 33 | count=response.context['count'], 34 | q=q, 35 | filter=query_filter 36 | ) 37 | self.assertContains(response, message) 38 | return response 39 | 40 | def test_landing_page(self): 41 | ''' 42 | Tests the main landing page of the music browser 43 | by default we search with the track filter 44 | count, item and query should be empty values 45 | ''' 46 | response = self.client.get(self.view_endpoint) 47 | 48 | self.assertEqual(response.status_code , 200) 49 | 50 | self.assertEqual(response.context['count'], 0) 51 | self.assertEqual(response.context['items'], []) 52 | self.assertEqual(response.context['q'], None) 53 | self.assertEqual(response.context['filter'], 'track') 54 | self.assertContains(response, '

Please fill out the form.

') 55 | 56 | def test_track_search(self): 57 | ''' 58 | Tests the default search functionality with a popular track name 59 | results of querying with 'track' and no filter should be the same 60 | ''' 61 | q = 'stronger' 62 | 63 | with_track_response = self.generic_valid_search(q, 'track') 64 | 65 | default_search_response = self.client.get(self.view_endpoint, {'q': q}) 66 | 67 | self.assertEqual(with_track_response.context['count'], default_search_response.context['count']) 68 | self.assertEqual(with_track_response.context['items'], default_search_response.context['items']) 69 | self.assertEqual(with_track_response.context['q'], default_search_response.context['q']) 70 | self.assertEqual(with_track_response.context['filter'], default_search_response.context['filter']) 71 | 72 | message = '

Found {count} results for "{q}" in {filter}s

'.format( 73 | count=default_search_response.context['count'], 74 | q=q, 75 | filter='track' 76 | ) 77 | self.assertContains(with_track_response, message) 78 | self.assertContains(default_search_response, message) 79 | 80 | def test_filters_search(self): 81 | ''' 82 | Test the results of searching a given query in all the valid filters 83 | ''' 84 | q = 'kanye' 85 | 86 | valid_filters = ['track', 'playlist', 'artist', 'album'] 87 | for query_filter in valid_filters: 88 | self.generic_valid_search(q, query_filter) 89 | 90 | def test_invalid_filter(self): 91 | ''' 92 | Tests the results when user manually enters an invalid filter parameter in url 93 | should return empty results 94 | ''' 95 | q = 'kanye' 96 | query_filter = 'song' 97 | response = self.client.get(self.view_endpoint, {'q': q , 'filter': query_filter}) 98 | 99 | self.assertEqual(response.context['count'], 0) 100 | self.assertEqual(response.context['items'], []) 101 | self.assertEqual(response.context['q'], q) 102 | self.assertEqual(response.context['filter'], query_filter) 103 | message = '

Found {count} results for "{q}" in {filter}s

'.format( 104 | count=response.context['count'], 105 | q=q, 106 | filter=query_filter 107 | ) 108 | self.assertContains(response, message) 109 | 110 | def test_null_results(self): 111 | ''' 112 | Tests the results of a nonsensical query 113 | should be empty results 114 | ''' 115 | q = 'adfadfadsafsa' 116 | response = self.client.get(self.view_endpoint, {'q': q}) 117 | 118 | self.assertEqual(response.context['count'], 0) 119 | self.assertEqual(response.context['items'], []) 120 | self.assertEqual(response.context['q'], q) 121 | self.assertEqual(response.context['filter'], 'track') 122 | message = '

Found {count} results for "{q}" in {filter}s

'.format( 123 | count=response.context['count'], 124 | q=q, 125 | filter='track' 126 | ) 127 | self.assertContains(response, message) 128 | -------------------------------------------------------------------------------- /spotify_proxy/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | url(r'^$', views.index_view, name='index') 7 | ] -------------------------------------------------------------------------------- /spotify_proxy/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | from .api_wrapper import get_track_list 3 | 4 | 5 | def index_view(request): 6 | ''' 7 | Index view renders a search bar with a list of results 8 | from the spotify_wrapper. landing page and subsequent searches 9 | end up here. 10 | 11 | if no query_filter is provided in the request we query with the default track filter 12 | validations of the query are handled by the api_wrapper instead of the view. 13 | ''' 14 | search_query = request.GET.get('q') 15 | query_filter = request.GET.get('filter') 16 | if not query_filter: 17 | query_filter = 'track' 18 | 19 | count, items = get_track_list(search_query, query_filter) 20 | context = { 21 | 'count' : count, 22 | 'items': items, 23 | 'q': search_query, 24 | 'filter' : query_filter 25 | } 26 | return render(request, 'spotify_proxy/index.html', context) --------------------------------------------------------------------------------