├── docker ├── prod │ ├── uwsgi.service │ ├── uwsgi.ini │ └── consul-template.conf ├── pg_custom │ ├── create_extensions.sql │ └── Dockerfile ├── nginx_custom │ ├── Dockerfile │ └── nginx.conf ├── config │ └── MBSpotify │ │ └── config.prod.json ├── push.sh ├── docker-compose.dev.yml ├── docker-compose.test.yml ├── git2consul │ └── config.json ├── common.yml ├── docker-compose.consul.yml ├── Dockerfile.dev └── Dockerfile.test ├── pytest.ini ├── sql ├── drop_db.sql ├── setup.sh ├── create_db.sql └── create_tables.sql ├── requirements.txt ├── server.py ├── mbspotify ├── test_config.py ├── utils_test.py ├── utils.py ├── decorators.py ├── loggers.py ├── __init__.py ├── db.py ├── views.py └── views_test.py ├── .gitignore ├── .dockerignore ├── default_config.py ├── config.py.example ├── consul_config.py.ctmpl ├── README.md ├── Dockerfile ├── userscript └── mb_spotify.user.js ├── manage.py ├── licenses └── COPYING-PublicDomain └── LICENSE /docker/prod/uwsgi.service: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | exec uwsgi /etc/uwsgi/uwsgi.ini 4 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = mbspotify 3 | addopts = --cov=mbspotify 4 | -------------------------------------------------------------------------------- /docker/pg_custom/create_extensions.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 2 | -------------------------------------------------------------------------------- /docker/nginx_custom/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.10.1 2 | 3 | COPY nginx.conf /etc/nginx/nginx.conf 4 | -------------------------------------------------------------------------------- /docker/pg_custom/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:9.5.4 2 | 3 | ADD create_extensions.sql /docker-entrypoint-initdb.d/ 4 | -------------------------------------------------------------------------------- /sql/drop_db.sql: -------------------------------------------------------------------------------- 1 | \set ON_ERROR_STOP 1 2 | 3 | DROP DATABASE IF EXISTS mbspotify; 4 | DROP USER IF EXISTS mbspotify; 5 | -------------------------------------------------------------------------------- /docker/config/MBSpotify/config.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "threshold": "4", 3 | "access_keys": "[\"example1\", \"example2\"]", 4 | "sentry_dsn_private": "" 5 | } 6 | -------------------------------------------------------------------------------- /docker/prod/uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | master = true 3 | socket = 0.0.0.0:13033 4 | module = mbspotify 5 | callable = create_app() 6 | chdir = /code/ 7 | processes = 20 8 | -------------------------------------------------------------------------------- /docker/prod/consul-template.conf: -------------------------------------------------------------------------------- 1 | template { 2 | source = "/code/consul_config.py.ctmpl" 3 | destination = "/code/consul_config.py" 4 | command = "sv hup uwsgi" 5 | } 6 | -------------------------------------------------------------------------------- /sql/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 4 | 5 | # Create the tables 6 | psql -h db -U mbspotify mbspotify < "$DIR/create_tables.sql" 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | git+https://github.com/metabrainz/python-brainzutils.git@v1.8.0 2 | Flask-Testing==0.7.1 3 | MarkupSafe==0.23 4 | psycopg2==2.7.3.2 5 | click==6.6 6 | pytest==4.3.0 7 | pytest-cov==2.6.1 8 | -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from mbspotify import create_app 3 | 4 | application = create_app() 5 | 6 | if __name__ == "__main__": 7 | application.run(debug=True, host="0.0.0.0", port=8080) 8 | -------------------------------------------------------------------------------- /mbspotify/test_config.py: -------------------------------------------------------------------------------- 1 | DEBUG = False 2 | TESTING = True 3 | 4 | # Logging 5 | LOG_FILE = None 6 | LOG_SENTRY = None 7 | 8 | # Number of votes required to delete a mapping 9 | THRESHOLD = 2 10 | 11 | ACCESS_KEYS = [ 12 | "test", 13 | ] 14 | -------------------------------------------------------------------------------- /sql/create_db.sql: -------------------------------------------------------------------------------- 1 | \set ON_ERROR_STOP 1 2 | 3 | -- Create the user and the database. Must run as user postgres. 4 | 5 | CREATE USER mbspotify NOCREATEDB NOCREATEUSER; 6 | CREATE DATABASE mbspotify WITH OWNER = mbspotify TEMPLATE template0 ENCODING = 'UNICODE'; 7 | -------------------------------------------------------------------------------- /mbspotify/utils_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from mbspotify import utils 3 | 4 | 5 | class UtilsTestCase(unittest.TestCase): 6 | 7 | def test_validate_uuid(self): 8 | self.assertTrue(utils.validate_uuid("123e4567-e89b-12d3-a456-426655440000")) 9 | self.assertFalse(utils.validate_uuid("not-a-uuid")) 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # Logs 6 | pip-log.txt 7 | pip-delete-this-directory.txt 8 | 9 | # Test results 10 | /cover/ 11 | .tox/ 12 | .cache 13 | .coverage 14 | 15 | # Application data 16 | /data 17 | 18 | # Configuration 19 | /consul_config.py 20 | /custom_config.py 21 | -------------------------------------------------------------------------------- /docker/push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Build image from the currently checked out version of CritiqueBrainz 4 | # and push it to the Docker Hub, with an optional tag (by default "beta"). 5 | # 6 | # Usage: 7 | # $ ./push.sh [env] [tag] 8 | 9 | cd "$(dirname "${BASH_SOURCE[0]}")/../" 10 | 11 | docker build -t metabrainz/mbspotify . 12 | docker push metabrainz/mbspotify 13 | -------------------------------------------------------------------------------- /mbspotify/utils.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | 4 | def validate_uuid(string, version=4): 5 | """Validates UUID of a specified version (default version is 4). 6 | 7 | Returns: 8 | True if UUID is valid. 9 | False otherwise. 10 | """ 11 | try: 12 | _ = uuid.UUID(string, version=version) 13 | except ValueError: 14 | return False 15 | return True 16 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | 7 | # Application data 8 | /data 9 | 10 | # Virtual environment 11 | env 12 | venv 13 | /build 14 | 15 | # Logs 16 | *.log 17 | pip-log.txt 18 | pip-delete-this-directory.txt 19 | 20 | # Configuration generated from Consul's KV storage 21 | /consul_config.py 22 | 23 | # Test results 24 | htmlcov 25 | .coverage 26 | -------------------------------------------------------------------------------- /default_config.py: -------------------------------------------------------------------------------- 1 | # DEFAULT CONFIGURATION 2 | 3 | # PostgreSQL connection string 4 | # See http://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING 5 | PG_INFO = { 6 | "host": "db", 7 | "port": 5432, 8 | "user": "mbspotify", 9 | "password": "mbspotify", 10 | "database": "mbspotify", 11 | } 12 | 13 | # Number of votes required to delete a mapping 14 | THRESHOLD = 4 15 | 16 | ACCESS_KEYS = [] 17 | -------------------------------------------------------------------------------- /docker/docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | 4 | db: 5 | extends: 6 | file: ./common.yml 7 | service: db 8 | volumes: 9 | - ../data/pgdata:/var/lib/postgresql/data/pgdata 10 | environment: 11 | PGDATA: /var/lib/postgresql/data/pgdata 12 | 13 | web: 14 | build: 15 | context: .. 16 | dockerfile: ./docker/Dockerfile.dev 17 | ports: 18 | - "80:8080" 19 | depends_on: 20 | - db 21 | -------------------------------------------------------------------------------- /docker/docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | # Docker Compose file for testing 2 | version: "2" 3 | services: 4 | 5 | db: 6 | build: 7 | context: ./pg_custom/ 8 | dockerfile: Dockerfile 9 | environment: 10 | POSTGRES_USER: mbspotify 11 | POSTGRES_PASSWORD: mbspotify 12 | POSTGRES_DB: mbspotify 13 | 14 | web_test: 15 | build: 16 | context: .. 17 | dockerfile: ./docker/Dockerfile.test 18 | depends_on: 19 | - db 20 | -------------------------------------------------------------------------------- /docker/git2consul/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "repos" : [ 4 | { 5 | "name": "docker-server-configs", 6 | "url": "/tmp/mbspotify.git", 7 | "branches": ["docker"], 8 | "source_root": "docker/config/", 9 | "expand_keys": true, 10 | "hooks": [ 11 | { 12 | "interval": "1", 13 | "type": "polling" 14 | } 15 | ], 16 | "include_branch_name": false 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /docker/common.yml: -------------------------------------------------------------------------------- 1 | # Docker Compose file with common services 2 | # See https://docs.docker.com/compose/extends/#extending-services 3 | version: "2" 4 | services: 5 | 6 | db: 7 | build: 8 | context: ./pg_custom/ 9 | dockerfile: Dockerfile 10 | environment: 11 | POSTGRES_USER: mbspotify 12 | POSTGRES_PASSWORD: mbspotify 13 | POSTGRES_DB: mbspotify 14 | 15 | nginx: 16 | build: 17 | context: ./nginx_custom/ 18 | dockerfile: Dockerfile 19 | -------------------------------------------------------------------------------- /config.py.example: -------------------------------------------------------------------------------- 1 | # CUSTOM CONFIGURATION 2 | 3 | # List of keys (strings) that is used to verify incoming requests 4 | ACCESS_KEYS = [ 5 | #"CHANGE_ME", 6 | ] 7 | 8 | # Number of votes required to delete a mapping 9 | THRESHOLD = 4 10 | 11 | 12 | # LOGGING 13 | 14 | #LOG_FILE = { 15 | # "filename": "./logs/log.txt", 16 | # "max_bytes": 512 * 1024, # optional 17 | # "backup_count": 100, # optional 18 | #} 19 | 20 | #LOG_SENTRY = { 21 | # "dsn": "YOUR_SENTRY_DSN", 22 | # "level": "WARNING", # optional 23 | #} 24 | -------------------------------------------------------------------------------- /docker/nginx_custom/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes 4; 2 | 3 | events { worker_connections 1024; } 4 | 5 | http { 6 | access_log /dev/stdout; 7 | error_log /dev/stdout info; 8 | 9 | server { 10 | listen 64180; 11 | 12 | location / { 13 | include uwsgi_params; 14 | uwsgi_pass mbspotify:13033; 15 | proxy_set_header Upgrade $http_upgrade; 16 | proxy_set_header Connection 'upgrade'; 17 | proxy_set_header Host $host; 18 | proxy_set_header X-Real-IP $remote_addr; 19 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 20 | proxy_cache_bypass $http_upgrade; 21 | } 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /docker/docker-compose.consul.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | 4 | consulagent: 5 | command: -server -bootstrap 6 | image: progrium/consul 7 | ports: 8 | - "8500:8500" 9 | 10 | git2consul: 11 | command: --endpoint consulagent --port 8500 --config-file /etc/git2consul.d/config.json 12 | image: cimpress/git2consul 13 | restart: always 14 | links: 15 | - consulagent 16 | volumes: 17 | - ../:/tmp/mbspotify.git 18 | - ./git2consul:/etc/git2consul.d 19 | 20 | mbspotify: 21 | build: 22 | context: .. 23 | dockerfile: ./Dockerfile 24 | environment: 25 | DEPLOY_ENV: prod 26 | CONSUL_HOST: git2consul 27 | volumes: 28 | - ../:/code 29 | - ../data/app:/data 30 | 31 | mbspotify-nginx: 32 | build: 33 | context: ./nginx_custom 34 | dockerfile: ./Dockerfile 35 | ports: 36 | - "80:80" 37 | 38 | 39 | -------------------------------------------------------------------------------- /consul_config.py.ctmpl: -------------------------------------------------------------------------------- 1 | {{- define "KEY" -}} 2 | {{ key (printf "docker-server-configs/MBSpotify/config.%s.json/%s" (env "DEPLOY_ENV") .) }} 3 | {{- end -}} 4 | {{- define "KEY_ARRAY" -}} 5 | {{- range $index, $element := (key (printf "docker-server-configs/MBSpotify/config.%s.json/%s" (env "DEPLOY_ENV") .) | parseJSON) -}} 6 | "{{.}}", 7 | {{- end -}} 8 | {{- end -}} 9 | 10 | {{if service "pgbouncer-master"}} 11 | {{with index (service "pgbouncer-master") 0}} 12 | PG_INFO = { 13 | "host": "{{.Address}}", 14 | "port": {{.Port}}, 15 | "user": "mbspotify", 16 | "password": "mbspotify", 17 | "database": "mbspotify_db", 18 | } 19 | {{end}} 20 | {{end}} 21 | 22 | # Number of votes required to delete a mapping 23 | THRESHOLD = {{template "KEY" "threshold"}} 24 | 25 | ACCESS_KEYS = [ 26 | {{template "KEY_ARRAY" "access_keys"}} 27 | ] 28 | 29 | LOG_SENTRY = { 30 | 'dsn': '''{{template "KEY" "sentry_dsn_private"}}''', 31 | } 32 | -------------------------------------------------------------------------------- /sql/create_tables.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | CREATE TABLE IF NOT EXISTS mapping ( 4 | id SERIAL, 5 | mbid UUID, 6 | spotify_uri TEXT, 7 | cb_user UUID, 8 | is_deleted BOOLEAN 9 | ); 10 | 11 | CREATE TABLE IF NOT EXISTS mapping_vote ( 12 | id SERIAL, 13 | mapping INTEGER NOT NULL, -- references mapping 14 | cb_user UUID 15 | ); 16 | 17 | ALTER TABLE mapping ADD CONSTRAINT mapping_pkey PRIMARY KEY (id); 18 | ALTER TABLE mapping_vote ADD CONSTRAINT mapping_vote_pkey PRIMARY KEY (id); 19 | 20 | CREATE INDEX mapping_ndx_mbid ON mapping (mbid); 21 | CREATE INDEX mapping_ndx_spotify_uri ON mapping (spotify_uri); 22 | CREATE INDEX mapping_ndx_mbid_spotify_uri ON mapping (mbid, spotify_uri); 23 | 24 | CREATE INDEX mapping_vote_ndx_mapping ON mapping_vote (mapping); 25 | CREATE INDEX cb_user_ndx_mapping ON mapping_vote (cb_user); 26 | CREATE UNIQUE INDEX mapping_vote_ndx_mapping_cb_user ON mapping_vote (mapping, cb_user); -- user can vote only once 27 | 28 | COMMIT; 29 | -------------------------------------------------------------------------------- /mbspotify/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from flask import current_app, request 3 | from werkzeug.exceptions import BadRequest 4 | 5 | 6 | def key_required(func): 7 | @wraps(func) 8 | def wrapper(*args, **kwds): 9 | if request.args.get('key') not in current_app.config['ACCESS_KEYS']: 10 | raise BadRequest("You need to provide a key.") 11 | return func(*args, **kwds) 12 | 13 | return wrapper 14 | 15 | 16 | def jsonp(func): 17 | """Wraps JSONified output for JSONP requests.""" 18 | # Based on snippet from http://flask.pocoo.org/snippets/79/. 19 | @wraps(func) 20 | def decorated_function(*args, **kwargs): 21 | callback = request.args.get('callback', False) 22 | if callback: 23 | data = str(func(*args, **kwargs).data) 24 | content = str(callback) + '(' + data + ')' 25 | mimetype = 'application/javascript' 26 | return current_app.response_class(content, mimetype=mimetype) 27 | else: 28 | return func(*args, **kwargs) 29 | return decorated_function 30 | -------------------------------------------------------------------------------- /docker/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM metabrainz/python:3.7 2 | 3 | # PostgreSQL client 4 | RUN apt-key adv --keyserver ha.pool.sks-keyservers.net --recv-keys B97B0AFCAA1A47F044F244A07FCC7D46ACCC4CF8 5 | ENV PG_MAJOR 9.5 6 | RUN echo 'deb http://apt.postgresql.org/pub/repos/apt/ jessie-pgdg main' $PG_MAJOR > /etc/apt/sources.list.d/pgdg.list 7 | RUN apt-get update \ 8 | && apt-get install -y postgresql-client-$PG_MAJOR \ 9 | && rm -rf /var/lib/apt/lists/* 10 | # Specifying password so that client doesn't ask scripts for it... 11 | ENV PGPASSWORD "mbspotify" 12 | 13 | RUN mkdir /code 14 | WORKDIR /code 15 | 16 | # Python dependencies 17 | RUN apt-get update \ 18 | && apt-get install -y --no-install-recommends \ 19 | build-essential \ 20 | git \ 21 | libpq-dev \ 22 | libffi-dev \ 23 | libssl-dev \ 24 | libxml2-dev \ 25 | libxslt1-dev 26 | COPY requirements.txt /code/ 27 | RUN pip install -r requirements.txt 28 | 29 | COPY . /code/ 30 | 31 | CMD python3 server.py 32 | -------------------------------------------------------------------------------- /mbspotify/loggers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging.handlers import RotatingFileHandler 3 | from raven.contrib.flask import Sentry 4 | 5 | 6 | def init_loggers(app): 7 | if 'LOG_FILE_ENABLED' in app.config and app.config['LOG_FILE_ENABLED']: 8 | _add_file_handler(app, app.config['LOG_FILE']) 9 | if 'LOG_SENTRY_ENABLED' in app.config and app.config['LOG_SENTRY_ENABLED']: 10 | _add_sentry(app, logging.WARNING) 11 | 12 | 13 | def _add_file_handler(app, filename, max_bytes=512 * 1024, backup_count=100): 14 | """Adds file logging.""" 15 | file_handler = RotatingFileHandler(filename, maxBytes=max_bytes, 16 | backupCount=backup_count) 17 | file_handler.setFormatter(logging.Formatter( 18 | '%(asctime)s %(levelname)s: %(message)s ' 19 | '[in %(pathname)s:%(lineno)d]' 20 | )) 21 | app.logger.addHandler(file_handler) 22 | 23 | 24 | def _add_sentry(app, level=logging.NOTSET): 25 | """Adds Sentry logging. 26 | Sentry is a realtime event logging and aggregation platform. Additional 27 | information about it is available at https://sentry.readthedocs.org/. 28 | We use Raven as a client for Sentry. More info about Raven is available at 29 | https://raven.readthedocs.org/. 30 | """ 31 | Sentry(app, logging=True, level=level) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mbspotify 2 | 3 | This project aims to provide mapping between [MusicBrainz Identifiers](https://musicbrainz.org/doc/MusicBrainz_Identifier) 4 | and [Spotify URIs](https://developer.spotify.com/web-api/user-guide/#spotify-uris-and-ids). 5 | It also makes MusicBrainz playable by embedding [Spotify Play Buttons](https://developer.spotify.com/technologies/widgets/spotify-play-button/) 6 | into the MusicBrainz pages. 7 | 8 | 9 | ## Development 10 | 11 | The easiest way to set up a development environment is to use [Docker](https://www.docker.com/): 12 | 13 | $ docker-compose -f docker/docker-compose.dev.yml up --build 14 | 15 | After starting it for the first time, initialize the database: 16 | 17 | $ docker-compose -f docker/docker-compose.dev.yml run web python manage.py init_db 18 | 19 | After containers are created and running, you can access the application at 20 | http://localhost:80/. 21 | 22 | ### Testing 23 | 24 | To run all tests use: 25 | 26 | $ docker-compose -f docker/docker-compose.test.yml up -d --build 27 | $ docker logs -f mbspotify_web_test_1 28 | 29 | ## Community 30 | 31 | If you want to discuss something, go to *#metabrainz* IRC channel on 32 | irc.libera.chat. More info about available methods of getting in touch with 33 | community is available at https://wiki.musicbrainz.org/Communication. 34 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM metabrainz/python:3.7 2 | 3 | # PostgreSQL client 4 | RUN apt-key adv --keyserver ha.pool.sks-keyservers.net --recv-keys B97B0AFCAA1A47F044F244A07FCC7D46ACCC4CF8 5 | ENV PG_MAJOR 9.5 6 | RUN echo 'deb http://apt.postgresql.org/pub/repos/apt/ jessie-pgdg main' $PG_MAJOR > /etc/apt/sources.list.d/pgdg.list 7 | RUN apt-get update \ 8 | && apt-get install -y postgresql-client-$PG_MAJOR \ 9 | && rm -rf /var/lib/apt/lists/* 10 | # Specifying password so that client doesn't ask scripts for it... 11 | ENV PGPASSWORD "mbspotify" 12 | 13 | RUN mkdir /code 14 | WORKDIR /code 15 | 16 | # Python dependencies 17 | RUN apt-get update \ 18 | && apt-get install -y --no-install-recommends \ 19 | build-essential \ 20 | git \ 21 | libpq-dev \ 22 | libffi-dev \ 23 | libssl-dev \ 24 | libxml2-dev \ 25 | libxslt1-dev 26 | COPY requirements.txt /code/ 27 | RUN pip install -r requirements.txt 28 | 29 | RUN pip install uWSGI==2.0.13.1 30 | 31 | COPY . /code/ 32 | 33 | ############ 34 | # Services # 35 | ############ 36 | 37 | # Consul Template service is already set up with the base image. 38 | # Just need to copy the configuration. 39 | COPY ./docker/prod/consul-template.conf /etc/consul-template.conf 40 | 41 | COPY ./docker/prod/uwsgi.service /etc/service/uwsgi/run 42 | RUN chmod 755 /etc/service/uwsgi/run 43 | COPY ./docker/prod/uwsgi.ini /etc/uwsgi/uwsgi.ini 44 | 45 | EXPOSE 13033 46 | -------------------------------------------------------------------------------- /docker/Dockerfile.test: -------------------------------------------------------------------------------- 1 | FROM metabrainz/python:3.7 2 | 3 | ENV DOCKERIZE_VERSION v0.2.0 4 | RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ 5 | && tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz 6 | 7 | # PostgreSQL client 8 | RUN apt-key adv --keyserver ha.pool.sks-keyservers.net --recv-keys B97B0AFCAA1A47F044F244A07FCC7D46ACCC4CF8 9 | ENV PG_MAJOR 9.5 10 | RUN echo 'deb http://apt.postgresql.org/pub/repos/apt/ jessie-pgdg main' $PG_MAJOR > /etc/apt/sources.list.d/pgdg.list 11 | RUN apt-get update \ 12 | && apt-get install -y postgresql-client-$PG_MAJOR \ 13 | && rm -rf /var/lib/apt/lists/* 14 | # Specifying password so that client doesn't ask scripts for it... 15 | ENV PGPASSWORD "mbspotify" 16 | 17 | RUN mkdir /code 18 | WORKDIR /code 19 | 20 | # Python dependencies 21 | RUN apt-get update \ 22 | && apt-get install -y --no-install-recommends \ 23 | build-essential \ 24 | git \ 25 | libpq-dev \ 26 | libffi-dev \ 27 | libssl-dev \ 28 | libxml2-dev \ 29 | libxslt1-dev 30 | COPY requirements.txt /code/ 31 | RUN pip install -r requirements.txt 32 | 33 | COPY . /code/ 34 | 35 | CMD dockerize -wait tcp://db:5432 -timeout 25s /bin/bash \ 36 | ./sql/setup.sh; \ 37 | py.test --junitxml=/data/test_report.xml \ 38 | --cov-report xml:/data/coverage.xml \ 39 | --cov-report html:/data/coverage-html 40 | -------------------------------------------------------------------------------- /mbspotify/__init__.py: -------------------------------------------------------------------------------- 1 | from brainzutils.flask import CustomFlask 2 | import os 3 | import sys 4 | from time import sleep 5 | 6 | deploy_env = os.environ.get('DEPLOY_ENV', '') 7 | CONSUL_CONFIG_FILE_RETRY_COUNT = 10 8 | 9 | def create_app(config_path=None): 10 | app = CustomFlask( 11 | import_name=__name__, 12 | use_flask_uuid=True, 13 | ) 14 | 15 | # Configuration files 16 | app.config.from_pyfile(os.path.join( 17 | os.path.dirname(os.path.realpath(__file__)), 18 | "..", "default_config.py" 19 | )) 20 | 21 | app.config.from_pyfile(os.path.join( 22 | os.path.dirname(os.path.realpath(__file__)), 23 | '..', 'custom_config.py' 24 | ), silent=True) 25 | 26 | if deploy_env: 27 | config_file = os.path.join( 28 | os.path.dirname(os.path.realpath(__file__)), 29 | '..', 'consul_config.py' 30 | ) 31 | print("Checking if consul template generated config file exists: {}".format(config_file)) 32 | for _ in range(CONSUL_CONFIG_FILE_RETRY_COUNT): 33 | if not os.path.exists(config_file): 34 | sleep(1) 35 | 36 | if not os.path.exists(config_file): 37 | print("No configuration file generated yet. Retried {} times, exiting.".format(CONSUL_CONFIG_FILE_RETRY_COUNT)) 38 | sys.exit(-1) 39 | 40 | print("Loading consul config file {}".format(config_file)) 41 | app.config.from_pyfile(config_file, silent=True) 42 | 43 | if config_path: 44 | app.config.from_pyfile(config_path) 45 | app.init_loggers( 46 | file_config=app.config.get("LOG_FILE"), 47 | sentry_config=app.config.get("LOG_SENTRY"), 48 | ) 49 | 50 | app.logger.info("Check Access Keys: {}".format(str(app.config['ACCESS_KEYS']))) 51 | # Blueprints 52 | from mbspotify.views import main_bp 53 | app.register_blueprint(main_bp) 54 | 55 | return app 56 | -------------------------------------------------------------------------------- /userscript/mb_spotify.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name MusicBrainz Spotify Integration 3 | // @description Shows Spotify player on release group pages 4 | // @version 1 5 | // @author - 6 | // @namespace https://critiquebrainz.org 7 | // 8 | // @include *://musicbrainz.org/release-group/* 9 | // @include *://beta.musicbrainz.org/release-group/* 10 | // 11 | // ==/UserScript== 12 | 13 | 14 | function injected() { 15 | 16 | function insertHTML(html) { 17 | var cover_art = $(".cover-art"); 18 | if (cover_art.length > 0) { // if there's a cover art on the page 19 | cover_art.append(html); 20 | } else { 21 | $("#sidebar").prepend(html); 22 | } 23 | } 24 | 25 | var spotify_html_begin = ''; 27 | 28 | var no_match_html_begin = '
This release-group has not been matched to Spotify. Please match this release group.
'; 30 | 31 | var error_html = '
An error has occurred looking up the release-group match.
'; 32 | 33 | var mbid = window.location.pathname.split("/")[2]; 34 | $.ajax({ 35 | url: "https://mbspotify.musicbrainz.org/mapping-jsonp/" + mbid, 36 | data: JSON.stringify({"mbids": [mbid]}), 37 | dataType: "jsonp", 38 | jsonpCallback: "jsonCallback", 39 | contentType: "application/json; charset=utf-8", 40 | success: function (json) { 41 | if (json[mbid]) { 42 | insertHTML(spotify_html_begin + json[mbid] + spotify_html_end); 43 | } else { 44 | insertHTML(no_match_html_begin + mbid + no_match_html_end); 45 | } 46 | }, 47 | error: function () { 48 | insertHTML(error_html); 49 | } 50 | }); 51 | 52 | } 53 | 54 | var script = document.createElement("script"); 55 | script.appendChild(document.createTextNode("(" + injected + ")();")); 56 | document.body.appendChild(script); 57 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | import os 3 | import click 4 | import subprocess 5 | import mbspotify 6 | import mbspotify.db 7 | 8 | SQL_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'sql') 9 | app = mbspotify.create_app() 10 | 11 | 12 | def _run_psql(script, 13 | host=app.config["PG_INFO"]["host"], 14 | port=app.config["PG_INFO"]["port"], 15 | user=app.config["PG_INFO"]["user"], 16 | database=app.config["PG_INFO"]["database"]): 17 | script = os.path.join(SQL_DIR, script) 18 | command = ['psql', '-h', host, 19 | '-p', str(port), 20 | '-U', user, 21 | '-d', database, 22 | '-f', script] 23 | exit_code = subprocess.call(command) 24 | return exit_code 25 | 26 | 27 | cli = click.Group() 28 | 29 | 30 | @cli.command() 31 | @click.option("--create-db", is_flag=True) 32 | @click.option("--force", "-f", is_flag=True, 33 | help="Drop existing database and user.") 34 | @click.argument("archive", type=click.Path(exists=True), required=False) 35 | def init_db(archive, force, create_db): 36 | """Initializes the database and imports data if needed. 37 | 38 | The archive must be a .tar.xz produced by the dump command. 39 | """ 40 | if create_db: 41 | 42 | if force: 43 | exit_code = _run_psql('drop_db.sql') 44 | if exit_code != 0: 45 | raise Exception('Failed to drop existing database and user! ' 46 | 'Exit code: %i' % exit_code) 47 | 48 | print('Creating user and a database...') 49 | exit_code = _run_psql('create_db.sql') 50 | if exit_code != 0: 51 | raise Exception('Failed to create new database and user! ' 52 | 'Exit code: %i' % exit_code) 53 | 54 | print('Creating tables...') 55 | _run_psql('create_tables.sql', user='mbspotify', database='mbspotify') 56 | 57 | if archive: 58 | print('Importing data...') 59 | mbspotify.db.import_db_dump(archive) 60 | else: 61 | print('Skipping data importing.') 62 | 63 | print("Done!") 64 | 65 | 66 | @cli.command() 67 | @click.option("--location", "-l", default=os.path.join("/", "data", "export"), 68 | show_default=True, 69 | help="Directory where dumps need to be created") 70 | @click.pass_context 71 | def dump(ctx, location): 72 | """Exports a full database dump to the specified location. 73 | """ 74 | print("Creating full database dump...") 75 | path = mbspotify.db.export_db_dump(location) 76 | print("Done! Created:", path) 77 | 78 | 79 | if __name__ == '__main__': 80 | cli() 81 | -------------------------------------------------------------------------------- /mbspotify/db.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | from datetime import datetime 3 | from flask import current_app 4 | import io 5 | import errno 6 | import os 7 | import psycopg2 8 | import subprocess 9 | import tarfile 10 | 11 | 12 | DUMP_LICENSE_FILE_PATH = os.path.join( 13 | os.path.dirname(os.path.realpath(__file__)), 14 | "..", "licenses", "COPYING-PublicDomain" 15 | ) 16 | 17 | _TABLES = { 18 | "mapping": ( 19 | "id", 20 | "mbid", 21 | "spotify_uri", 22 | "cb_user", 23 | "is_deleted", 24 | ), 25 | "mapping_vote": ( 26 | "id", 27 | "mapping", 28 | "cb_user", 29 | ), 30 | } 31 | 32 | 33 | def create_path(path): 34 | """Creates a directory structure if it doesn't exist yet.""" 35 | try: 36 | os.makedirs(path) 37 | except OSError as exception: 38 | if exception.errno != errno.EEXIST: 39 | raise Exception("Failed to create directory structure %s. " 40 | "Error: %s" % (path, exception)) 41 | 42 | 43 | def add_tarfile(tar, name, output): 44 | info = tarfile.TarInfo(name=name) 45 | info.size = output.tell() 46 | output.seek(0) 47 | tar.addfile(info, fileobj=output) 48 | 49 | 50 | def export_db_dump(location): 51 | """Exports a full database dump to the specified location. 52 | Args: 53 | location: Directory where the archive will be created. 54 | Returns: 55 | Path to the created archive. 56 | """ 57 | conn = psycopg2.connect(**current_app.config["PG_INFO"]) 58 | create_path(location) 59 | time_now = datetime.today() 60 | 61 | archive_name = "mbspotify-dump-%s" % time_now.strftime("%Y%m%d-%H%M%S") 62 | archive_path = os.path.join(location, archive_name + ".tar.xz") 63 | 64 | with open(archive_path, "w") as archive: 65 | pxz_command = ["pxz", "--compress"] 66 | pxz = subprocess.Popen(pxz_command, 67 | stdin=subprocess.PIPE, 68 | stdout=archive) 69 | 70 | with tarfile.open(fileobj=pxz.stdin, mode="w|") as tar: 71 | output = io.StringIO(time_now.isoformat(" ")) 72 | add_tarfile(tar, os.path.join(archive_name, "TIMESTAMP"), output) 73 | 74 | tar.add(DUMP_LICENSE_FILE_PATH, 75 | arcname=os.path.join(archive_name, "COPYING")) 76 | 77 | cur = conn.cursor() 78 | for table_name in _TABLES: 79 | output = io.StringIO() 80 | print(" - Copying table %s..." % table_name) 81 | cur.copy_to(output, "(SELECT %s FROM %s)" % 82 | (", ".join(_TABLES[table_name]), table_name)) 83 | add_tarfile(tar, 84 | os.path.join(archive_name, "dump", table_name), 85 | output) 86 | 87 | pxz.stdin.close() 88 | pxz.wait() 89 | 90 | conn.close() 91 | return archive_path 92 | 93 | 94 | def import_db_dump(archive_path): 95 | """Imports data from a .tar.xz archive into the database.""" 96 | pxz_command = ["pxz", "--decompress", "--stdout", archive_path] 97 | pxz = subprocess.Popen(pxz_command, stdout=subprocess.PIPE) 98 | 99 | conn = psycopg2.connect(**current_app.config["PG_INFO"]) 100 | try: 101 | cur = conn.cursor() 102 | 103 | with tarfile.open(fileobj=pxz.stdout, mode="r|") as tar: 104 | for member in tar: 105 | file_name = member.name.split("/")[-1] 106 | 107 | if file_name in _TABLES: 108 | print(" - Importing data into %s table..." % file_name) 109 | cur.copy_from(tar.extractfile(member), '"%s"' % file_name, 110 | columns=_TABLES[file_name]) 111 | conn.commit() 112 | finally: 113 | conn.close() 114 | 115 | pxz.stdout.close() 116 | pxz.wait() 117 | -------------------------------------------------------------------------------- /licenses/COPYING-PublicDomain: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | Statement of Purpose 6 | 7 | The laws of most jurisdictions throughout the world automatically confer 8 | exclusive Copyright and Related Rights (defined below) upon the creator 9 | and subsequent owner(s) (each and all, an "owner") of an original work 10 | of authorship and/or a database (each, a "Work"). 11 | 12 | Certain owners wish to permanently relinquish those rights to a Work 13 | for the purpose of contributing to a commons of creative, cultural and 14 | scientific works ("Commons") that the public can reliably and without 15 | fear of later claims of infringement build upon, modify, incorporate in 16 | other works, reuse and redistribute as freely as possible in any form 17 | whatsoever and for any purposes, including without limitation commercial 18 | purposes. These owners may contribute to the Commons to promote the 19 | ideal of a free culture and the further production of creative, cultural 20 | and scientific works, or to gain reputation or greater distribution for 21 | their Work in part through the use and efforts of others. 22 | 23 | For these and/or other purposes and motivations, and without any 24 | expectation of additional consideration or compensation, the person 25 | associating CC0 with a Work (the "Affirmer"), to the extent that he or 26 | she is an owner of Copyright and Related Rights in the Work, voluntarily 27 | elects to apply CC0 to the Work and publicly distribute the Work under 28 | its terms, with knowledge of his or her Copyright and Related Rights in 29 | the Work and the meaning and intended legal effect of CC0 on those rights. 30 | 31 | 1. Copyright and Related Rights. A Work made available under CC0 may 32 | be protected by copyright and related or neighboring rights ("Copyright 33 | and Related Rights"). Copyright and Related Rights include, but are not 34 | limited to, the following: 35 | 36 | the right to reproduce, adapt, distribute, perform, display, communicate, 37 | and translate a Work; moral rights retained by the original author(s) 38 | and/or performer(s); publicity and privacy rights pertaining to a person's 39 | image or likeness depicted in a Work; rights protecting against unfair 40 | competition in regards to a Work, subject to the limitations in paragraph 41 | 4(a), below; rights protecting the extraction, dissemination, use and 42 | reuse of data in a Work; database rights (such as those arising under 43 | Directive 96/9/EC of the European Parliament and of the Council of 11 44 | March 1996 on the legal protection of databases, and under any national 45 | implementation thereof, including any amended or successor version of 46 | such directive); and other similar, equivalent or corresponding rights 47 | throughout the world based on applicable law or treaty, and any national 48 | implementations thereof. 2. Waiver. To the greatest extent permitted by, 49 | but not in contravention of, applicable law, Affirmer hereby overtly, 50 | fully, permanently, irrevocably and unconditionally waives, abandons, 51 | and surrenders all of Affirmer's Copyright and Related Rights and 52 | associated claims and causes of action, whether now known or unknown 53 | (including existing as well as future claims and causes of action), in 54 | the Work (i) in all territories worldwide, (ii) for the maximum duration 55 | provided by applicable law or treaty (including future time extensions), 56 | (iii) in any current or future medium and for any number of copies, and 57 | (iv) for any purpose whatsoever, including without limitation commercial, 58 | advertising or promotional purposes (the "Waiver"). Affirmer makes the 59 | Waiver for the benefit of each member of the public at large and to the 60 | detriment of Affirmer's heirs and successors, fully intending that such 61 | Waiver shall not be subject to revocation, rescission, cancellation, 62 | termination, or any other legal or equitable action to disrupt the quiet 63 | enjoyment of the Work by the public as contemplated by Affirmer's express 64 | Statement of Purpose. 65 | 66 | 3. Public License Fallback. Should any part of the Waiver for any reason 67 | be judged legally invalid or ineffective under applicable law, then the 68 | Waiver shall be preserved to the maximum extent permitted taking into 69 | account Affirmer's express Statement of Purpose. In addition, to the 70 | extent the Waiver is so judged Affirmer hereby grants to each affected 71 | person a royalty-free, non transferable, non sublicensable, non exclusive, 72 | irrevocable and unconditional license to exercise Affirmer's Copyright 73 | and Related Rights in the Work (i) in all territories worldwide, (ii) 74 | for the maximum duration provided by applicable law or treaty (including 75 | future time extensions), (iii) in any current or future medium and for 76 | any number of copies, and (iv) for any purpose whatsoever, including 77 | without limitation commercial, advertising or promotional purposes (the 78 | "License"). The License shall be deemed effective as of the date CC0 was 79 | applied by Affirmer to the Work. Should any part of the License for any 80 | reason be judged legally invalid or ineffective under applicable law, 81 | such partial invalidity or ineffectiveness shall not invalidate the 82 | remainder of the License, and in such case Affirmer hereby affirms that 83 | he or she will not (i) exercise any of his or her remaining Copyright 84 | and Related Rights in the Work or (ii) assert any associated claims and 85 | causes of action with respect to the Work, in either case contrary to 86 | Affirmer's express Statement of Purpose. 87 | 88 | 4. Limitations and Disclaimers. 89 | 90 | No trademark or patent rights held by Affirmer are waived, abandoned, 91 | surrendered, licensed or otherwise affected by this document. Affirmer 92 | offers the Work as-is and makes no representations or warranties of any 93 | kind concerning the Work, express, implied, statutory or otherwise, 94 | including without limitation warranties of title, merchantability, 95 | fitness for a particular purpose, non infringement, or the absence of 96 | latent or other defects, accuracy, or the present or absence of errors, 97 | whether or not discoverable, all to the greatest extent permissible 98 | under applicable law. Affirmer disclaims responsibility for clearing 99 | rights of other persons that may apply to the Work or any use thereof, 100 | including without limitation any person's Copyright and Related Rights 101 | in the Work. Further, Affirmer disclaims responsibility for obtaining 102 | any necessary consents, permissions or other rights required for any 103 | use of the Work. Affirmer understands and acknowledges that Creative 104 | Commons is not a party to this document and has no duty or obligation 105 | with respect to this CC0 or use of the Work. 106 | -------------------------------------------------------------------------------- /mbspotify/views.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from flask import Blueprint, redirect, request, Response, jsonify, current_app 3 | from werkzeug.exceptions import BadRequest, ServiceUnavailable 4 | from mbspotify.decorators import key_required, jsonp 5 | from mbspotify.utils import validate_uuid 6 | import psycopg2 7 | import json 8 | 9 | 10 | main_bp = Blueprint('ws_review', __name__) 11 | 12 | 13 | @main_bp.route("/") 14 | def index(): 15 | """Redirect to project page on GitHub.""" 16 | return redirect("https://github.com/metabrainz/mbspotify", code=302) 17 | 18 | 19 | @main_bp.route("/mapping/add", methods=["POST"]) 20 | @key_required 21 | def add(): 22 | """Endpoint for adding new mappings to Spotify. 23 | 24 | Only connection to albums on Spotify is supported right now. 25 | 26 | JSON parameters: 27 | user: UUID of the user who is adding new mapping. 28 | mbid: MusicBrainz ID of an entity that is being connected. 29 | spotify_uri: Spotify URI of an album that is being connected. 30 | """ 31 | user = request.json["user"] 32 | if not validate_uuid(user): 33 | raise BadRequest("Incorrect user ID (UUID).") 34 | 35 | mbid = request.json["mbid"] 36 | if not validate_uuid(mbid): 37 | raise BadRequest("Incorrect MBID (UUID).") 38 | 39 | uri = request.json["spotify_uri"] 40 | if not uri.startswith("spotify:album:"): 41 | raise BadRequest("Incorrect Spotify URI. Only albums are supported right now.") 42 | 43 | conn = psycopg2.connect(**current_app.config["PG_INFO"]) 44 | cur = conn.cursor() 45 | 46 | try: 47 | # Checking if mapping is already created 48 | cur.execute("SELECT id FROM mapping " 49 | "WHERE is_deleted = FALSE " 50 | "AND mbid = %s " 51 | "AND spotify_uri = %s", (mbid, uri)) 52 | if not cur.rowcount: 53 | # and if it's not, adding it 54 | cur.execute("INSERT INTO mapping (mbid, spotify_uri, cb_user, is_deleted)" 55 | "VALUES (%s, %s, %s, FALSE)", 56 | (mbid, uri, user)) 57 | conn.commit() 58 | except psycopg2.IntegrityError as e: 59 | raise BadRequest(str(e)) 60 | except psycopg2.OperationalError as e: 61 | raise ServiceUnavailable(str(e)) 62 | 63 | response = Response() 64 | response.headers["Access-Control-Allow-Origin"] = "*" 65 | return response 66 | 67 | 68 | @main_bp.route("/mapping/vote", methods=["POST"]) 69 | @key_required 70 | def vote(): 71 | """Endpoint for voting against incorrect mappings. 72 | 73 | JSON parameters: 74 | user: UUID of the user who is voting. 75 | mbid: MusicBrainz ID of an entity that has incorrect mapping. 76 | spotify_uri: Spotify URI of an incorrectly mapped entity. 77 | """ 78 | user = request.json["user"] 79 | if not validate_uuid(user): 80 | raise BadRequest("Incorrect user ID (UUID).") 81 | 82 | mbid = request.json["mbid"] 83 | if not validate_uuid(mbid): 84 | raise BadRequest("Incorrect MBID (UUID).") 85 | 86 | spotify_uri = request.json["spotify_uri"] 87 | 88 | conn = psycopg2.connect(**current_app.config["PG_INFO"]) 89 | cur = conn.cursor() 90 | 91 | try: 92 | cur.execute("SELECT id FROM mapping WHERE mbid = %s AND spotify_uri = %s", 93 | (mbid, spotify_uri)) 94 | if not cur.rowcount: 95 | raise BadRequest("Can't find mapping between specified MBID and Spotify URI.") 96 | mapping_id = cur.fetchone()[0] 97 | 98 | # Checking if user have already voted 99 | cur.execute("SELECT id FROM mapping_vote WHERE mapping = %s AND cb_user = %s", 100 | (mapping_id, user)) 101 | if cur.rowcount: 102 | raise BadRequest("You already voted against this mapping.") 103 | 104 | cur.execute("INSERT INTO mapping_vote (mapping, cb_user) VALUES (%s, %s)", 105 | (mapping_id, user)) 106 | conn.commit() 107 | 108 | except psycopg2.IntegrityError as e: 109 | raise BadRequest(str(e)) 110 | except psycopg2.OperationalError as e: 111 | raise ServiceUnavailable(str(e)) 112 | 113 | # Check if threshold is reached. And if it is, marking mapping as deleted. 114 | try: 115 | cur.execute("SELECT * " 116 | "FROM mapping_vote " 117 | "JOIN mapping ON mapping_vote.mapping = mapping.id " 118 | "WHERE mapping.mbid = %s" 119 | " AND mapping.spotify_uri = %s" 120 | " AND mapping.is_deleted = FALSE", 121 | (mbid, spotify_uri)) 122 | if cur.rowcount >= current_app.config["THRESHOLD"]: 123 | cur.execute("UPDATE mapping SET is_deleted = TRUE " 124 | "WHERE mbid = %s AND spotify_uri = %s", 125 | (mbid, spotify_uri)) 126 | conn.commit() 127 | 128 | except psycopg2.IntegrityError as e: 129 | raise BadRequest(str(e)) 130 | except psycopg2.OperationalError as e: 131 | raise ServiceUnavailable(str(e)) 132 | 133 | response = Response() 134 | response.headers["Access-Control-Allow-Origin"] = "*" 135 | return response 136 | 137 | 138 | @main_bp.route("/mapping", methods=["POST"]) 139 | def mapping(): 140 | """Endpoint for getting mappings for a MusicBrainz entity. 141 | 142 | JSON parameters: 143 | mbid: MBID of the entity that you need to find a mapping for. 144 | 145 | Returns: 146 | List with mappings to a specified MBID. 147 | """ 148 | mbid = request.json["mbid"] 149 | if not validate_uuid(mbid): 150 | raise BadRequest("Incorrect MBID (UUID).") 151 | 152 | conn = psycopg2.connect(**current_app.config["PG_INFO"]) 153 | cur = conn.cursor() 154 | 155 | cur.execute("SELECT spotify_uri " 156 | "FROM mapping " 157 | "WHERE is_deleted = FALSE AND mbid = %s", 158 | (mbid,)) 159 | 160 | response = Response( 161 | json.dumps({ 162 | "mbid": mbid, 163 | "mappings": [row[0] for row in cur.fetchall()], 164 | }), 165 | mimetype="application/json" 166 | ) 167 | response.headers["Access-Control-Allow-Origin"] = "*" 168 | return response 169 | 170 | 171 | @main_bp.route("/mapping-spotify") 172 | def mapping_spotify(): 173 | """Endpoint for getting MusicBrainz entities mapped to a Spotify URI.""" 174 | uri = request.args.get("spotify_uri") 175 | if uri is None: 176 | raise BadRequest("`spotify_uri` argument is missing.") 177 | if not uri.startswith("spotify:album:"): 178 | raise BadRequest("Incorrect Spotify URI. Only albums are supported right now.") 179 | 180 | conn = psycopg2.connect(**current_app.config["PG_INFO"]) 181 | cur = conn.cursor() 182 | 183 | cur.execute(""" 184 | SELECT mbid::text 185 | FROM mapping 186 | WHERE is_deleted = FALSE AND spotify_uri = %s 187 | """, (uri,)) 188 | 189 | return jsonify({ 190 | "mappings": [row[0] for row in cur.fetchall()], 191 | }) 192 | 193 | 194 | @main_bp.route("/mapping-jsonp/") 195 | @jsonp 196 | def mapping_jsonp(mbid): 197 | if not validate_uuid(mbid): 198 | raise BadRequest("Incorrect MBID (UUID).") 199 | 200 | conn = psycopg2.connect(**current_app.config["PG_INFO"]) 201 | cur = conn.cursor() 202 | 203 | cur.execute("SELECT mbid, spotify_uri " 204 | "FROM mapping " 205 | "WHERE is_deleted = FALSE AND mbid = %s", 206 | (mbid,)) 207 | if not cur.rowcount: 208 | return jsonify({}) 209 | # TODO: Return all mappings to a specified MBID (don't forget to update userscript). 210 | row = cur.fetchone() 211 | return jsonify({mbid: row[1]}) 212 | -------------------------------------------------------------------------------- /mbspotify/views_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | from flask_testing import TestCase 3 | from flask import current_app 4 | from mbspotify import create_app 5 | import subprocess 6 | import psycopg2 7 | import json 8 | import os 9 | 10 | SQL_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'sql') 11 | 12 | 13 | def _run_psql(app, script, user=None, database=None): 14 | script = os.path.join(SQL_DIR, script) 15 | command = ['psql', '-h', app.config["PG_INFO"]["host"], 16 | '-p', str(app.config["PG_INFO"]["port"]), 17 | '-U', user or app.config["PG_INFO"]["user"], 18 | '-d', database or app.config["PG_INFO"]["database"], 19 | '-f', script] 20 | exit_code = subprocess.call(command) 21 | return exit_code 22 | 23 | 24 | class ViewsTestCase(TestCase): 25 | 26 | def setUp(self): 27 | self.mbid = "10000000-0000-0000-0000-000000000001" 28 | self.spotify_uri = "spotify:album:42" 29 | self.another_spotify_uri = "spotify:album:123" 30 | self.users = [ 31 | "00000000-0000-0000-0000-000000000001", 32 | "00000000-0000-0000-0000-000000000002", 33 | "00000000-0000-0000-0000-000000000003", 34 | "00000000-0000-0000-0000-000000000004", 35 | "00000000-0000-0000-0000-000000000005", 36 | "00000000-0000-0000-0000-000000000006", 37 | ] 38 | self.json_headers = {"Content-Type": "application/json"} 39 | 40 | with psycopg2.connect(**self.app.config["PG_INFO"]) as conn: 41 | with conn.cursor() as cur: 42 | cur.execute("DROP TABLE IF EXISTS mapping_vote CASCADE;") 43 | cur.execute("DROP TABLE IF EXISTS mapping CASCADE;") 44 | conn.commit() 45 | _run_psql(current_app, 'create_tables.sql', user='mbspotify', database='mbspotify') 46 | 47 | def tearDown(self): 48 | pass 49 | 50 | def create_app(self): 51 | app = create_app(config_path=os.path.join( 52 | os.path.dirname(os.path.realpath(__file__)), 53 | 'test_config.py' 54 | )) 55 | return app 56 | 57 | def test_index(self): 58 | """Test that index page redirects to the project page on GitHub.""" 59 | response = self.client.get("/") 60 | self.assertStatus(response, 302) 61 | self.assertRedirects(response, "https://github.com/metabrainz/mbspotify") 62 | 63 | def test_vote(self): 64 | # Adding a new mapping 65 | self.client.post( 66 | "mapping/add?key=%s" % self.app.config["ACCESS_KEYS"][0], 67 | headers=self.json_headers, 68 | data=json.dumps({ 69 | "mbid": self.mbid, 70 | "spotify_uri": self.spotify_uri, 71 | "user": self.users[0]}) 72 | ) 73 | 74 | # All users are voting against a mapping 75 | for user in self.users: 76 | self.client.post( 77 | "/mapping/vote?key=%s" % self.app.config["ACCESS_KEYS"][0], 78 | headers=self.json_headers, 79 | data=json.dumps({ 80 | "mbid": self.mbid, 81 | "spotify_uri": self.spotify_uri, 82 | "user": user, 83 | }) 84 | ) 85 | 86 | # Mapping should be deleted now 87 | response = self.client.post( 88 | "/mapping", 89 | headers=self.json_headers, 90 | data=json.dumps({"mbid": self.mbid}) 91 | ) 92 | self.assertEquals(response.json, { 93 | "mbid": self.mbid, 94 | "mappings": [], 95 | }) 96 | 97 | # And we should be able to add the same mapping again 98 | self.client.post( 99 | "mapping/add?key=%s" % self.app.config["ACCESS_KEYS"][0], 100 | headers=self.json_headers, 101 | data=json.dumps({ 102 | "mbid": self.mbid, 103 | "spotify_uri": self.spotify_uri, 104 | "user": self.users[0]}) 105 | ) 106 | response = self.client.post("/mapping", headers=self.json_headers, 107 | data=json.dumps({"mbid": self.mbid})) 108 | self.assertEquals(response.json, { 109 | "mbid": self.mbid, 110 | "mappings": [self.spotify_uri], 111 | }) 112 | 113 | # Let's try voting multiple times as the same user 114 | for _ in range(10): 115 | self.client.post( 116 | "/mapping/vote?key=%s" % self.app.config["ACCESS_KEYS"][0], 117 | headers=self.json_headers, 118 | data=json.dumps({ 119 | "mbid": self.mbid, 120 | "spotify_uri": self.spotify_uri, 121 | "user": self.users[0], 122 | }) 123 | ) 124 | 125 | # Mapping should still be there 126 | response = self.client.post("/mapping", headers=self.json_headers, 127 | data=json.dumps({"mbid": self.mbid})) 128 | self.assertEquals(response.json, { 129 | "mbid": self.mbid, 130 | "mappings": [self.spotify_uri], 131 | }) 132 | 133 | def test_mapping(self): 134 | response = self.client.post( 135 | "/mapping", 136 | headers=self.json_headers, 137 | data=json.dumps({"mbid": self.mbid}) 138 | ) 139 | self.assertEquals(response.json, { 140 | "mbid": self.mbid, 141 | "mappings": [], 142 | }) 143 | 144 | # Adding a new mapping 145 | self.client.post( 146 | "mapping/add?key=%s" % self.app.config["ACCESS_KEYS"][0], 147 | headers=self.json_headers, 148 | data=json.dumps({ 149 | "mbid": self.mbid, 150 | "spotify_uri": self.spotify_uri, 151 | "user": self.users[0] 152 | }) 153 | ) 154 | 155 | response = self.client.post("/mapping", headers=self.json_headers, 156 | data=json.dumps({"mbid": self.mbid})) 157 | self.assertEquals(response.json, { 158 | "mbid": self.mbid, 159 | "mappings": [self.spotify_uri], 160 | }) 161 | 162 | # Adding another mapping for the same MBID 163 | self.client.post( 164 | "mapping/add?key=%s" % self.app.config["ACCESS_KEYS"][0], 165 | headers=self.json_headers, 166 | data=json.dumps({ 167 | "mbid": self.mbid, 168 | "spotify_uri": self.another_spotify_uri, 169 | "user": self.users[0] 170 | }) 171 | ) 172 | 173 | response = self.client.post("/mapping", headers=self.json_headers, 174 | data=json.dumps({"mbid": self.mbid})) 175 | self.assertEquals(response.json, { 176 | "mbid": self.mbid, 177 | "mappings": [self.spotify_uri, self.another_spotify_uri], 178 | }) 179 | 180 | def mapping_spotify(self): 181 | response = self.client.get("/mapping-spotify?spotify_uri=%s" % self.spotify_uri) 182 | self.assertEquals(response.json, { 183 | "mappings": [], 184 | }) 185 | 186 | # Adding a new mapping 187 | self.client.post( 188 | "mapping/add?key=%s" % self.app.config["ACCESS_KEYS"][0], 189 | headers=self.json_headers, 190 | data=json.dumps({ 191 | "mbid": self.mbid, 192 | "spotify_uri": self.spotify_uri, 193 | "user": self.users[0] 194 | }) 195 | ) 196 | 197 | response = self.client.get("/mapping-spotify?spotify_uri=%s" % self.spotify_uri) 198 | self.assertEquals(response.json, { 199 | "mappings": [ 200 | self.mbid, 201 | ], 202 | }) 203 | 204 | def test_mapping_jsonp(self): 205 | response = self.client.get("/mapping-jsonp/%s" % self.mbid) 206 | self.assertEquals(response.json, {}) 207 | 208 | # Adding a new mapping 209 | self.client.post( 210 | "mapping/add?key=%s" % self.app.config["ACCESS_KEYS"][0], 211 | headers=self.json_headers, 212 | data=json.dumps({ 213 | "mbid": self.mbid, 214 | "spotify_uri": self.spotify_uri, 215 | "user": self.users[0], 216 | }) 217 | ) 218 | 219 | response = self.client.get("/mapping-jsonp/%s" % self.mbid) 220 | self.assertEquals(response.json, {self.mbid: self.spotify_uri}) 221 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | --------------------------------------------------------------------------------