├── MANIFEST.in ├── src └── cloudplayer │ ├── api │ ├── tests │ │ ├── http │ │ │ ├── __init__.py │ │ │ ├── test_favourite.py │ │ │ ├── test_session.py │ │ │ ├── test_account.py │ │ │ ├── test_playlist.py │ │ │ ├── test_track_comment.py │ │ │ ├── test_base.py │ │ │ └── test_auth.py │ │ ├── model │ │ │ ├── __init__.py │ │ │ ├── test_base.py │ │ │ ├── test_user.py │ │ │ ├── test_image.py │ │ │ ├── test_token.py │ │ │ ├── test_account.py │ │ │ ├── test_favourite.py │ │ │ ├── test_playlist.py │ │ │ ├── test_provider.py │ │ │ ├── test_favourite_item.py │ │ │ └── test_playlist_item.py │ │ ├── ws │ │ │ ├── __init__.py │ │ │ ├── test_user.py │ │ │ ├── test_base.py │ │ │ └── test_account.py │ │ ├── access │ │ │ ├── __init__.py │ │ │ ├── test_action.py │ │ │ ├── test_fields.py │ │ │ ├── test_rule.py │ │ │ ├── test_principal.py │ │ │ └── test_policy.py │ │ ├── controller │ │ │ ├── __init__.py │ │ │ ├── test_account.py │ │ │ ├── test_favourite.py │ │ │ ├── test_track.py │ │ │ ├── test_favourite_item.py │ │ │ ├── test_playlist_item.py │ │ │ ├── test_user.py │ │ │ ├── test_playlist.py │ │ │ └── test_token.py │ │ ├── __init__.py │ │ ├── upstream │ │ │ └── youtube │ │ │ │ ├── search.json │ │ │ │ └── videos.json │ │ ├── test_util.py │ │ ├── test_routing.py │ │ ├── test_app.py │ │ ├── expected │ │ │ └── tracks │ │ │ │ ├── youtube.json │ │ │ │ └── soundcloud.json │ │ └── test_handler.py │ ├── __init__.py │ ├── ws │ │ ├── __init__.py │ │ ├── user.py │ │ ├── account.py │ │ ├── playlist.py │ │ └── base.py │ ├── http │ │ ├── __init__.py │ │ ├── user.py │ │ ├── account.py │ │ ├── favourite.py │ │ ├── session.py │ │ ├── track_comment.py │ │ ├── provider.py │ │ ├── token.py │ │ ├── playlist.py │ │ ├── playlist_item.py │ │ ├── favourite_item.py │ │ ├── track.py │ │ ├── proxy.py │ │ ├── socket.py │ │ ├── auth.py │ │ └── base.py │ ├── model │ │ ├── __init__.py │ │ ├── tracklist_item.py │ │ ├── favourite.py │ │ ├── provider.py │ │ ├── track_comment.py │ │ ├── user.py │ │ ├── tracklist.py │ │ ├── token.py │ │ ├── session.py │ │ ├── image.py │ │ ├── favourite_item.py │ │ ├── playlist_item.py │ │ ├── playlist.py │ │ ├── track.py │ │ ├── account.py │ │ └── base.py │ ├── controller │ │ ├── __init__.py │ │ ├── session.py │ │ ├── provider.py │ │ ├── account.py │ │ ├── favourite.py │ │ ├── user.py │ │ ├── playlist.py │ │ ├── favourite_item.py │ │ ├── playlist_item.py │ │ ├── token.py │ │ ├── track_comment.py │ │ └── track.py │ ├── access │ │ ├── __init__.py │ │ ├── action.py │ │ ├── fields.py │ │ ├── rule.py │ │ ├── principal.py │ │ └── policy.py │ ├── routing.py │ ├── util.py │ └── handler.py │ └── __init__.py ├── static ├── robots.txt ├── favicon.ico └── close.html ├── scripts ├── stop.sh ├── validate.sh ├── run.sh ├── init-database.sh ├── install-as-user.sh ├── init-database.sql └── install-as-root.sh ├── source ├── fonts │ ├── slate.eot │ ├── slate.ttf │ ├── slate.woff │ ├── slate.woff2 │ └── slate.svg ├── images │ └── logo.png ├── index.rst └── conf.py ├── pytest.ini ├── readthedocs.yml ├── .gitignore ├── api.conf ├── spec ├── provider.yml ├── session.yml ├── queue.yml ├── token.yml ├── user.yml ├── history.yml ├── favourite.yml ├── image.yml ├── track_comment.yml ├── playback.yml ├── playlist_item.yml ├── favourite_item.yml ├── track.yml ├── account.yml ├── playlist.yml └── asyncapi.yml ├── widdershins.json ├── .gitlab-ci.yml ├── appspec.yml ├── Makefile ├── aws.py ├── README.md ├── dev.py ├── setup.py ├── cloudplayer.conf └── .travis.yml /MANIFEST.in: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/http/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/ws/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/access/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/controller/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/test_base.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # python package 2 | -------------------------------------------------------------------------------- /scripts/stop.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | 4 | supervisorctl stop api 5 | -------------------------------------------------------------------------------- /scripts/validate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | 4 | supervisorctl status api 5 | -------------------------------------------------------------------------------- /src/cloudplayer/__init__.py: -------------------------------------------------------------------------------- 1 | __import__('pkg_resources').declare_namespace(__name__) 2 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cloud-Player/api/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /source/fonts/slate.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cloud-Player/api/HEAD/source/fonts/slate.eot -------------------------------------------------------------------------------- /source/fonts/slate.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cloud-Player/api/HEAD/source/fonts/slate.ttf -------------------------------------------------------------------------------- /source/fonts/slate.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cloud-Player/api/HEAD/source/fonts/slate.woff -------------------------------------------------------------------------------- /source/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cloud-Player/api/HEAD/source/images/logo.png -------------------------------------------------------------------------------- /scripts/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | 4 | supervisorctl start api 5 | service nginx reload 6 | -------------------------------------------------------------------------------- /source/fonts/slate.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cloud-Player/api/HEAD/source/fonts/slate.woff2 -------------------------------------------------------------------------------- /scripts/init-database.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | 4 | psql -f /srv/cloudplayer/scripts/init-database.sql 5 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --pep8 --showlocals --tb=native --cov=cloudplayer.api src/cloudplayer/api/tests 3 | timeout = 60 4 | postgresql_port = 8852 5 | redis_port = 8869 6 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | formats: 2 | - none 3 | build: 4 | image: latest 5 | python: 6 | version: 3.6 7 | pip_install: true 8 | extra_requirements: 9 | - test 10 | - doc 11 | -------------------------------------------------------------------------------- /scripts/install-as-user.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | 4 | cd /srv/cloudplayer 5 | python3.6 -m venv --upgrade --copies . 6 | bin/pip3.6 install --upgrade pip 7 | bin/pip3.6 install -e . 8 | -------------------------------------------------------------------------------- /src/cloudplayer/api/__init__.py: -------------------------------------------------------------------------------- 1 | from tornado.web import HTTPError as APIException 2 | 3 | __all__ = [ 4 | 'APIException' 5 | ] 6 | __import__('pkg_resources').declare_namespace(__name__) 7 | -------------------------------------------------------------------------------- /src/cloudplayer/api/ws/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import WSRequest, WSHandler 2 | 3 | __all__ = [ 4 | 'WSRequest', 5 | 'WSHandler' 6 | ] 7 | __import__('pkg_resources').declare_namespace(__name__) 8 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import HTTPException, HTTPHandler 2 | 3 | __all__ = [ 4 | 'HTTPException', 5 | 'HTTPHandler' 6 | ] 7 | __import__('pkg_resources').declare_namespace(__name__) 8 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Base, Encoder, Transient 2 | 3 | __all__ = [ 4 | 'Base', 5 | 'Encoder', 6 | 'Transient' 7 | ] 8 | __import__('pkg_resources').declare_namespace(__name__) 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .coverage 3 | .eggs 4 | .pytest_cache 5 | *.html 6 | *.html.md 7 | *.pyc 8 | *.pyo 9 | aws_upload 10 | bin 11 | build 12 | dist 13 | include 14 | lib 15 | share 16 | pip-selfcheck.json 17 | private.py 18 | pyvenv.cfg 19 | -------------------------------------------------------------------------------- /api.conf: -------------------------------------------------------------------------------- 1 | [program:api] 2 | command=/srv/cloudplayer/bin/api --config=/srv/cloudplayer/aws.py 3 | user=cloudplayer 4 | numprocs=1 5 | autostart=true 6 | autorestart=true 7 | startsecs=10 8 | startretries=3 9 | stopsignal=TERM 10 | stopwaitsecs=10 11 | -------------------------------------------------------------------------------- /spec/provider.yml: -------------------------------------------------------------------------------- 1 | Provider: 2 | type: object 3 | properties: 4 | id: 5 | description: Provider id 6 | type: string 7 | client_id: 8 | description: Provider client id 9 | type: string 10 | required: 11 | - id 12 | -------------------------------------------------------------------------------- /widdershins.json: -------------------------------------------------------------------------------- 1 | { 2 | "language_tabs": [ 3 | { 4 | "http": "HTTP" 5 | }, 6 | { 7 | "javascript": "JavaScript" 8 | }, 9 | { 10 | "python": "Python" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Controller, ControllerException, ProviderRegistry 2 | 3 | __all__ = [ 4 | 'Controller', 5 | 'ControllerException', 6 | 'ProviderRegistry' 7 | ] 8 | __import__('pkg_resources').declare_namespace(__name__) 9 | -------------------------------------------------------------------------------- /source/index.rst: -------------------------------------------------------------------------------- 1 | .. Cloud-Player API documentation master file 2 | 3 | Cloud-Player API 4 | ================ 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | :caption: Contents: 9 | 10 | 11 | Indices and tables 12 | ================== 13 | 14 | * :ref:`genindex` 15 | * :ref:`modindex` 16 | * :ref:`search` 17 | -------------------------------------------------------------------------------- /spec/session.yml: -------------------------------------------------------------------------------- 1 | Session: 2 | title: Session 3 | type: object 4 | properties: 5 | system: 6 | description: Session system 7 | type: string 8 | browser: 9 | description: Session browser 10 | type: string 11 | screen: 12 | description: Session screen 13 | type: string 14 | required: 15 | - system 16 | - browser 17 | - screen 18 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/ws/test_user.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.gen_test 5 | async def test_user_entity_should_be_available_over_websocket( 6 | user_push, user): 7 | response = await user_push({'channel': 'user.me'}) 8 | result = response.json() 9 | assert result['channel'] == 'user.me' 10 | assert result['body']['id'] == str(user.id) 11 | assert result['body'].pop('accounts') 12 | -------------------------------------------------------------------------------- /spec/queue.yml: -------------------------------------------------------------------------------- 1 | Queue: 2 | type: object 3 | tags: 4 | - draft 5 | properties: 6 | id: 7 | description: Queue id 8 | type: string 9 | account_id: 10 | $ref: 'account.yml#/Account/properties/id' 11 | playbacks: 12 | description: List of playbacks 13 | items: 14 | $ref: 'playback.yml#/Playback' 15 | type: array 16 | required: 17 | - id 18 | - account_id 19 | - playbacks 20 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/session.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.controller.session 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller import Controller 9 | from cloudplayer.api.model.session import Session 10 | 11 | 12 | class SessionController(Controller): 13 | 14 | __model__ = Session 15 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/upstream/youtube/search.json: -------------------------------------------------------------------------------- 1 | { 2 | "items": [ 3 | { 4 | "id": { 5 | "videoId": "PDZcqBgCS74" 6 | } 7 | }, 8 | { 9 | "id": { 10 | "videoId": "kK42LZqO0wA" 11 | } 12 | }, 13 | { 14 | "id": { 15 | "videoId": "yL6odWvurCI" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/ws/test_base.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.gen_test 5 | async def test_websocket_connection_responds_with_fallback(user_push): 6 | not_found = await user_push({'channel': 'cannot.be.found'}) 7 | assert not_found.json() == { 8 | 'channel': 'cannot.be.found', 9 | 'sequence': 0, 10 | 'body': { 11 | 'reason': 'channel not found', 12 | 'status_code': 404}} 13 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/provider.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.controller.provider 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller import Controller 9 | from cloudplayer.api.model.provider import Provider 10 | 11 | 12 | class ProviderController(Controller): 13 | 14 | __model__ = Provider 15 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/controller/test_account.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from cloudplayer.api.controller.account import AccountController 4 | 5 | 6 | @pytest.mark.gen_test 7 | async def test_controller_should_redirect_alias_to_current_user( 8 | db, current_user): 9 | controller = AccountController(db, current_user) 10 | account = await controller.read({'id': 'me', 'provider_id': 'cloudplayer'}) 11 | assert account.id == current_user['cloudplayer'] 12 | -------------------------------------------------------------------------------- /spec/token.yml: -------------------------------------------------------------------------------- 1 | Token: 2 | title: Token 3 | type: object 4 | properties: 5 | id: 6 | description: Token id 7 | type: string 8 | claimed: 9 | description: Claim status 10 | type: boolean 11 | default: False 12 | account_id: 13 | $ref: 'account.yml#/Account/properties/id' 14 | account_provider_id: 15 | $ref: 'provider.yml#/Provider/properties/id' 16 | required: 17 | - id 18 | - claimed 19 | - account_id 20 | - account_provider_id 21 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/controller/test_favourite.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from cloudplayer.api.controller.favourite import FavouriteController 4 | 5 | 6 | @pytest.mark.gen_test 7 | async def test_favourite_controller_should_redirect_mine_alias( 8 | db, current_user): 9 | controller = FavouriteController(db, current_user) 10 | ids = {'id': 'mine', 'provider_id': 'cloudplayer'} 11 | favourite = await controller.read(ids) 12 | assert favourite.account_id == current_user['cloudplayer'] 13 | -------------------------------------------------------------------------------- /src/cloudplayer/api/ws/user.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.ws.user 3 | ~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.user import UserController 9 | from cloudplayer.api.handler import EntityMixin 10 | from cloudplayer.api.ws import WSHandler 11 | 12 | 13 | class Entity(EntityMixin, WSHandler): 14 | 15 | __controller__ = UserController 16 | 17 | SUPPORTED_METHODS = ('GET',) 18 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/user.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.user 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.user import UserController 9 | from cloudplayer.api.handler import EntityMixin 10 | from cloudplayer.api.http import HTTPHandler 11 | 12 | 13 | class Entity(EntityMixin, HTTPHandler): 14 | 15 | __controller__ = UserController 16 | 17 | SUPPORTED_METHODS = ('GET',) 18 | -------------------------------------------------------------------------------- /spec/user.yml: -------------------------------------------------------------------------------- 1 | User: 2 | title: User 3 | type: object 4 | properties: 5 | id: 6 | description: User id 7 | type: string 8 | accounts: 9 | description: Connected accounts 10 | type: array 11 | items: 12 | $ref: 'account.yml#/ListedAccount' 13 | created: 14 | description: User creation 15 | format: date-time 16 | type: string 17 | updated: 18 | description: User update 19 | format: date-time 20 | type: string 21 | required: 22 | - id 23 | - accounts 24 | -------------------------------------------------------------------------------- /src/cloudplayer/api/ws/account.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.ws.account 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.account import AccountController 9 | from cloudplayer.api.handler import EntityMixin 10 | from cloudplayer.api.ws import WSHandler 11 | 12 | 13 | class Entity(EntityMixin, WSHandler): 14 | 15 | __controller__ = AccountController 16 | 17 | SUPPORTED_METHODS = ('GET', 'SUB', 'UNSUB') 18 | -------------------------------------------------------------------------------- /spec/history.yml: -------------------------------------------------------------------------------- 1 | History: 2 | type: object 3 | tags: 4 | - draft 5 | properties: 6 | id: 7 | description: History id 8 | type: string 9 | account_id: 10 | $ref: 'account.yml#/Account/properties/id' 11 | account_provider_id: 12 | $ref: 'provider.yml#/Provider/properties/id' 13 | playbacks: 14 | description: List of playbacks 15 | items: 16 | $ref: 'playback.yml#/Playback' 17 | type: array 18 | required: 19 | - id 20 | - account_id 21 | - account_provider_id 22 | - playbacks 23 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/account.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.account 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.account import AccountController 9 | from cloudplayer.api.http import HTTPHandler 10 | from cloudplayer.api.handler import EntityMixin 11 | 12 | 13 | class Entity(EntityMixin, HTTPHandler): 14 | 15 | __controller__ = AccountController 16 | 17 | SUPPORTED_METHODS = ('GET', 'OPTIONS') 18 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - prep 3 | - test 4 | 5 | services: 6 | - postgres:10.4 7 | - redis:4.0 8 | 9 | variables: 10 | POSTGRES_DB: cloudplayer 11 | POSTGRES_USER: api 12 | POSTGRES_PASSWORD: password 13 | 14 | prep: 15 | stage: prep 16 | script: 17 | - apt-get install software-properties-common python-software-properties 18 | - add-apt-repository -y ppa:jonathonf/python-3.6 19 | - apt-get update 20 | - apt-get install python3.6 21 | 22 | test: 23 | stage: test 24 | script: 25 | - make install 26 | - pytest 27 | -------------------------------------------------------------------------------- /src/cloudplayer/api/ws/playlist.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.ws.playlist 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.playlist import PlaylistController 9 | from cloudplayer.api.handler import EntityMixin 10 | from cloudplayer.api.ws import WSHandler 11 | 12 | 13 | class Entity(EntityMixin, WSHandler): 14 | 15 | __controller__ = PlaylistController 16 | 17 | SUPPORTED_METHODS = ('GET', 'PUT', 'SUB', 'UNSUB') 18 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/favourite.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.favourite 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.favourite import FavouriteController 9 | from cloudplayer.api.handler import EntityMixin 10 | from cloudplayer.api.http import HTTPHandler 11 | 12 | 13 | class Entity(EntityMixin, HTTPHandler): 14 | 15 | __controller__ = FavouriteController 16 | 17 | SUPPORTED_METHODS = ('GET', 'OPTIONS') 18 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/session.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.session 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.session import SessionController 9 | from cloudplayer.api.handler import CollectionMixin 10 | from cloudplayer.api.http import HTTPHandler 11 | 12 | 13 | class Collection(CollectionMixin, HTTPHandler): 14 | 15 | __controller__ = SessionController 16 | 17 | SUPPORTED_METHODS = ('POST', 'OPTIONS') 18 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/http/test_favourite.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.gen_test 5 | async def test_favourite_id_should_be_referenced_in_account( 6 | account, user_fetch): 7 | response = await user_fetch( 8 | '/account/cloudplayer/{}'.format(account.id)) 9 | favourite_id = response.json().get('favourite_id') 10 | assert favourite_id == account.favourite.id 11 | response = await user_fetch( 12 | '/favourite/cloudplayer/{}'.format(favourite_id)) 13 | account_id = response.json().get('account_id') 14 | assert account_id == account.id 15 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/test_user.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sql 2 | 3 | from cloudplayer.api.model.user import User 4 | from cloudplayer.api.model.base import Base 5 | 6 | 7 | def test_user_model_should_create_table(db): 8 | user = sql.Table( 9 | 'user', Base.metadata, autoload=True, 10 | autoload_with=db) 11 | assert user.exists(db.connection()) 12 | 13 | 14 | def test_user_model_can_be_created(db): 15 | user = User() 16 | db.add(user) 17 | db.commit() 18 | user_id = user.id 19 | db.expunge(user) 20 | assert db.query(User).get(user_id) 21 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/controller/test_track.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.gen_test 5 | def test_soundcloud_track_controller_should_retrieve_and_convert_tracks( 6 | user_fetch, expect): 7 | response = yield user_fetch('/track/soundcloud?q=test') 8 | assert response.json() == expect('/tracks/soundcloud') 9 | 10 | 11 | @pytest.mark.gen_test 12 | def test_youtube_track_controller_should_retrieve_and_convert_tracks( 13 | user_fetch, expect): 14 | response = yield user_fetch('/track/youtube?q=test') 15 | assert response.json() == expect('/tracks/youtube') 16 | -------------------------------------------------------------------------------- /spec/favourite.yml: -------------------------------------------------------------------------------- 1 | Favourite: 2 | type: object 3 | properties: 4 | id: 5 | description: Favourite id 6 | type: string 7 | provider_id: 8 | $ref: 'provider.yml#/Provider/properties/id' 9 | account_id: 10 | $ref: 'account.yml#/Account/properties/id' 11 | account_provider_id: 12 | $ref: 'provider.yml#/Provider/properties/id' 13 | items: 14 | description: Favourite items 15 | items: 16 | $ref: 'favourite_item.yml#/FavouriteItem' 17 | type: array 18 | required: 19 | - id 20 | - provider_id 21 | - account_id 22 | - account_provider_id 23 | - items 24 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/track_comment.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.track_comment 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.track_comment import \ 9 | SoundcloudTrackCommentController 10 | from cloudplayer.api.handler import CollectionMixin 11 | from cloudplayer.api.http import HTTPHandler 12 | 13 | 14 | class Soundcloud(CollectionMixin, HTTPHandler): 15 | 16 | __controller__ = SoundcloudTrackCommentController 17 | 18 | SUPPORTED_METHODS = ('GET', 'OPTIONS') 19 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/http/test_session.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.gen_test 5 | async def test_account_session_can_be_created_with_fingerprint( 6 | user_fetch, account, db): 7 | assert not account.sessions 8 | body = {'system': 'windows', 'browser': 'safari', 'screen': '1024x800'} 9 | response = await user_fetch('/session', method='POST', body=body) 10 | assert response.json() == {} 11 | db.refresh(account) 12 | assert account.sessions 13 | session = account.sessions[0] 14 | assert session.system == 'windows' 15 | assert session.browser == 'safari' 16 | assert session.screen == '1024x800' 17 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/test_image.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sql 2 | 3 | from cloudplayer.api.model.image import Image 4 | import cloudplayer.api.model.base as model 5 | 6 | 7 | def test_image_model_should_create_table(db): 8 | image = sql.Table( 9 | 'image', model.Base.metadata, autoload=True, 10 | autoload_with=db) 11 | assert image.exists(db.connection()) 12 | 13 | 14 | def test_image_model_can_be_created(db): 15 | image = Image( 16 | large='http://img.host/large.jpg') 17 | db.add(image) 18 | db.commit() 19 | image_id = image.id 20 | db.expunge(image) 21 | assert db.query(Image).get(image_id) 22 | -------------------------------------------------------------------------------- /scripts/init-database.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS dblink; 2 | 3 | DO 4 | $do$ 5 | BEGIN 6 | IF NOT EXISTS ( 7 | SELECT 8 | FROM pg_database 9 | WHERE datname = 'cloudplayer') THEN 10 | PERFORM dblink_exec('dbname=' || current_database(), 'CREATE DATABASE cloudplayer'); 11 | END IF; 12 | END 13 | $do$; 14 | 15 | DO 16 | $do$ 17 | BEGIN 18 | IF NOT EXISTS ( 19 | SELECT 20 | FROM pg_catalog.pg_user 21 | WHERE usename = 'api') THEN 22 | CREATE ROLE api LOGIN PASSWORD 'password'; 23 | GRANT ALL PRIVILEGES ON DATABASE cloudplayer TO api; 24 | END IF; 25 | END 26 | $do$; 27 | -------------------------------------------------------------------------------- /src/cloudplayer/api/access/__init__.py: -------------------------------------------------------------------------------- 1 | from .action import Anything, Create, Delete, Query, Read, Update 2 | from .fields import Available, Fields 3 | from .policy import Policy, PolicyViolation 4 | from .principal import Child, Everyone, Owner, Parent 5 | from .rule import Allow, Deny 6 | 7 | __all__ = [ 8 | 'Allow', 9 | 'Anything', 10 | 'Available', 11 | 'Child', 12 | 'Create', 13 | 'Delete', 14 | 'Deny', 15 | 'Everyone', 16 | 'Fields', 17 | 'Owner', 18 | 'Parent', 19 | 'Policy', 20 | 'PolicyViolation', 21 | 'Query', 22 | 'Read', 23 | 'Update' 24 | ] 25 | __import__('pkg_resources').declare_namespace(__name__) 26 | -------------------------------------------------------------------------------- /appspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.0 2 | os: linux 3 | hooks: 4 | AfterInstall: 5 | - location: scripts/install-as-root.sh 6 | timeout: 300 7 | runas: root 8 | - location: scripts/install-as-user.sh 9 | timeout: 600 10 | runas: cloudplayer 11 | - location: scripts/init-database.sh 12 | timeout: 100 13 | runas: postgres 14 | ApplicationStart: 15 | - location: scripts/run.sh 16 | timeout: 120 17 | runas: root 18 | ApplicationStop: 19 | - location: scripts/stop.sh 20 | timeout: 120 21 | runas: root 22 | ValidateService: 23 | - location: scripts/validate.sh 24 | timeout: 60 25 | runas: root 26 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/controller/test_favourite_item.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from cloudplayer.api.controller import ControllerException 4 | from cloudplayer.api.controller.favourite_item import FavouriteItemController 5 | 6 | 7 | @pytest.mark.gen_test 8 | async def test_favourite_item_controller_should_404_if_favourite_is_missing( 9 | db, current_user): 10 | controller = FavouriteItemController(db, current_user) 11 | ids = {'favourite_id': 'missing', 'favourite_provider_id': 'cloudplayer'} 12 | with pytest.raises(ControllerException) as error: 13 | await controller.read(ids, {'favourite_id': 'missing'}) 14 | assert error.value.status_code == 404 15 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/controller/test_playlist_item.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from cloudplayer.api.controller import ControllerException 4 | from cloudplayer.api.controller.playlist_item import PlaylistItemController 5 | 6 | 7 | @pytest.mark.gen_test 8 | async def test_playlist_item_controller_should_404_if_playlist_is_missing( 9 | db, current_user): 10 | controller = PlaylistItemController(db, current_user) 11 | ids = {'playlist_id': 'something', 'playlist_provider_id': 'cloudplayer'} 12 | with pytest.raises(ControllerException) as error: 13 | await controller.read(ids, {'playlist_id': 'something else'}) 14 | assert error.value.status_code == 404 15 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/provider.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.provider 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.provider import ProviderController 9 | from cloudplayer.api.handler import CollectionMixin, EntityMixin 10 | from cloudplayer.api.http import HTTPHandler 11 | 12 | 13 | class Entity(EntityMixin, HTTPHandler): 14 | 15 | __controller__ = ProviderController 16 | 17 | SUPPORTED_METHODS = ('GET',) 18 | 19 | 20 | class Collection(CollectionMixin, HTTPHandler): 21 | 22 | __controller__ = ProviderController 23 | 24 | SUPPORTED_METHODS = ('GET',) 25 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/account.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.controller.account 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.access import Available 9 | from cloudplayer.api.controller import Controller 10 | from cloudplayer.api.model.account import Account 11 | 12 | 13 | class AccountController(Controller): 14 | 15 | __model__ = Account 16 | 17 | async def read(self, ids, fields=Available): 18 | if ids['id'] == 'me': 19 | ids['id'] = self.current_user.get(ids['provider_id'], 'me') 20 | entity = await super().read(ids) 21 | return entity 22 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/test_token.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sql 2 | 3 | from cloudplayer.api.model.token import Token 4 | import cloudplayer.api.model.base as model 5 | 6 | 7 | def test_token_model_should_create_table(db): 8 | token = sql.Table( 9 | 'token', model.Base.metadata, autoload=True, 10 | autoload_with=db) 11 | assert token.exists(db.connection()) 12 | 13 | 14 | def test_token_model_can_be_created(current_user, db): 15 | token = Token( 16 | account_id=current_user['cloudplayer'], 17 | account_provider_id='cloudplayer') 18 | db.add(token) 19 | db.commit() 20 | token_id = token.id 21 | db.expunge(token) 22 | assert db.query(Token).get(token_id) 23 | -------------------------------------------------------------------------------- /spec/image.yml: -------------------------------------------------------------------------------- 1 | EmbeddedImage: 2 | type: object 3 | properties: 4 | id: 5 | description: Image id 6 | type: string 7 | large: 8 | description: Large uri 9 | type: string 10 | medium: 11 | description: Medium uri 12 | type: string 13 | small: 14 | description: Small uri 15 | type: string 16 | required: 17 | - id 18 | - large 19 | 20 | Image: 21 | allOf: 22 | - $ref: '#/EmbeddedImage' 23 | - type: object 24 | properties: 25 | created: 26 | description: Image creation 27 | format: date-time 28 | type: string 29 | updated: 30 | description: Image update 31 | format: date-time 32 | type: string 33 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/token.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.token 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.token import TokenController 9 | from cloudplayer.api.handler import CollectionMixin, EntityMixin 10 | from cloudplayer.api.http import HTTPHandler 11 | 12 | 13 | class Entity(EntityMixin, HTTPHandler): 14 | 15 | __controller__ = TokenController 16 | 17 | SUPPORTED_METHODS = ('GET', 'PUT', 'OPTIONS') 18 | 19 | 20 | class Collection(CollectionMixin, HTTPHandler): 21 | 22 | __controller__ = TokenController 23 | 24 | SUPPORTED_METHODS = ('POST', 'OPTIONS') 25 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/test_account.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sql 2 | 3 | from cloudplayer.api.model.account import Account 4 | import cloudplayer.api.model.base as model 5 | 6 | 7 | def test_account_model_should_create_table(db): 8 | account = sql.Table( 9 | 'account', model.Base.metadata, autoload=True, 10 | autoload_with=db) 11 | assert account.exists(db.connection()) 12 | 13 | 14 | def test_account_model_can_be_created(current_user, db): 15 | account = Account( 16 | id='abcd1234', 17 | user_id=current_user['user_id'], 18 | provider_id='cloudplayer') 19 | db.add(account) 20 | db.commit() 21 | db.expunge(account) 22 | assert db.query(Account).get(('abcd1234', 'cloudplayer')) 23 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/playlist.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.playlist 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.playlist import PlaylistController 9 | from cloudplayer.api.handler import CollectionMixin, EntityMixin 10 | from cloudplayer.api.http import HTTPHandler 11 | 12 | 13 | class Entity(EntityMixin, HTTPHandler): 14 | 15 | __controller__ = PlaylistController 16 | 17 | SUPPORTED_METHODS = ('GET', 'PATCH', 'DELETE', 'OPTIONS') 18 | 19 | 20 | class Collection(CollectionMixin, HTTPHandler): 21 | 22 | __controller__ = PlaylistController 23 | 24 | SUPPORTED_METHODS = ('GET', 'POST', 'OPTIONS') 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | @python3.6 -m venv --upgrade --copies . 3 | bin/pip3.6 install -e .[test,doc] 4 | 5 | sphinx: 6 | bin/sphinx-build -N -b singlehtml source build 7 | 8 | shins: 9 | @npm i -g shins widdershins 10 | @mkdir -p ./shins 11 | widdershins -e widdershins.json --omitBody --resolve spec/restapi.yml source/restapi.html.md 12 | shins --minify --inline --logo source/images/logo.png -o shins/restapi.html source/restapi.html.md 13 | widdershins -e widdershins.json --omitBody --resolve spec/asyncapi.yml source/asyncapi.html.md 14 | shins --minify --inline --logo source/images/logo.png -o shins/asyncapi.html source/asyncapi.html.md 15 | @grep -v "X509" shins/asyncapi.html > temp && mv temp shins/asyncapi.html 16 | 17 | .DEFAULT: install sphinx shins 18 | .PHONY: install sphinx shins 19 | -------------------------------------------------------------------------------- /spec/track_comment.yml: -------------------------------------------------------------------------------- 1 | TrackComment: 2 | type: object 3 | properties: 4 | id: 5 | description: Track comment id 6 | type: string 7 | provider_id: 8 | $ref: 'provider.yml#/Provider/properties/id' 9 | account: 10 | $ref: 'account.yml#/EmbeddedAccount' 11 | body: 12 | description: Track comment body 13 | type: string 14 | timestamp: 15 | description: Track comment timestamp 16 | format: int32 17 | type: integer 18 | track_id: 19 | $ref: 'track.yml#/Track/properties/id' 20 | track_provider_id: 21 | $ref: 'provider.yml#/Provider/properties/id' 22 | created: 23 | description: Track comment creation 24 | format: date-time 25 | type: string 26 | required: 27 | - id 28 | - provider_id 29 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/favourite.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.controller.favourite 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.access import Available 9 | from cloudplayer.api.controller import Controller 10 | from cloudplayer.api.model.favourite import Favourite 11 | 12 | 13 | class FavouriteController(Controller): 14 | 15 | __model__ = Favourite 16 | 17 | async def read(self, ids, fields=Available): 18 | if ids['id'] == 'mine': 19 | account = self.get_account(ids['provider_id']) 20 | if account: 21 | ids['id'] = account.favourite.id 22 | return await super().read(ids, fields=fields) 23 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/playlist_item.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.playlist_item 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.playlist_item import PlaylistItemController 9 | from cloudplayer.api.handler import CollectionMixin, EntityMixin 10 | from cloudplayer.api.http import HTTPHandler 11 | 12 | 13 | class Entity(EntityMixin, HTTPHandler): 14 | 15 | __controller__ = PlaylistItemController 16 | 17 | SUPPORTED_METHODS = ('GET', 'PATCH', 'DELETE', 'OPTIONS') 18 | 19 | 20 | class Collection(CollectionMixin, HTTPHandler): 21 | 22 | __controller__ = PlaylistItemController 23 | 24 | SUPPORTED_METHODS = ('GET', 'POST', 'OPTIONS') 25 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/user.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.controller.user 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller import Controller, ControllerException 9 | from cloudplayer.api.model.user import User 10 | 11 | 12 | class UserController(Controller): 13 | 14 | __model__ = User 15 | 16 | async def read(self, ids): 17 | if ids['id'] == 'me': 18 | ids['id'] = self.current_user['user_id'] 19 | try: 20 | entity = await super().read(ids) 21 | except ControllerException: 22 | if ids['id'] == self.current_user['user_id']: 23 | self.current_user.clear() 24 | raise 25 | return entity 26 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/test_favourite.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sql 2 | 3 | from cloudplayer.api.model.favourite import Favourite 4 | import cloudplayer.api.model.base as model 5 | 6 | 7 | def test_favourite_model_should_create_table(db): 8 | favourite = sql.Table( 9 | 'favourite', model.Base.metadata, autoload=True, 10 | autoload_with=db) 11 | assert favourite.exists(db.connection()) 12 | 13 | 14 | def test_favourite_model_can_be_created(current_user, db): 15 | favourite = Favourite( 16 | provider_id='cloudplayer', 17 | account_id=current_user['cloudplayer'], 18 | account_provider_id='cloudplayer') 19 | db.add(favourite) 20 | db.commit() 21 | favourite_id = favourite.id 22 | db.expunge(favourite) 23 | assert db.query(Favourite).get((favourite_id, 'cloudplayer')) 24 | -------------------------------------------------------------------------------- /spec/playback.yml: -------------------------------------------------------------------------------- 1 | Playback: 2 | type: object 3 | tags: 4 | - draft 5 | properties: 6 | id: 7 | description: Playback id 8 | type: string 9 | previous: 10 | $ref: '#/Playback/properties/id' 11 | next: 12 | $ref: '#/Playback/properties/id' 13 | progress: 14 | description: Playback progress 15 | format: double 16 | type: number 17 | status: 18 | description: Playback status 19 | type: string 20 | enum: 21 | - completed 22 | - skipped 23 | - current 24 | - queued 25 | - suggested 26 | created: 27 | description: Playback creation 28 | format: date-time 29 | type: string 30 | updated: 31 | description: Playback update 32 | format: date-time 33 | type: string 34 | required: 35 | - id 36 | - status 37 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/test_playlist.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sql 2 | 3 | from cloudplayer.api.model.playlist import Playlist 4 | import cloudplayer.api.model.base as model 5 | 6 | 7 | def test_playlist_model_should_create_table(db): 8 | playlist = sql.Table( 9 | 'playlist', model.Base.metadata, autoload=True, 10 | autoload_with=db) 11 | assert playlist.exists(db.connection()) 12 | 13 | 14 | def test_playlist_model_can_be_created(current_user, db): 15 | playlist = Playlist( 16 | provider_id='cloudplayer', 17 | account_id=current_user['cloudplayer'], 18 | account_provider_id='cloudplayer', 19 | title='5678-abcd') 20 | db.add(playlist) 21 | db.commit() 22 | playlist_id = playlist.id 23 | db.expunge(playlist) 24 | assert db.query(Playlist).get((playlist_id, 'cloudplayer')) 25 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/controller/test_user.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from cloudplayer.api.controller import ControllerException 4 | from cloudplayer.api.controller.user import UserController 5 | 6 | 7 | @pytest.mark.gen_test 8 | async def test_controller_should_redirect_alias_to_current_user( 9 | db, current_user): 10 | controller = UserController(db, current_user) 11 | user = await controller.read({'id': 'me'}) 12 | assert user.id == current_user['user_id'] 13 | 14 | 15 | @pytest.mark.gen_test 16 | async def test_controller_should_clear_current_user_on_id_missing( 17 | db, current_user): 18 | current_user['user_id'] = 999123999456 19 | controller = UserController(db, current_user) 20 | with pytest.raises(ControllerException): 21 | await controller.read({'id': current_user['user_id']}) 22 | assert current_user == {} 23 | -------------------------------------------------------------------------------- /spec/playlist_item.yml: -------------------------------------------------------------------------------- 1 | PlaylistItem: 2 | type: object 3 | properties: 4 | id: 5 | description: Playlist item id 6 | type: string 7 | playlist_id: 8 | $ref: 'playlist.yml#/Playlist/properties/id' 9 | playlist_provider_id: 10 | $ref: 'provider.yml#/Provider/properties/id' 11 | rank: 12 | description: Playlist item rank 13 | track_id: 14 | $ref: 'track.yml#/Track/properties/id' 15 | track_provider_id: 16 | $ref: 'provider.yml#/Provider/properties/id' 17 | created: 18 | description: Playlist item creation 19 | format: date-time 20 | type: string 21 | updated: 22 | description: Playlist item update 23 | format: date-time 24 | type: string 25 | required: 26 | - id 27 | - playlist_id 28 | - playlist_provider_id 29 | - rank 30 | - track_provider_id 31 | - track_id 32 | -------------------------------------------------------------------------------- /spec/favourite_item.yml: -------------------------------------------------------------------------------- 1 | favouriteItem: 2 | type: object 3 | properties: 4 | id: 5 | description: favourite item id 6 | type: string 7 | favourite_id: 8 | $ref: 'favourite.yml#/Favourite/properties/id' 9 | favourite_provider_id: 10 | $ref: 'provider.yml#/Provider/properties/id' 11 | rank: 12 | description: favourite item rank 13 | track_id: 14 | $ref: 'track.yml#/Track/properties/id' 15 | track_provider_id: 16 | $ref: 'provider.yml#/Provider/properties/id' 17 | created: 18 | description: favourite item creation 19 | format: date-time 20 | type: string 21 | updated: 22 | description: favourite item update 23 | format: date-time 24 | type: string 25 | required: 26 | - id 27 | - favourite_id 28 | - favourite_provider_id 29 | - rank 30 | - track_provider_id 31 | - track_id 32 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/tracklist_item.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.tracklist_item 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import sqlalchemy as sql 9 | import sqlalchemy.orm as orm 10 | from sqlalchemy.ext.declarative import declared_attr 11 | 12 | 13 | class TracklistItemMixin(object): 14 | 15 | id = sql.Column(sql.Integer) 16 | 17 | account_provider_id = sql.Column(sql.String(16), nullable=False) 18 | account_id = sql.Column(sql.String(32), nullable=False) 19 | 20 | @declared_attr 21 | def account(cls): 22 | return orm.relation( 23 | 'Account', 24 | single_parent=True) 25 | 26 | track_provider_id = sql.Column(sql.String(16), nullable=False) 27 | track_id = sql.Column(sql.String(128), nullable=False) 28 | -------------------------------------------------------------------------------- /src/cloudplayer/api/routing.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.routing 3 | ~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import re 9 | 10 | from tornado.routing import Matcher 11 | from tornado.util import basestring_type 12 | 13 | 14 | class ProtocolMatches(Matcher): 15 | """Matches requests based on `protocol_pattern` regex.""" 16 | 17 | def __init__(self, protocol_pattern): 18 | if isinstance(protocol_pattern, basestring_type): 19 | if not protocol_pattern.endswith('$'): 20 | protocol_pattern += '$' 21 | self.protocol_pattern = re.compile(protocol_pattern) 22 | else: 23 | self.protocol_pattern = protocol_pattern 24 | 25 | def match(self, request): 26 | if self.protocol_pattern.match(request.protocol): 27 | return {} 28 | return None 29 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/test_provider.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sql 2 | 3 | from cloudplayer.api.model.provider import Provider 4 | import cloudplayer.api.model.base as model 5 | 6 | 7 | def test_provider_model_should_create_table(db): 8 | provider = sql.Table( 9 | 'provider', model.Base.metadata, autoload=True, 10 | autoload_with=db) 11 | assert provider.exists(db.connection()) 12 | 13 | 14 | def test_provider_model_can_be_created(db): 15 | provider = Provider( 16 | id='abcd') 17 | db.add(provider) 18 | db.commit() 19 | db.expunge(provider) 20 | assert db.query(Provider).get('abcd') 21 | 22 | 23 | def test_provider_should_provider_access_to_api_keys(db): 24 | dummy = Provider(id='dummy') 25 | db.add(dummy) 26 | db.commit() 27 | assert dummy.client_id is None 28 | cloudplayer = db.query(Provider).get('cloudplayer') 29 | assert cloudplayer.client_id == 'cp-api-key' 30 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/access/test_action.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from cloudplayer.api.access import (Anything, Create, Delete, Query, Read, 4 | Update) 5 | 6 | 7 | def test_action_should_bind_to_target(): 8 | target = mock.Mock() 9 | action = Anything(target) 10 | assert action._target is target 11 | 12 | 13 | def test_actions_for_extended_crud_are_available_in_module(): 14 | assert Anything() 15 | assert Create() 16 | assert Read() 17 | assert Update() 18 | assert Delete() 19 | assert Query() 20 | 21 | 22 | def test_anything_action_should_equal_anything_else(): 23 | assert Anything() == Anything() 24 | assert Anything() == Create() 25 | assert Anything() == object() 26 | 27 | 28 | def test_operations_should_equal_only_same_action_classes(): 29 | assert Create() == Create() 30 | assert Create() != Delete() 31 | assert Create() != object() 32 | -------------------------------------------------------------------------------- /scripts/install-as-root.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | 4 | # create cloudplayer service user 5 | id -u cloudplayer &> /dev/null || useradd -m -U -d /srv/cloudplayer cloudplayer 6 | 7 | # update and install dependencies 8 | add-apt-repository -y \ 9 | ppa:jonathonf/python-3.6 10 | apt-get update 11 | apt-get install -y \ 12 | redis-server \ 13 | supervisor \ 14 | nginx \ 15 | htop \ 16 | python3.6 \ 17 | python3.6-venv \ 18 | python3.6-dev \ 19 | python3-pycurl \ 20 | python-pip \ 21 | python-pip-whl \ 22 | libcurl4-openssl-dev \ 23 | libssl-dev \ 24 | postgresql 25 | 26 | # enable nginx site 27 | rm /etc/nginx/sites-enabled/default &> /dev/null || echo "default disabled" 28 | cp cloudplayer.conf /etc/nginx/sites-enabled/cloudplayer.conf 29 | 30 | # copy supervisor config 31 | cp api.conf /etc/supervisor/conf.d/api.conf 32 | 33 | # copy current app distribution 34 | mkdir -p /srv/cloudplayer 35 | cp -r . /srv/cloudplayer 36 | chown -R cloudplayer:cloudplayer /srv/cloudplayer 37 | -------------------------------------------------------------------------------- /spec/track.yml: -------------------------------------------------------------------------------- 1 | Track: 2 | type: object 3 | properties: 4 | id: 5 | description: Track id 6 | type: string 7 | provider_id: 8 | $ref: 'provider.yml#/Provider/properties/id' 9 | account: 10 | $ref: 'account.yml#/EmbeddedAccount' 11 | aspect_ratio: 12 | description: Player aspect ratio 13 | format: double 14 | type: number 15 | duration: 16 | description: Track duration 17 | format: int32 18 | type: integer 19 | favourite_count: 20 | description: Favourite count 21 | format: int32 22 | type: integer 23 | image: 24 | $ref: 'image.yml#/EmbeddedImage' 25 | play_count: 26 | description: Play count 27 | format: int32 28 | type: integer 29 | title: 30 | description: Track title 31 | type: string 32 | created: 33 | description: Track creation 34 | format: date-time 35 | type: string 36 | required: 37 | - id 38 | - provider_id 39 | - account 40 | - title 41 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/favourite.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.favourite 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import sqlalchemy.orm as orm 9 | 10 | from cloudplayer.api.access import Allow, Fields, Owner, Read 11 | from cloudplayer.api.model import Base 12 | from cloudplayer.api.model.tracklist import TracklistMixin 13 | 14 | 15 | class Favourite(TracklistMixin, Base): 16 | 17 | __acl__ = ( 18 | Allow(Owner, Read, Fields( 19 | 'id', 20 | 'provider_id', 21 | 'account_id', 22 | 'account_provider_id' 23 | )), 24 | ) 25 | 26 | account = orm.relation( 27 | 'Account', 28 | back_populates='favourite', 29 | viewonly=True) 30 | 31 | items = orm.relation( 32 | 'FavouriteItem', 33 | cascade='all, delete-orphan', 34 | order_by='FavouriteItem.created', 35 | single_parent=True) 36 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/provider.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.provider 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import sqlalchemy as sql 9 | import sqlalchemy.orm as orm 10 | import tornado.options as opt 11 | 12 | from cloudplayer.api.access import Allow, Everyone, Fields, Query, Read 13 | from cloudplayer.api.model import Base 14 | 15 | 16 | class Provider(Base): 17 | 18 | __acl__ = ( 19 | Allow(Everyone, Read, Fields( 20 | 'id', 21 | 'client_id' 22 | )), 23 | Allow(Everyone, Query, Fields('id')) 24 | ) 25 | __table_args__ = ( 26 | sql.PrimaryKeyConstraint( 27 | 'id'), 28 | ) 29 | 30 | id = sql.Column(sql.String(12)) 31 | 32 | provider_id = orm.synonym('id') 33 | 34 | @property 35 | def client_id(self): 36 | if self.id in opt.options['providers']: 37 | return opt.options[self.id]['api_key'] 38 | -------------------------------------------------------------------------------- /spec/account.yml: -------------------------------------------------------------------------------- 1 | EmbeddedAccount: 2 | type: object 3 | properties: 4 | id: 5 | description: Account id 6 | type: string 7 | provider_id: 8 | $ref: 'provider.yml#/Provider/properties/id' 9 | image: 10 | $ref: 'image.yml#/EmbeddedImage' 11 | title: 12 | description: Account title 13 | type: string 14 | required: 15 | - id 16 | - provider_id 17 | 18 | ListedAccount: 19 | allOf: 20 | - $ref: '#/EmbeddedAccount' 21 | - type: object 22 | properties: 23 | connected: 24 | description: Connection status 25 | type: boolean 26 | 27 | Account: 28 | allOf: 29 | - $ref: '#/ListedAccount' 30 | - type: object 31 | properties: 32 | user_id: 33 | $ref: 'user.yml#/User/properties/id' 34 | favourite_id: 35 | $ref: 'favourite.yml#/Favourite/properties/id' 36 | created: 37 | description: Account creation 38 | format: date-time 39 | type: string 40 | updated: 41 | description: Account update 42 | format: date-time 43 | type: string 44 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/track_comment.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.track_comment 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.access import Allow, Everyone, Read, Fields 9 | from cloudplayer.api.model import Transient 10 | 11 | 12 | class TrackComment(Transient): 13 | 14 | __acl__ = ( 15 | Allow(Everyone, Read, Fields( 16 | 'id', 17 | 'provider_id', 18 | 'account.id', 19 | 'account.provider_id', 20 | 'account.title', 21 | 'account.image.small', 22 | 'account.image.medium', 23 | 'account.image.large', 24 | 'body', 25 | 'timestamp', 26 | 'track_id', 27 | 'track_provider_id', 28 | 'created' 29 | )), 30 | ) 31 | 32 | id = None 33 | provider_id = None 34 | 35 | account = None 36 | 37 | body = None 38 | timestamp = None 39 | track_id = None 40 | track_provider_id = None 41 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/test_util.py: -------------------------------------------------------------------------------- 1 | from cloudplayer.api.util import gen_token, chunk_range, squeeze 2 | 3 | 4 | def test_token_generator_should_create_token(): 5 | token = gen_token(10, 'abc') 6 | assert len(token) == 10 7 | assert all([t in 'abc' for t in token]) 8 | 9 | 10 | def test_chunk_range_should_range_and_split_numbers(): 11 | assert chunk_range(0, 0, 0) == [(0, None)] 12 | assert chunk_range(3, 0, 0) == [(0, None)] 13 | assert chunk_range(3, 0, 1) == [(0, None)] 14 | assert chunk_range(3, 0, 2) == [(0, 2), (2, None)] 15 | assert chunk_range(3, 3, 2) == [(0, None)] 16 | assert chunk_range(3, 0, 3) == [(0, 1), (1, 2), (2, None)] 17 | assert chunk_range(3, 2, 3) == [(0, 2), (2, None)] 18 | assert chunk_range(3, 0, 4) == [(0, 1), (1, 2), (2, None)] 19 | 20 | 21 | def test_squeeze_should_reduce_string_to_non_whitespace_chars(): 22 | assert squeeze('') == '' 23 | assert squeeze('hello') == 'hello' 24 | assert squeeze('good bye') == 'goodbye' 25 | assert squeeze(""" 26 | multiple 27 | lines 28 | and words . 29 | """) == 'multiplelinesandwords.' 30 | -------------------------------------------------------------------------------- /src/cloudplayer/api/util.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.util 3 | ~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import math 9 | import multiprocessing 10 | import random 11 | import string 12 | 13 | 14 | def gen_token(n, alphabet=string.ascii_lowercase + string.digits): 15 | """Cryptographically sufficient token generator for length `n`.""" 16 | urand = random.SystemRandom() 17 | return ''.join(urand.choice(alphabet) for i in range(n)) 18 | 19 | 20 | def chunk_range(size, min_step=10, chunks=multiprocessing.cpu_count()): 21 | """Evenly chunk a range of `size` into `chunks` with `min_step`.""" 22 | size = max(size, 1) 23 | step = max(int(math.ceil(size / max(chunks, 1))), min_step) 24 | ranges = list(zip(range(0, size, step), range(step, size + step, step))) 25 | # Inject `None` as a slice operator to catch the last item 26 | ranges[-1] = (ranges[-1][0], None) 27 | return ranges 28 | 29 | 30 | def squeeze(string): 31 | """Squeezes any whitespaces or linebreaks out of `string`.""" 32 | return ''.join(string.split()) 33 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/test_favourite_item.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sql 2 | 3 | from cloudplayer.api.model.favourite import Favourite 4 | from cloudplayer.api.model.favourite_item import FavouriteItem 5 | import cloudplayer.api.model.base as model 6 | 7 | 8 | def test_favourite_item_model_should_create_table(db): 9 | favourite_item = sql.Table( 10 | 'favouriteitem', model.Base.metadata, autoload=True, 11 | autoload_with=db) 12 | assert favourite_item.exists(db.connection()) 13 | 14 | 15 | def test_favourite_item_model_can_be_created(current_user, db): 16 | favourite_item = FavouriteItem( 17 | favourite_provider_id='cloudplayer', 18 | favourite=Favourite( 19 | provider_id='cloudplayer', 20 | account_id=current_user['cloudplayer'], 21 | account_provider_id='cloudplayer'), 22 | account_provider_id='cloudplayer', 23 | account_id=current_user['cloudplayer'], 24 | track_provider_id='cloudplayer', 25 | track_id='abcd-1234') 26 | db.add(favourite_item) 27 | db.commit() 28 | favourite_item_id = favourite_item.id 29 | db.expunge_all() 30 | assert db.query(FavouriteItem).get(favourite_item_id) 31 | -------------------------------------------------------------------------------- /aws.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os.path 3 | __path__ = os.path.dirname(os.path.realpath(__file__)) 4 | sys.path.insert(0, __path__) 5 | 6 | debug = False 7 | xheaders = True 8 | static_path = '/srv/cloudplayer/static' 9 | redirect_state = 'v3' 10 | websocket_ping_interval = 10 11 | websocket_ping_timeout = 30 12 | 13 | providers = [ 14 | 'youtube', 15 | 'soundcloud', 16 | 'cloudplayer'] 17 | 18 | allowed_origins = [ 19 | 'https://cloud-player.io', 20 | 'http://localhost:8080', 21 | 'http://localhost:4200'] 22 | 23 | jwt_secret = 'secret' 24 | 25 | youtube = { 26 | 'key': 'key', 27 | 'api_key': 'api_key', 28 | 'secret': 'secret', 29 | 'redirect_uri': 'https://api.cloud-player.io/youtube'} 30 | 31 | soundcloud = { 32 | 'key': 'key', 33 | 'api_key': 'api_key', 34 | 'secret': 'secret', 35 | 'redirect_uri': 'https://api.cloud-player.io/soundcloud'} 36 | 37 | cloudplayer = { 38 | 'key': 'key', 39 | 'api_key': 'api_key', 40 | 'secret': 'secret', 41 | 'redirect_uri': 'https://api.cloud-player.io/cloudplayer'} 42 | 43 | bugsnag = { 44 | 'api_key': 'api_key', 45 | 'project_root': '/usr/local/cloudplayer' 46 | } 47 | 48 | try: 49 | from private import * 50 | except ImportError: 51 | pass 52 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/test_playlist_item.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sql 2 | 3 | from cloudplayer.api.model.playlist import Playlist 4 | from cloudplayer.api.model.playlist_item import PlaylistItem 5 | import cloudplayer.api.model.base as model 6 | 7 | 8 | def test_playlist_item_model_should_create_table(db): 9 | playlist_item = sql.Table( 10 | 'playlistitem', model.Base.metadata, autoload=True, 11 | autoload_with=db) 12 | assert playlist_item.exists(db.connection()) 13 | 14 | 15 | def test_playlist_item_model_can_be_created(current_user, db): 16 | playlist_item = PlaylistItem( 17 | rank='aaa', 18 | playlist_provider_id='cloudplayer', 19 | playlist=Playlist( 20 | provider_id='cloudplayer', 21 | account_id=current_user['cloudplayer'], 22 | account_provider_id='cloudplayer', 23 | title='5678-abcd'), 24 | account_provider_id='cloudplayer', 25 | account_id=current_user['cloudplayer'], 26 | track_provider_id='cloudplayer', 27 | track_id='abcd-1234') 28 | db.add(playlist_item) 29 | db.commit() 30 | playlist_item_id = playlist_item.id 31 | db.expunge_all() 32 | assert db.query(PlaylistItem).get(playlist_item_id) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [](https://travis-ci.org/Cloud-Player/api) 2 | [](https://cloud-player.github.io/api/restapi.html) 3 | [](https://cloud-player.github.io/api/asyncapi.html) 4 | [](https://codecov.io/gh/Cloud-Player/api) 5 | [](https://www.python.org/downloads) 6 | 7 | # Cloud Player API 8 | 9 | This repository contains the REST and ASYNC backend API for the Cloud-Player 10 | web and desktop applications. It is build using the tornado framework and integrates 11 | a PostgreSQL database over SQLAlchemy. The pub/sub system is implemented with Redis. 12 | There is a continuous testing and deployment toolchain setup with travis and AWS. 13 | 14 | ## require 15 | 16 | ``` 17 | python==3.6 18 | pip>=9 19 | npm>=3.5 20 | postgresql>=10 21 | redis>=3 22 | ``` 23 | 24 | ## install 25 | ``` 26 | make 27 | ``` 28 | 29 | ## develop 30 | ``` 31 | bin/api 32 | ``` 33 | 34 | ## test 35 | ``` 36 | bin/test 37 | ``` 38 | -------------------------------------------------------------------------------- /spec/playlist.yml: -------------------------------------------------------------------------------- 1 | Playlist: 2 | type: object 3 | properties: 4 | id: 5 | description: Playlist id 6 | type: string 7 | provider_id: 8 | $ref: 'provider.yml#/Provider/properties/id' 9 | account_id: 10 | $ref: 'account.yml#/Account/properties/id' 11 | account_provider_id: 12 | $ref: 'provider.yml#/Provider/properties/id' 13 | description: 14 | description: Playlist description 15 | type: string 16 | follower_count: 17 | description: Follower count 18 | format: int32 19 | type: integer 20 | image: 21 | $ref: 'image.yml#/EmbeddedImage' 22 | items: 23 | description: Playlist items 24 | items: 25 | $ref: 'playlist_item.yml#/PlaylistItem' 26 | type: array 27 | public: 28 | description: Public state 29 | type: boolean 30 | title: 31 | description: Playlist title 32 | type: string 33 | created: 34 | description: Playlist creation 35 | format: date-time 36 | type: string 37 | updated: 38 | description: Playlist update 39 | format: date-time 40 | type: string 41 | required: 42 | - id 43 | - provider_id 44 | - account_id 45 | - account_provider_id 46 | - items 47 | - title 48 | -------------------------------------------------------------------------------- /dev.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os.path 3 | __path__ = os.path.dirname(os.path.realpath(__file__)) 4 | sys.path.insert(0, __path__) 5 | 6 | debug = True 7 | xheaders = False 8 | static_path = 'static' 9 | redirect_state = 'dev' 10 | websocket_ping_interval = 5 11 | websocket_ping_timeout = 15 12 | 13 | providers = [ 14 | 'youtube', 15 | 'soundcloud', 16 | 'cloudplayer'] 17 | 18 | allowed_origins = [ 19 | 'http://localhost:4200', 20 | 'http://localhost:8040', 21 | 'http://localhost:8080'] 22 | 23 | jwt_secret = 'secret' 24 | public_scheme = 'http' 25 | public_domain = 'localhost' 26 | 27 | youtube = { 28 | 'key': 'key', 29 | 'api_key': 'api_key', 30 | 'secret': 'secret', 31 | 'redirect_uri': 'http://localhost:8040/youtube'} 32 | 33 | soundcloud = { 34 | 'key': 'key', 35 | 'api_key': 'api_key', 36 | 'secret': 'secret', 37 | 'redirect_uri': 'http://localhost:8040/soundcloud'} 38 | 39 | cloudplayer = { 40 | 'key': 'key', 41 | 'api_key': 'api_key', 42 | 'secret': 'secret', 43 | 'redirect_uri': 'http://localhost:8040/cloudplayer'} 44 | 45 | bugsnag = { 46 | 'api_key': 'api_key', 47 | 'project_root': '/usr/local/cloudplayer' 48 | } 49 | 50 | try: 51 | from private import * 52 | except ImportError: 53 | pass 54 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/playlist.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.controller.playlist 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from sqlalchemy.sql.expression import func 9 | 10 | from cloudplayer.api.access import Available 11 | from cloudplayer.api.controller import Controller 12 | from cloudplayer.api.model.playlist import Playlist 13 | 14 | 15 | class PlaylistController(Controller): 16 | 17 | __model__ = Playlist 18 | 19 | async def read(self, ids, fields=Available): 20 | if ids['id'] == 'random': 21 | provider_id = ids['provider_id'] 22 | account = self.get_account(provider_id) 23 | kw = dict( 24 | account_id=account.id, 25 | account_provider_id=account.provider_id, 26 | provider_id=provider_id) 27 | self.policy.grant_query(account, self.__model__, kw) 28 | ids = dict( 29 | provider_id=provider_id) 30 | query = await self.query(ids, kw) 31 | random = query.order_by(func.random()).first() 32 | if random: 33 | ids['id'] = random.id 34 | return await super().read(ids, fields=fields) 35 | -------------------------------------------------------------------------------- /source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Cloud-Player API documentation build configuration file 5 | # 6 | 7 | import os.path as path 8 | import sys 9 | import mock 10 | import setuptools 11 | from recommonmark.parser import CommonMarkParser 12 | 13 | sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) 14 | with mock.patch.object(setuptools, 'setup') as mock_setup: 15 | import setup as _setup # NOQA 16 | package_info = mock_setup.call_args[1] 17 | 18 | 19 | extensions = [ 20 | 'sphinx.ext.autodoc', 21 | 'sphinx.ext.todo', 22 | 'sphinx.ext.viewcode'] 23 | 24 | source_parsers = {'.md': CommonMarkParser} 25 | source_suffix = ['.rst', '.md'] 26 | master_doc = 'index' 27 | 28 | project = package_info['name'] 29 | copyright = package_info['license'] 30 | author = package_info['author'] 31 | version = package_info['version'] 32 | release = version.replace('.dev0', '') 33 | 34 | exclude_patterns = [] 35 | pygments_style = 'sphinx' 36 | todo_include_todos = True 37 | htmlhelp_basename = 'Cloud-PlayerAPIdoc' 38 | html_theme = 'alabaster' 39 | html_theme_options = { 40 | 'show_related': True 41 | } 42 | html_static_path = ['../static'] 43 | html_sidebars = { 44 | '**': [ 45 | 'relations.html', 46 | 'searchbox.html', 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/favourite_item.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.favourite_item 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.favourite_item import ( 9 | CloudplayerFavouriteItemController, 10 | SoundcloudFavouriteItemController, 11 | YoutubeFavouriteItemController) 12 | from cloudplayer.api.handler import CollectionMixin, EntityMixin 13 | from cloudplayer.api.http import HTTPHandler 14 | 15 | 16 | class CloudplayerEntity(EntityMixin, HTTPHandler): 17 | 18 | __controller__ = CloudplayerFavouriteItemController 19 | 20 | SUPPORTED_METHODS = ('DELETE', 'OPTIONS') 21 | 22 | 23 | class CloudplayerCollection(CollectionMixin, HTTPHandler): 24 | 25 | __controller__ = CloudplayerFavouriteItemController 26 | 27 | SUPPORTED_METHODS = ('GET', 'POST', 'OPTIONS') 28 | 29 | 30 | class SoundcloudCollection(CollectionMixin, HTTPHandler): 31 | 32 | __controller__ = SoundcloudFavouriteItemController 33 | 34 | SUPPORTED_METHODS = ('GET', 'OPTIONS') 35 | 36 | 37 | class YoutubeCollection(CollectionMixin, HTTPHandler): 38 | 39 | __controller__ = YoutubeFavouriteItemController 40 | 41 | SUPPORTED_METHODS = ('GET', 'OPTIONS') 42 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/user.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.user 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import sqlalchemy as sql 9 | import sqlalchemy.orm as orm 10 | 11 | from cloudplayer.api.access import Allow, Child, Fields, Read 12 | from cloudplayer.api.model import Base 13 | 14 | 15 | class User(Base): 16 | 17 | __acl__ = ( 18 | Allow(Child, Read, Fields( 19 | 'id', 20 | 'provider_id', 21 | 'accounts.id', 22 | 'accounts.provider_id', 23 | 'accounts.connected', 24 | 'accounts.favourite_id', 25 | 'accounts.image.id', 26 | 'accounts.image.small', 27 | 'accounts.image.medium', 28 | 'accounts.image.large', 29 | 'accounts.title', 30 | 'created', 31 | 'updated' 32 | )), 33 | ) 34 | __table_args__ = ( 35 | sql.PrimaryKeyConstraint( 36 | 'id'), 37 | ) 38 | 39 | id = sql.Column(sql.Integer) 40 | provider_id = 'cloudplayer' 41 | 42 | accounts = orm.relation( 43 | 'Account', 44 | back_populates='user', 45 | uselist=True, 46 | single_parent=True, 47 | cascade='all, delete-orphan') 48 | children = orm.synonym('accounts') 49 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/http/test_account.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.gen_test 5 | def test_account_entity_can_be_read_by_owner(user_fetch, account): 6 | response = yield user_fetch('/account/{}/me'.format(account.provider_id)) 7 | assert response.json()['id'] == account.id 8 | 9 | 10 | @pytest.mark.gen_test 11 | def test_account_returns_all_fields_for_owner(user_fetch, account): 12 | response = yield user_fetch('/account/{}/{}'.format( 13 | account.provider_id, account.id)) 14 | result = response.json() 15 | assert set(result.pop('image').keys()) == { 16 | 'id', 17 | 'small', 18 | 'medium', 19 | 'large'} 20 | assert set(result.keys()) == { 21 | 'id', 22 | 'provider_id', 23 | 'user_id', 24 | 'connected', 25 | 'favourite_id', 26 | 'title', 27 | 'created', 28 | 'updated'} 29 | 30 | 31 | @pytest.mark.gen_test 32 | def test_account_returns_limited_fields_for_others(user_fetch, other): 33 | response = yield user_fetch('/account/{}/{}'.format( 34 | other.provider_id, other.id)) 35 | result = response.json() 36 | assert set(result.pop('image').keys()) == { 37 | 'id', 38 | 'small', 39 | 'medium', 40 | 'large'} 41 | assert set(result.keys()) == { 42 | 'id', 43 | 'provider_id', 44 | 'title'} 45 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/track.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.track 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.track import SoundcloudTrackController 9 | from cloudplayer.api.controller.track import TrackController 10 | from cloudplayer.api.controller.track import YoutubeTrackController 11 | from cloudplayer.api.handler import CollectionMixin, EntityMixin 12 | from cloudplayer.api.http import HTTPHandler 13 | 14 | 15 | class Collection(CollectionMixin, HTTPHandler): 16 | 17 | __controller__ = TrackController 18 | 19 | SUPPORTED_METHODS = ('GET', 'OPTIONS') 20 | 21 | 22 | class SoundcloudEntity(EntityMixin, HTTPHandler): 23 | 24 | __controller__ = SoundcloudTrackController 25 | 26 | SUPPORTED_METHODS = ('GET', 'OPTIONS') 27 | 28 | 29 | class SoundcloudCollection(CollectionMixin, HTTPHandler): 30 | 31 | __controller__ = SoundcloudTrackController 32 | 33 | SUPPORTED_METHODS = ('GET', 'OPTIONS') 34 | 35 | 36 | class YoutubeEntity(EntityMixin, HTTPHandler): 37 | 38 | __controller__ = YoutubeTrackController 39 | 40 | SUPPORTED_METHODS = ('GET', 'OPTIONS') 41 | 42 | 43 | class YoutubeCollection(CollectionMixin, HTTPHandler): 44 | 45 | __controller__ = YoutubeTrackController 46 | 47 | SUPPORTED_METHODS = ('GET', 'OPTIONS') 48 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/tracklist.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.tracklist 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import functools 9 | 10 | import sqlalchemy as sql 11 | import sqlalchemy.orm as orm 12 | from sqlalchemy.ext.declarative import declared_attr 13 | 14 | import cloudplayer.api.util 15 | 16 | 17 | class TracklistMixin(object): 18 | 19 | @declared_attr 20 | def __table_args__(cls): 21 | return ( 22 | sql.PrimaryKeyConstraint( 23 | 'id', 'provider_id'), 24 | sql.ForeignKeyConstraint( 25 | ['provider_id'], 26 | ['provider.id']), 27 | sql.ForeignKeyConstraint( 28 | ['account_id', 'account_provider_id'], 29 | ['account.id', 'account.provider_id']) 30 | ) 31 | 32 | id = sql.Column(sql.String(96), default=functools.partial( 33 | cloudplayer.api.util.gen_token, 16)) 34 | 35 | provider_id = sql.Column(sql.String(16), default='cloudplayer') 36 | 37 | @declared_attr 38 | def provider(cls): 39 | return orm.relation( 40 | 'Provider', 41 | cascade=None, 42 | viewonly=True) 43 | 44 | account_provider_id = sql.Column(sql.String(16), nullable=False) 45 | account_id = sql.Column(sql.String(32), nullable=False) 46 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/favourite_item.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.controller.favourite_item 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.access import Available 9 | from cloudplayer.api.controller import Controller 10 | from cloudplayer.api.controller.track import TrackController 11 | from cloudplayer.api.model.favourite_item import FavouriteItem 12 | 13 | 14 | class FavouriteItemController(Controller): 15 | 16 | __model__ = FavouriteItem 17 | 18 | 19 | class CloudplayerFavouriteItemController(Controller): 20 | 21 | __provider__ = 'cloudplayer' 22 | __model__ = FavouriteItem 23 | 24 | 25 | class SoundcloudFavouriteItemController(Controller): 26 | 27 | __provider__ = 'soundcloud' 28 | __model__ = FavouriteItem 29 | 30 | 31 | class YoutubeFavouriteItemController(Controller): 32 | 33 | __provider__ = 'youtube' 34 | __model__ = FavouriteItem 35 | 36 | async def search(self, ids, kw, fields=Available): 37 | track_controller = TrackController.for_provider( 38 | self.__provider__, self.db, self.current_user) 39 | 40 | track_ids = {'provider_id': self.__provider__, 'ids': []} 41 | track_kw = {'rating': 'like'} 42 | tracks = await track_controller.mread( 43 | track_ids, track_kw, fields=fields) 44 | return tracks 45 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/controller/test_playlist.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import sqlalchemy.orm.util 3 | 4 | from cloudplayer.api.controller.playlist import PlaylistController 5 | from cloudplayer.api.model.playlist import Playlist 6 | 7 | 8 | @pytest.mark.gen_test 9 | async def test_playlist_controller_should_resolve_random_id(db, current_user): 10 | playlist = Playlist( 11 | account_id=current_user['cloudplayer'], 12 | account_provider_id='cloudplayer', 13 | provider_id='cloudplayer', 14 | title='test') 15 | 16 | db.add(playlist) 17 | db.commit() 18 | playlist_id = playlist.id 19 | db.expunge(playlist) 20 | controller = PlaylistController(db, current_user) 21 | ids = {'id': 'random', 'provider_id': 'cloudplayer'} 22 | playlist = await controller.read(ids) 23 | assert playlist.id == playlist_id 24 | 25 | 26 | @pytest.mark.gen_test 27 | async def test_playlist_controller_should_create_entity_and_read_result( 28 | db, current_user, account, user): 29 | controller = PlaylistController(db, current_user) 30 | ids = {'provider_id': 'cloudplayer'} 31 | kw = {'title': 'foo', 'account_id': account.id, 32 | 'account_provider_id': account.provider_id} 33 | entity = await controller.create(ids, kw) 34 | assert isinstance(entity.id, str) 35 | assert entity.provider_id == 'cloudplayer' 36 | assert entity.title == 'foo' 37 | assert entity.account is account 38 | assert sqlalchemy.orm.util.object_state(entity).persistent 39 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/access/test_fields.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from cloudplayer.api.access import Available, Fields 4 | 5 | 6 | def test_fields_should_be_eager_with_values_and_lazy_with_targets(): 7 | fields = Fields('one', 'two', 'six') 8 | assert fields._target is None 9 | assert fields._values == {'one', 'two', 'six'} 10 | target = object() 11 | assert fields in fields(target) 12 | assert fields(target)._target is target 13 | assert set(fields) == {'one', 'two', 'six'} 14 | 15 | 16 | def test_fields_should_check_field_containment_against_values(): 17 | fields = Fields('one', 'two', 'six') 18 | assert 'one' in fields 19 | assert 'zero' not in fields 20 | f1 = Fields('one', 'five') 21 | assert f1 not in fields 22 | f2 = Fields('one', 'two', 'six', 'nine') 23 | assert f2 not in fields 24 | f3 = Fields('one', 'six') 25 | assert f3 in fields 26 | 27 | 28 | def test_available_fields_init_eagerly_and_keep_empty_values(): 29 | target = mock.Mock() 30 | f1 = Available(target) 31 | assert f1._target is target 32 | assert len(list(f1)) == 0 33 | f2 = Available(target)(target) 34 | assert f2._target is target 35 | assert len(list(f2)) == 0 36 | 37 | 38 | def test_available_fields_are_always_contained_in_specific_fields(): 39 | target = mock.Mock() 40 | f1 = Available(target) 41 | f2 = Fields('ten', 'six', 'two') 42 | assert f1 in f2 43 | f1 = Available(target) 44 | f2 = Fields() 45 | assert f1 in f2 46 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/token.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.token 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import functools 9 | 10 | import sqlalchemy as sql 11 | import sqlalchemy.orm as orm 12 | 13 | from cloudplayer.api.access import (Allow, Create, Everyone, Fields, Read, 14 | Update) 15 | from cloudplayer.api.model import Base 16 | from cloudplayer.api.util import gen_token 17 | 18 | 19 | class Token(Base): 20 | 21 | __acl__ = ( 22 | Allow(Everyone, Create, Fields()), 23 | Allow(Everyone, Read, Fields( 24 | 'id', 25 | 'claimed' 26 | )), 27 | Allow(Everyone, Update, Fields( 28 | 'claimed', 29 | 'account_id', 30 | 'account_provider_id' 31 | )) 32 | ) 33 | __table_args__ = ( 34 | sql.PrimaryKeyConstraint( 35 | 'id'), 36 | sql.ForeignKeyConstraint( 37 | ['account_id', 'account_provider_id'], 38 | ['account.id', 'account.provider_id']) 39 | ) 40 | 41 | id = sql.Column(sql.String(16), default=functools.partial(gen_token, 6)) 42 | 43 | claimed = sql.Column(sql.Boolean, default=False) 44 | 45 | account_provider_id = sql.Column(sql.String(16)) 46 | account_id = sql.Column(sql.String(32)) 47 | account = orm.relation( 48 | 'Account', 49 | single_parent=True) 50 | parent = orm.synonym('account') 51 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/test_routing.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | import re 3 | 4 | import pytest 5 | import tornado.web 6 | 7 | from cloudplayer.api.routing import ProtocolMatches 8 | 9 | 10 | def test_protocol_matcher_should_ensure_regex_termination(): 11 | matcher = ProtocolMatches('proto') 12 | assert matcher.protocol_pattern.pattern == 'proto$' 13 | 14 | 15 | def test_protocol_matcher_should_allow_regex_as_pattern(): 16 | matcher = ProtocolMatches(re.compile('^reg.*ex$')) 17 | assert matcher.protocol_pattern.pattern == '^reg.*ex$' 18 | 19 | 20 | @pytest.mark.gen_test 21 | async def test_protocol_matcher_should_route_requests_based_on_protocol(): 22 | 23 | async def app_one(*_): 24 | return 'ONE' 25 | 26 | async def app_two(*_): 27 | return 'TWO' 28 | 29 | async def catch_all(*_): 30 | return 'ALL' 31 | 32 | request = mock.Mock() 33 | routes = [ 34 | (ProtocolMatches('^one[s]?$'), app_one), 35 | (ProtocolMatches('two'), app_two), 36 | (ProtocolMatches('.*'), catch_all)] 37 | base_app = tornado.web.Application(routes) 38 | 39 | async def assert_route(proto, expected): 40 | request.protocol = proto 41 | delegate = base_app.find_handler(request) 42 | response = await delegate.request_callback(base_app, request) 43 | assert response == expected 44 | 45 | await assert_route('ones', 'ONE') 46 | await assert_route('one', 'ONE') 47 | await assert_route('two', 'TWO') 48 | await assert_route('three', 'ALL') 49 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | setup( 5 | name='cloudplayer.api', 6 | version='0.5.0.dev0', 7 | author='Nicolas Drebenstedt', 8 | author_email='hello@cloud-player.io', 9 | url='https://cloud-player.io', 10 | description='REST API for Cloud-Player', 11 | packages=find_packages('src'), 12 | package_dir={'': 'src'}, 13 | include_package_data=True, 14 | zip_safe=False, 15 | license='Apache-2.0', 16 | namespace_packages=['cloudplayer'], 17 | setup_requires=['setuptools_git'], 18 | install_requires=[ 19 | 'bugsnag', 20 | 'isodate', 21 | 'psycopg2-binary', 22 | 'pycurl', 23 | 'PyJWT', 24 | 'redis', 25 | 'setuptools', 26 | 'sqlalchemy', 27 | 'tornado' 28 | ], 29 | extras_require={ 30 | 'test': [ 31 | 'asynctest', 32 | 'codecov', 33 | 'pylint', 34 | 'pytest-cov', 35 | 'pytest-pep8', 36 | 'pytest-postgresql==1.3.3', 37 | 'pytest-redis', 38 | 'pytest-remove-stale-bytecode', 39 | 'pytest-timeout', 40 | 'pytest-tornado', 41 | 'pytest' 42 | ], 43 | 'doc': [ 44 | 'recommonmark', 45 | 'sphinx-autobuild', 46 | 'sphinx', 47 | ] 48 | }, 49 | entry_points={ 50 | 'console_scripts': [ 51 | 'api=cloudplayer.api.app:main', 52 | 'pytest=pytest:main [test]', 53 | 'test=pytest:main [test]' 54 | ] 55 | } 56 | ) 57 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/session.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.session 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import functools 9 | 10 | import sqlalchemy as sql 11 | import sqlalchemy.orm as orm 12 | 13 | from cloudplayer.api.access import Allow, Create, Deny, Fields, Owner, Read 14 | from cloudplayer.api.model import Base 15 | from cloudplayer.api.util import gen_token 16 | 17 | 18 | class Session(Base): 19 | 20 | __acl__ = ( 21 | Allow(Owner, Create, Fields( 22 | 'account_id', 23 | 'account_provider_id', 24 | 'system', 25 | 'browser', 26 | 'screen' 27 | )), 28 | Allow(Owner, Read, Fields()), 29 | Deny() 30 | ) 31 | __table_args__ = ( 32 | sql.PrimaryKeyConstraint( 33 | 'id'), 34 | sql.ForeignKeyConstraint( 35 | ['account_id', 'account_provider_id'], 36 | ['account.id', 'account.provider_id']) 37 | ) 38 | 39 | id = sql.Column(sql.String(64), default=functools.partial(gen_token, 64)) 40 | 41 | account_id = sql.Column(sql.String(32), nullable=False) 42 | account_provider_id = sql.Column(sql.String(16), nullable=False) 43 | account = orm.relation( 44 | 'Account', 45 | back_populates='sessions', 46 | uselist=False, 47 | single_parent=True) 48 | parent = orm.synonym('account') 49 | 50 | system = sql.Column(sql.String(64), nullable=False) 51 | browser = sql.Column(sql.String(64), nullable=False) 52 | screen = sql.Column(sql.String(32), nullable=False) 53 | -------------------------------------------------------------------------------- /cloudplayer.conf: -------------------------------------------------------------------------------- 1 | server_tokens off; 2 | 3 | upstream api { 4 | server 127.0.0.1:8040; 5 | } 6 | 7 | server { 8 | listen 80 default_server; 9 | listen [::]:80 default_server; 10 | server_name api.cloud-player.io; 11 | return 301 https://$host$request_uri; 12 | } 13 | 14 | server { 15 | listen 443 ssl http2; 16 | listen [::]:443 ssl http2; 17 | server_name api.cloud-player.io; 18 | 19 | keepalive_timeout 70; 20 | 21 | ssl_certificate /etc/nginx/ssl/api.cloud-player.io.crt; 22 | ssl_certificate_key /etc/nginx/ssl/api.cloud-player.io.key; 23 | 24 | ssl_session_cache shared:SSL:50m; 25 | ssl_session_timeout 1d; 26 | ssl_session_tickets off; 27 | 28 | ssl_dhparam /etc/nginx/ssl/dhparam.pem; 29 | 30 | ssl_prefer_server_ciphers on; 31 | 32 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 33 | 34 | access_log /var/log/nginx/api.access.log; 35 | error_log /var/log/nginx/api.error.log error; 36 | 37 | location / { 38 | proxy_pass http://api; 39 | proxy_pass_header Server; 40 | proxy_redirect off; 41 | proxy_set_header Host $http_host; 42 | proxy_set_header X-Real-IP $remote_addr; 43 | proxy_set_header X-Scheme $scheme; 44 | } 45 | 46 | location /websocket { 47 | proxy_pass http://api/websocket; 48 | proxy_pass_header Server; 49 | proxy_redirect off; 50 | proxy_http_version 1.1; 51 | proxy_set_header Connection "upgrade"; 52 | proxy_set_header Host $http_host; 53 | proxy_set_header Upgrade $http_upgrade; 54 | proxy_set_header X-Real-IP $remote_addr; 55 | proxy_set_header X-Scheme $scheme; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/cloudplayer/api/access/action.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.access.action 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | 9 | 10 | class Action(object): 11 | """Base class for access control list entities describing an action. 12 | 13 | Actions are intended by a principal and executed on a target. The target 14 | is bound to an action instance in its constructor. 15 | 16 | The equality operator is implemented to check whether the intent should 17 | be granted or not. 18 | """ 19 | 20 | def __init__(self, target=None): 21 | self._target = target 22 | 23 | def __eq__(self, other): 24 | raise NotImplementedError() # pragma: no cover 25 | 26 | 27 | class Anything(Action): 28 | """Wildcard action that describes any of the below.""" 29 | 30 | def __eq__(self, other): 31 | return True 32 | 33 | 34 | class Operation(Action): 35 | """Base class for specific CRUD operations.""" 36 | 37 | def __eq__(self, other): 38 | return type(self) is type(other) 39 | 40 | 41 | class Create(Operation): 42 | """Action describing a create operation.""" 43 | 44 | pass 45 | 46 | 47 | class Read(Operation): 48 | """Action describing a read operation.""" 49 | 50 | pass 51 | 52 | 53 | class Update(Operation): 54 | """Action describing a update operation.""" 55 | 56 | pass 57 | 58 | 59 | class Delete(Operation): 60 | """Action describing a delete operation.""" 61 | 62 | pass 63 | 64 | 65 | class Query(Operation): 66 | """Action describing a query operation.""" 67 | 68 | pass 69 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/playlist_item.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.controller.playlist_item 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.access import Available 9 | from cloudplayer.api.controller import Controller, ControllerException 10 | from cloudplayer.api.controller.track import TrackController 11 | from cloudplayer.api.model.playlist import Playlist 12 | from cloudplayer.api.model.playlist_item import PlaylistItem 13 | 14 | 15 | class PlaylistItemController(Controller): 16 | 17 | __model__ = PlaylistItem 18 | 19 | async def create(self, ids, kw, fields=Available): 20 | track_id = kw.get('track_id') 21 | track_provider_id = kw.get('track_provider_id') 22 | track_controller = TrackController.for_provider( 23 | track_provider_id, self.db, self.current_user) 24 | track = await track_controller.read({ 25 | 'id': track_id, 'provider_id': track_provider_id}) 26 | if not track: 27 | raise ControllerException(404, 'track not found') 28 | 29 | playlist = self.db.query(Playlist).get( 30 | (ids.pop('playlist_id'), ids.pop('playlist_provider_id'))) 31 | if not playlist: 32 | raise ControllerException(404, 'playlist not found') 33 | 34 | if not playlist.image: 35 | playlist.image = track.image.copy() 36 | self.db.add(playlist) 37 | 38 | return await super().create(ids, kw, fields=fields) 39 | 40 | async def query(self, ids, kw): 41 | query = await super().query(ids, kw) 42 | return query.order_by(PlaylistItem.rank) 43 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/image.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.image 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import sqlalchemy as sql 9 | 10 | from cloudplayer.api.access import Allow, Deny, Everyone, Fields, Read 11 | from cloudplayer.api.model import Base 12 | 13 | 14 | class Image(Base): 15 | 16 | __acl__ = ( 17 | Allow(Everyone, Read, Fields( 18 | 'id', 19 | 'small', 20 | 'medium', 21 | 'large', 22 | 'created', 23 | 'updated' 24 | )), 25 | Deny() 26 | ) 27 | __table_args__ = ( 28 | sql.PrimaryKeyConstraint( 29 | 'id'), 30 | ) 31 | 32 | id = sql.Column(sql.Integer) 33 | small = sql.Column(sql.String(256)) 34 | medium = sql.Column(sql.String(256)) 35 | large = sql.Column(sql.String(256), nullable=False) 36 | 37 | def copy(self): 38 | return Image( 39 | small=self.small, 40 | medium=self.medium, 41 | large=self.large) 42 | 43 | @classmethod 44 | def from_soundcloud(cls, url): 45 | if isinstance(url, str): 46 | return cls( 47 | small=url, 48 | medium=url.replace('large', 't300x300'), 49 | large=url.replace('large', 't500x500')) 50 | 51 | @classmethod 52 | def from_youtube(cls, thumbnails): 53 | if isinstance(thumbnails, dict): 54 | return cls( 55 | small=thumbnails.get('default', {}).get('url'), 56 | medium=thumbnails.get('medium', {}).get('url'), 57 | large=thumbnails.get('high', {}).get('url')) 58 | -------------------------------------------------------------------------------- /spec/asyncapi.yml: -------------------------------------------------------------------------------- 1 | asyncapi: '1.0.0' 2 | info: 3 | title: Cloud-Player 4 | version: '1.0' 5 | description: cloud-player websocket channels 6 | servers: 7 | - url: api.cloud-player.io 8 | scheme: wss 9 | security: 10 | - httpApiKey: [] 11 | 12 | topics: 13 | user.{id}: 14 | publish: 15 | $ref: '#/components/messages/userEntityRead' 16 | subscribe: 17 | $ref: '#/components/messages/onUserUpdate' 18 | 19 | components: 20 | securitySchemes: 21 | httpApiKey: 22 | name: tok_v1 23 | type: httpApiKey 24 | description: '' 25 | in: cookie 26 | 27 | messages: 28 | userEntityRead: 29 | headers: 30 | type: object 31 | properties: 32 | channel: 33 | title: channel 34 | description: Channel descriptor 35 | type: string 36 | method: 37 | title: method 38 | description: Execution method 39 | default: PUT 40 | type: string 41 | enum: 42 | - GET 43 | - POST 44 | - PUT 45 | - DELETE 46 | payload: 47 | items: 48 | $ref: 'user.yml#/User' 49 | type: array 50 | onUserUpdate: 51 | headers: 52 | type: object 53 | properties: 54 | channel: 55 | title: channel 56 | description: Channel descriptor 57 | type: string 58 | method: 59 | title: method 60 | description: Execution method 61 | default: PUT 62 | type: string 63 | enum: 64 | - SUBSCRIBE 65 | - UNSUBSCRIBE 66 | payload: 67 | type: array 68 | items: 69 | $ref: 'user.yml#/User' 70 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/token.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.controller.token 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import datetime 9 | 10 | from cloudplayer.api.access import Available 11 | from cloudplayer.api.controller import Controller, ControllerException 12 | from cloudplayer.api.model.token import Token 13 | 14 | 15 | class TokenController(Controller): 16 | 17 | __model__ = Token 18 | 19 | async def read(self, ids, fields=Available): 20 | threshold = datetime.datetime.utcnow() - datetime.timedelta(minutes=5) 21 | query = self.db.query( 22 | self.__model__).filter_by(**ids).filter(Token.created > threshold) 23 | entity = query.first() 24 | if not entity: 25 | raise ControllerException(404, 'token not found') 26 | account = self.get_account(entity.provider_id) 27 | self.policy.grant_read(account, entity, fields) 28 | if entity.claimed: 29 | self.current_user.clear() 30 | self.current_user['user_id'] = entity.account.user_id 31 | for a in entity.account.user.accounts: 32 | self.current_user[a.provider_id] = a.id 33 | entity.account_id = None 34 | entity.account_provider_id = None 35 | self.db.commit() 36 | return entity 37 | 38 | async def update(self, ids, kw, fields=Available): 39 | token = { 40 | 'id': ids['id'], 41 | 'claimed': True, 42 | 'account_id': self.current_user['cloudplayer'], 43 | 'account_provider_id': 'cloudplayer'} 44 | if kw != token: 45 | raise ControllerException(404, 'invalid update') 46 | return await super().update(ids, kw, fields=fields) 47 | -------------------------------------------------------------------------------- /src/cloudplayer/api/access/fields.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.access.fields 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | 9 | 10 | class Fields(object): 11 | """Component for fine-tuning column-level ACL rules. 12 | 13 | Fields contain a set of column names that may restrict a CRUD operation. 14 | E.g., the owner of an entity may read more attributes than a third party. 15 | 16 | Column names are provided as positional arguments and can have a dotted 17 | syntax to describe columns of related models. 18 | The constructer accepts an additional target keyword to provide 19 | compatibility with the `Available` fields class. 20 | 21 | Calling a fields instance with a target entity creates a bound copy. 22 | 23 | The `in` operator is implemented to check whether the intent applies 24 | to the specified field restriction. 25 | """ 26 | 27 | def __init__(self, *args, target=None): 28 | self._values = frozenset(args) 29 | self._target = target 30 | 31 | def __call__(self, target): 32 | return Fields(*self._values, target=target) 33 | 34 | def __iter__(self): 35 | yield from self._values 36 | 37 | def __contains__(self, item): 38 | if isinstance(item, Fields): 39 | return self._values.issuperset(item._values) 40 | return self._values.__contains__(item) 41 | 42 | 43 | class Available(Fields): 44 | """Wildcard fields class that unlocks all fields in an ACL rule. 45 | 46 | Rules must reference the class uninstanciated and the target is bound 47 | through the constructor. 48 | """ 49 | 50 | def __init__(self, target): 51 | self(target) 52 | 53 | def __call__(self, target): 54 | self._target = target 55 | self._values = frozenset() 56 | return self 57 | 58 | def __contains__(self, item): 59 | return True 60 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/http/test_playlist.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from cloudplayer.api.model.playlist import Playlist 4 | from cloudplayer.api.model.playlist_item import PlaylistItem 5 | 6 | 7 | @pytest.mark.gen_test 8 | async def test_playlist_can_be_created(user_fetch, account): 9 | body = {'title': 'test playlist'} 10 | response = await user_fetch( 11 | '/playlist/cloudplayer', method='POST', body=body) 12 | result = response.json() 13 | assert result['account_id'] == account.id 14 | assert result['id'] is not None 15 | assert result['title'] == 'test playlist' 16 | assert result['description'] is None 17 | 18 | 19 | @pytest.mark.gen_test 20 | async def test_playlist_can_be_deleted_without_tracks(user_fetch): 21 | body = {'title': 'test playlist'} 22 | response = await user_fetch( 23 | '/playlist/cloudplayer', method='POST', body=body) 24 | response = await user_fetch('/playlist/cloudplayer/{}'.format( 25 | response.json()['id']), method='DELETE') 26 | assert response.code == 204 27 | 28 | 29 | @pytest.mark.gen_test 30 | async def test_playlist_can_be_deleted_with_cascading_tracks( 31 | db, user_fetch, account): 32 | item = PlaylistItem( 33 | account=account, 34 | rank='aaa', 35 | track_id='test-id', 36 | track_provider_id='cloudplayer') 37 | playlist = Playlist( 38 | title='test playlist', 39 | provider_id='cloudplayer', 40 | items=[item], 41 | account_id=account.id, 42 | account_provider_id=account.provider_id) 43 | db.add(playlist) 44 | db.commit() 45 | item_ids = (item.id,) 46 | playlist_ids = (playlist.id, playlist.provider_id) 47 | 48 | response = await user_fetch( 49 | '/playlist/cloudplayer/{}'.format(playlist.id), method='DELETE') 50 | assert response.code == 204 51 | 52 | db.expunge_all() 53 | assert not db.query(Playlist).get(playlist_ids) 54 | assert not db.query(PlaylistItem).get(item_ids) 55 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/track_comment.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.controller.track_comment 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import datetime 9 | 10 | import tornado.escape 11 | 12 | from cloudplayer.api.access import Available 13 | from cloudplayer.api.controller import Controller 14 | from cloudplayer.api.model.account import Account 15 | from cloudplayer.api.model.image import Image 16 | from cloudplayer.api.model.track_comment import TrackComment 17 | 18 | 19 | class SoundcloudTrackCommentController(Controller): 20 | 21 | DATE_FORMAT = '%Y/%m/%d %H:%M:%S %z' 22 | 23 | async def search(self, ids, kw, fields=Available): 24 | response = await self.fetch( 25 | ids['track_provider_id'], 26 | '/tracks/{}/comments'.format(ids['track_id'])) 27 | comments = tornado.escape.json_decode(response.body) 28 | 29 | entities = [] 30 | for comment in comments: 31 | user = comment['user'] 32 | writer = Account( 33 | id=user['id'], 34 | provider_id=ids['track_provider_id'], 35 | title=user['username'], 36 | image=Image.from_soundcloud(user.get('avatar_url'))) 37 | entity = TrackComment( 38 | id=comment['id'], 39 | provider_id=ids['track_provider_id'], 40 | body=comment['body'], 41 | timestamp=comment['timestamp'], 42 | track_id=ids['track_id'], 43 | track_provider_id=ids['track_provider_id'], 44 | account=writer, 45 | created=datetime.datetime.strptime( 46 | comment['created_at'], self.DATE_FORMAT)) 47 | entities.append(entity) 48 | 49 | account = self.get_account(entity.provider_id) 50 | self.policy.grant_read(account, entities, fields) 51 | return entities 52 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/proxy.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.proxy 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import tornado.web 9 | 10 | from cloudplayer.api.controller import Controller 11 | from cloudplayer.api.http import HTTPHandler 12 | 13 | 14 | class Proxy(HTTPHandler): # pragma: no cover 15 | 16 | SUPPORTED_METHODS = ('GET', 'POST', 'PUT', 'DELETE', 'OPTIONS') 17 | 18 | async def proxy(self, method, provider, path, **kw): 19 | controller = Controller(self.db, self.current_user) 20 | response = await controller.fetch( 21 | provider, path, method=method, params=self.query_params, 22 | raise_error=False, **kw) 23 | body = response.body 24 | if response.error: 25 | code = response.error.code 26 | if code == 599: 27 | code = 503 28 | if not body: 29 | body = response.error.message 30 | self.set_status(code) 31 | else: 32 | body = body.replace( # Get your TLS on, YouTube! 33 | b'http://s.ytimg.com', b'https://s.ytimg.com') 34 | self.write_str(body) 35 | 36 | def write_str(self, data): 37 | tornado.web.RequestHandler.write(self, data) 38 | self.finish() 39 | 40 | async def get(self, provider, path): 41 | await self.proxy('GET', provider, path) 42 | 43 | async def post(self, provider, path): 44 | await self.proxy( 45 | 'POST', provider, path, 46 | body=self.request.body, 47 | headers={'Content-Type': 'application/json'}) 48 | 49 | async def put(self, provider, path): 50 | await self.proxy( 51 | 'PUT', provider, path, 52 | body=self.request.body, 53 | headers={'Content-Type': 'application/json'}) 54 | 55 | async def delete(self, provider, path): 56 | await self.proxy('DELETE', provider, path) 57 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/favourite_item.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.favourite_item 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import sqlalchemy as sql 9 | import sqlalchemy.orm as orm 10 | 11 | from cloudplayer.api.access import (Allow, Create, Delete, Fields, Owner, 12 | Parent, Query, Read) 13 | from cloudplayer.api.model import Base 14 | from cloudplayer.api.model.tracklist_item import TracklistItemMixin 15 | 16 | 17 | class FavouriteItem(TracklistItemMixin, Base): 18 | 19 | __acl__ = ( 20 | Allow(Parent, Create, Fields( 21 | 'favourite_id', 22 | 'favourite_provider_id', 23 | 'account_id', 24 | 'account_provider_id', 25 | 'track_id', 26 | 'track_provider_id' 27 | )), 28 | Allow(Owner, Read, Fields( 29 | 'id', 30 | 'track_id', 31 | 'track_provider_id' 32 | )), 33 | Allow(Owner, Delete), 34 | Allow(Parent, Query, Fields( 35 | 'favourite_id', 36 | 'favourite_provider_id' 37 | )) 38 | ) 39 | __table_args__ = ( 40 | sql.PrimaryKeyConstraint( 41 | 'id'), 42 | sql.ForeignKeyConstraint( 43 | ['favourite_provider_id', 'favourite_id'], 44 | ['favourite.provider_id', 'favourite.id']), 45 | sql.ForeignKeyConstraint( 46 | ['account_id', 'account_provider_id'], 47 | ['account.id', 'account.provider_id']), 48 | sql.ForeignKeyConstraint( 49 | ['track_provider_id'], 50 | ['provider.id']) 51 | ) 52 | 53 | provider_id = orm.synonym('favourite_provider_id') 54 | 55 | favourite_id = sql.Column(sql.String(96), nullable=False) 56 | favourite_provider_id = sql.Column(sql.String(16), nullable=False) 57 | favourite = orm.relation( 58 | 'Favourite', 59 | back_populates='items') 60 | parent = orm.synonym('favourite') 61 | -------------------------------------------------------------------------------- /static/close.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |You can close this tab now.
53 |