From 12fe953126e4654d0c92d8a1fc6a77cb78fd32e9 Mon Sep 17 00:00:00 2001 From: Timofey Kochetov Date: Mon, 4 Dec 2023 22:04:06 +0300 Subject: [PATCH 01/28] chore: create .gitignore --- .gitignore | 160 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68bc17f --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ From 94e38ba3305ee66ed7a57e6ed4193d9f7d2bb448 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 5 Dec 2023 03:56:03 +0300 Subject: [PATCH 02/28] feat: add .idea to gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 68bc17f..2dc53ca 100644 --- a/.gitignore +++ b/.gitignore @@ -157,4 +157,4 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ From 5b7d780020a52fdd94e5b6f49cae6c24ef6a89b1 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 5 Dec 2023 03:56:22 +0300 Subject: [PATCH 03/28] req: add spotipy and environs --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..12bee30 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +spotipy==2.23.0 +environs==9.5.0 \ No newline at end of file From 3ba99c020faf06d86e0d8771041a7cc2d5b191ea Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 5 Dec 2023 03:56:34 +0300 Subject: [PATCH 04/28] feat: add config --- .env.dist | 2 ++ src/config.py | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 .env.dist create mode 100644 src/config.py diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..8829cec --- /dev/null +++ b/.env.dist @@ -0,0 +1,2 @@ +SPOTIFY_CLIENT_ID=spotifyclientid +SPOTIFY_CLIENT_SECRET=spotifyclientsecret \ No newline at end of file diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..a349f63 --- /dev/null +++ b/src/config.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass + +from environs import Env + + +@dataclass +class SpotifyConfig: + client_id: str + client_secret: str + + +@dataclass +class Config: + spotify: SpotifyConfig + + +def load_config(path: str | None = None) -> Config: + env = Env() + env.read_env(path) + + return Config( + spotify=SpotifyConfig( + client_id=env.str("SPOTIFY_CLIENT_ID"), + client_secret=env.str("SPOTIFY_CLIENT_SECRET") + ) + ) From bce897a26896371b9dc69bb1a28b746943f3169f Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 5 Dec 2023 03:56:43 +0300 Subject: [PATCH 05/28] feat: add device model --- src/models/device.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/models/device.py diff --git a/src/models/device.py b/src/models/device.py new file mode 100644 index 0000000..7f8aa15 --- /dev/null +++ b/src/models/device.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + +@dataclass +class Device: + id: str + is_active: bool + is_private_session: bool + is_restricted: bool + name: str + supports_volume: bool + type: str + volume_percent: int From 5f1e996dc8d03c7838c2579c6ed537a0890e1a54 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 5 Dec 2023 03:56:52 +0300 Subject: [PATCH 06/28] feat: add track model --- src/models/__init__.py | 2 ++ src/models/track.py | 9 +++++++++ 2 files changed, 11 insertions(+) create mode 100644 src/models/__init__.py create mode 100644 src/models/track.py diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..4ddc5ab --- /dev/null +++ b/src/models/__init__.py @@ -0,0 +1,2 @@ +from .track import Track +from .device import Device diff --git a/src/models/track.py b/src/models/track.py new file mode 100644 index 0000000..5cf1ea6 --- /dev/null +++ b/src/models/track.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + + +@dataclass +class Track: + id: str + + def __post_init__(self): + self.uri = f"spotify:track:{self.id}" From 90ec0bc45bf746aadc41d98bb097dbaebd8e8355 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 5 Dec 2023 03:57:14 +0300 Subject: [PATCH 07/28] feat: add spotify class --- src/spotify.py | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/spotify.py diff --git a/src/spotify.py b/src/spotify.py new file mode 100644 index 0000000..033846d --- /dev/null +++ b/src/spotify.py @@ -0,0 +1,87 @@ +import spotipy + +from src.exceptions import TrackNotFoundError, DeviceNotFoundError +from src.models import Track, Device + + +class Spotify: + def __init__( + self, + client_id: str, + client_secret: str, + redirect_uri: str = "http://localhost:8080", + ) -> None: + self.__api = spotipy.Spotify( + auth_manager=spotipy.SpotifyOAuth( + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + scope="user-read-playback-state,user-modify-playback-state" + ) + ) + + self.__devices = self.__get_devices_from_api() + self.__current_device_index = 0 + + def play(self, track: Track) -> None: + current_device = self.get_current_device() + + try: + self.__api.transfer_playback( + device_id=current_device.id, + force_play=False + ) + self.__api.start_playback( + device_id=current_device.id, + uris=[track.uri] + ) + except spotipy.exceptions.SpotifyException as e: + if "Device not found" in e.msg: + raise DeviceNotFoundError(e.msg) + elif "Invalid track uri" in e.msg: + raise TrackNotFoundError(e.msg) + + def refresh_devices(self) -> list[Device]: + self.__devices = self.__get_devices_from_api() + self.__current_device_index = 0 + return self.__devices + + def next_device(self) -> Device | None: + if len(self.__devices) == 0: + return None + + self.__current_device_index = (self.__current_device_index + 1) % len(self.__devices) + return self.get_current_device() + + def previous_device(self) -> Device | None: + if len(self.__devices) == 0: + return None + + self.__current_device_index = (self.__current_device_index - 1) % len(self.__devices) + return self.get_current_device() + + def get_current_device(self) -> Device | None: + if len(self.__devices) == 0: + return None + + return self.__devices[self.__current_device_index] + + def get_devices(self) -> list[Device]: + return self.__devices + + def __get_devices_from_api(self) -> list[Device]: + devices_json = self.__api.devices()["devices"] + return [Device(**device) for device in devices_json] + + +if __name__ == "__main__": + from src.config import load_config + + config = load_config() + + sp = Spotify( + client_id=config.spotify.client_id, + client_secret=config.spotify.client_secret, + ) + + sp.play(Track(id="2bqS0QtnXGjOYs3z6VtSyW")) From 79fa7fe03974e5986574602ac7023d7f4abf00c8 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 5 Dec 2023 03:57:25 +0300 Subject: [PATCH 08/28] feat: add exceptions --- src/exceptions.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/exceptions.py diff --git a/src/exceptions.py b/src/exceptions.py new file mode 100644 index 0000000..1c93c1e --- /dev/null +++ b/src/exceptions.py @@ -0,0 +1,25 @@ +class SpotifyError(Exception): + """Base class for exceptions in this module.""" + pass + + +class TrackNotFoundError(SpotifyError): + """Exception raised when a track is not found. + + Attributes: + message -- explanation of the error + """ + + def __init__(self, message: str): + self.message = message + + +class DeviceNotFoundError(SpotifyError): + """Exception raised when a device is not found. + + Attributes: + message -- explanation of the error + """ + + def __init__(self, message: str): + self.message = message From c03ae687fd6a908b852b1eaf9daa0bb4e7436a71 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 6 Dec 2023 18:47:12 +0300 Subject: [PATCH 09/28] feat: add docker support --- Dockerfile | 8 ++++++++ docker-compose.yml | 12 ++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a31b3f8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.10-slim + +WORKDIR /code + +COPY ./requirements.txt /code/requirements.txt +RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt + +COPY ./app /code/app diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..87856ae --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +version: '3.8' + +services: + fastapi-app: + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/code + ports: + - "8000:8000" + command: ["python", "-m", "app.main"] From 1f436b3de40fb9ba88b12e07b67080792eef6f47 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 6 Dec 2023 18:47:42 +0300 Subject: [PATCH 10/28] chore: remove old files --- src/config.py | 26 ------------- src/exceptions.py | 25 ------------ src/models/__init__.py | 2 - src/models/device.py | 12 ------ src/models/track.py | 9 ----- src/spotify.py | 87 ------------------------------------------ 6 files changed, 161 deletions(-) delete mode 100644 src/config.py delete mode 100644 src/exceptions.py delete mode 100644 src/models/__init__.py delete mode 100644 src/models/device.py delete mode 100644 src/models/track.py delete mode 100644 src/spotify.py diff --git a/src/config.py b/src/config.py deleted file mode 100644 index a349f63..0000000 --- a/src/config.py +++ /dev/null @@ -1,26 +0,0 @@ -from dataclasses import dataclass - -from environs import Env - - -@dataclass -class SpotifyConfig: - client_id: str - client_secret: str - - -@dataclass -class Config: - spotify: SpotifyConfig - - -def load_config(path: str | None = None) -> Config: - env = Env() - env.read_env(path) - - return Config( - spotify=SpotifyConfig( - client_id=env.str("SPOTIFY_CLIENT_ID"), - client_secret=env.str("SPOTIFY_CLIENT_SECRET") - ) - ) diff --git a/src/exceptions.py b/src/exceptions.py deleted file mode 100644 index 1c93c1e..0000000 --- a/src/exceptions.py +++ /dev/null @@ -1,25 +0,0 @@ -class SpotifyError(Exception): - """Base class for exceptions in this module.""" - pass - - -class TrackNotFoundError(SpotifyError): - """Exception raised when a track is not found. - - Attributes: - message -- explanation of the error - """ - - def __init__(self, message: str): - self.message = message - - -class DeviceNotFoundError(SpotifyError): - """Exception raised when a device is not found. - - Attributes: - message -- explanation of the error - """ - - def __init__(self, message: str): - self.message = message diff --git a/src/models/__init__.py b/src/models/__init__.py deleted file mode 100644 index 4ddc5ab..0000000 --- a/src/models/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .track import Track -from .device import Device diff --git a/src/models/device.py b/src/models/device.py deleted file mode 100644 index 7f8aa15..0000000 --- a/src/models/device.py +++ /dev/null @@ -1,12 +0,0 @@ -from dataclasses import dataclass - -@dataclass -class Device: - id: str - is_active: bool - is_private_session: bool - is_restricted: bool - name: str - supports_volume: bool - type: str - volume_percent: int diff --git a/src/models/track.py b/src/models/track.py deleted file mode 100644 index 5cf1ea6..0000000 --- a/src/models/track.py +++ /dev/null @@ -1,9 +0,0 @@ -from dataclasses import dataclass - - -@dataclass -class Track: - id: str - - def __post_init__(self): - self.uri = f"spotify:track:{self.id}" diff --git a/src/spotify.py b/src/spotify.py deleted file mode 100644 index 033846d..0000000 --- a/src/spotify.py +++ /dev/null @@ -1,87 +0,0 @@ -import spotipy - -from src.exceptions import TrackNotFoundError, DeviceNotFoundError -from src.models import Track, Device - - -class Spotify: - def __init__( - self, - client_id: str, - client_secret: str, - redirect_uri: str = "http://localhost:8080", - ) -> None: - self.__api = spotipy.Spotify( - auth_manager=spotipy.SpotifyOAuth( - client_id=client_id, - client_secret=client_secret, - redirect_uri=redirect_uri, - scope="user-read-playback-state,user-modify-playback-state" - ) - ) - - self.__devices = self.__get_devices_from_api() - self.__current_device_index = 0 - - def play(self, track: Track) -> None: - current_device = self.get_current_device() - - try: - self.__api.transfer_playback( - device_id=current_device.id, - force_play=False - ) - self.__api.start_playback( - device_id=current_device.id, - uris=[track.uri] - ) - except spotipy.exceptions.SpotifyException as e: - if "Device not found" in e.msg: - raise DeviceNotFoundError(e.msg) - elif "Invalid track uri" in e.msg: - raise TrackNotFoundError(e.msg) - - def refresh_devices(self) -> list[Device]: - self.__devices = self.__get_devices_from_api() - self.__current_device_index = 0 - return self.__devices - - def next_device(self) -> Device | None: - if len(self.__devices) == 0: - return None - - self.__current_device_index = (self.__current_device_index + 1) % len(self.__devices) - return self.get_current_device() - - def previous_device(self) -> Device | None: - if len(self.__devices) == 0: - return None - - self.__current_device_index = (self.__current_device_index - 1) % len(self.__devices) - return self.get_current_device() - - def get_current_device(self) -> Device | None: - if len(self.__devices) == 0: - return None - - return self.__devices[self.__current_device_index] - - def get_devices(self) -> list[Device]: - return self.__devices - - def __get_devices_from_api(self) -> list[Device]: - devices_json = self.__api.devices()["devices"] - return [Device(**device) for device in devices_json] - - -if __name__ == "__main__": - from src.config import load_config - - config = load_config() - - sp = Spotify( - client_id=config.spotify.client_id, - client_secret=config.spotify.client_secret, - ) - - sp.play(Track(id="2bqS0QtnXGjOYs3z6VtSyW")) From 4f5484ee93f547b108089bdfc08fc1109e2fb1b9 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 6 Dec 2023 18:48:08 +0300 Subject: [PATCH 11/28] chore: add uvicorn settings --- .env.dist | 5 +++++ app/config.py | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 app/config.py diff --git a/.env.dist b/.env.dist index 8829cec..5e71fae 100644 --- a/.env.dist +++ b/.env.dist @@ -1,2 +1,7 @@ +UVICORN_HOST=0.0.0.0 +UVICORN_PORT=8000 +UVICORN_LOG_LEVEL=debug +UVICORN_RELOAD=false + SPOTIFY_CLIENT_ID=spotifyclientid SPOTIFY_CLIENT_SECRET=spotifyclientsecret \ No newline at end of file diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..15ba9ef --- /dev/null +++ b/app/config.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass + +from environs import Env + + +@dataclass +class UvicornConfig: + host: str + port: int + log_level: str + reload: bool + + +@dataclass +class SpotifyConfig: + client_id: str + client_secret: str + + +@dataclass +class Config: + uvicorn: UvicornConfig + spotify: SpotifyConfig + + +def load_config(path: str | None = None) -> Config: + env = Env() + env.read_env(path) + + return Config( + uvicorn=UvicornConfig( + host=env.str("UVICORN_HOST"), + port=env.int("UVICORN_PORT"), + log_level=env.str("UVICORN_LOG_LEVEL"), + reload=env.bool("UVICORN_RELOAD") + ), + spotify=SpotifyConfig( + client_id=env.str("SPOTIFY_CLIENT_ID"), + client_secret=env.str("SPOTIFY_CLIENT_SECRET") + ) + ) From a7f87ab45f3e18eb5ebb6fd8c86de69a11b3ab42 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 6 Dec 2023 18:48:28 +0300 Subject: [PATCH 12/28] req: add fastapi --- requirements.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 12bee30..8cda26f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,5 @@ spotipy==2.23.0 -environs==9.5.0 \ No newline at end of file +environs==9.5.0 +fastapi[all]==0.104.1 +fastapi_restful==0.5.0 +typing_inspect==0.9.0 \ No newline at end of file From 0c3a24c1fedea639442263c0cb71328f563869f7 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 6 Dec 2023 18:48:55 +0300 Subject: [PATCH 13/28] feat: add spotify router --- app/spotify/router.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 app/spotify/router.py diff --git a/app/spotify/router.py b/app/spotify/router.py new file mode 100644 index 0000000..ae09371 --- /dev/null +++ b/app/spotify/router.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter +from fastapi_restful.cbv import cbv + +from .service import SpotifyService + +spotify_router = APIRouter() + + +@cbv(spotify_router) +class SpotifyController: + def __init__(self): + self.spotify_service = SpotifyService() + + @spotify_router.get("/devices") + def get_devices(self): + return self.spotify_service.get_devices() From 271dafa89f53b46b4a9d2935df4def465e6527b4 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 6 Dec 2023 18:49:03 +0300 Subject: [PATCH 14/28] feat: add spotify schemas --- app/spotify/schemas.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 app/spotify/schemas.py diff --git a/app/spotify/schemas.py b/app/spotify/schemas.py new file mode 100644 index 0000000..2bfb43d --- /dev/null +++ b/app/spotify/schemas.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel + + +class Device(BaseModel): + id: str + is_active: bool + is_private_session: bool + is_restricted: bool + name: str + supports_volume: bool + type: str + volume_percent: int + + +class Track(BaseModel): + id: str + + def __post_init__(self): + self.uri = f"spotify:track:{self.id}" From 884f7a5b6c7edfb580728a9c6b2acc61804a2b57 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 6 Dec 2023 18:49:10 +0300 Subject: [PATCH 15/28] feat: add spotify service --- app/spotify/__init__.py | 0 app/spotify/service.py | 2 ++ 2 files changed, 2 insertions(+) create mode 100644 app/spotify/__init__.py create mode 100644 app/spotify/service.py diff --git a/app/spotify/__init__.py b/app/spotify/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/spotify/service.py b/app/spotify/service.py new file mode 100644 index 0000000..7d5ebab --- /dev/null +++ b/app/spotify/service.py @@ -0,0 +1,2 @@ +class SpotifyService: + pass \ No newline at end of file From 06edb9397e85dab2e2869576d2cfc3d8bb170889 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 6 Dec 2023 18:49:24 +0300 Subject: [PATCH 16/28] feat: add system router --- app/system/__init__.py | 0 app/system/router.py | 17 +++++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 app/system/__init__.py create mode 100644 app/system/router.py diff --git a/app/system/__init__.py b/app/system/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/system/router.py b/app/system/router.py new file mode 100644 index 0000000..c4adfff --- /dev/null +++ b/app/system/router.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, status +from fastapi_restful.cbv import cbv +from starlette.responses import JSONResponse + +system_router = APIRouter() + + +@cbv(system_router) +class SystemRouter: + @system_router.get("/", include_in_schema=False) + async def root(self) -> dict[str, str]: + return {"message": "MusicBox API"} + + @system_router.get("/health", include_in_schema=False) + async def health(self) -> JSONResponse: + data = {"music-box-api": status.HTTP_200_OK} + return JSONResponse(data, status_code=status.HTTP_200_OK) From d0333128cb6dda5c8c5a1603d9f34cd4391a8a1c Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 6 Dec 2023 18:50:33 +0300 Subject: [PATCH 17/28] feat: add main app --- app/main.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 app/main.py diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..124b2c8 --- /dev/null +++ b/app/main.py @@ -0,0 +1,32 @@ +from fastapi import FastAPI + +from .config import load_config +from .spotify.router import spotify_router +from .system.router import system_router + +config = load_config() + + +def create_application(): + application = FastAPI( + title="MusicBox API", + debug=True # TODO: Remove debug mode + ) + + application.include_router(system_router) + application.include_router(spotify_router) + + return application + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "app.main:create_application", + factory=True, + host=config.uvicorn.host, + port=config.uvicorn.port, + log_level=config.uvicorn.log_level, + reload=config.uvicorn.reload, + ) From 4dd70be8bb8796cd9b9400f9158aeda9981267d3 Mon Sep 17 00:00:00 2001 From: Tim Date: Thu, 7 Dec 2023 00:52:08 +0300 Subject: [PATCH 18/28] req: add dependency injector --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 8cda26f..cc825f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ spotipy==2.23.0 environs==9.5.0 fastapi[all]==0.104.1 -fastapi_restful==0.5.0 -typing_inspect==0.9.0 \ No newline at end of file +typing_inspect==0.9.0 +dependency-injector==4.41.0 \ No newline at end of file From 325a35fb7a5578029932136881f57796bb79f23b Mon Sep 17 00:00:00 2001 From: Tim Date: Thu, 7 Dec 2023 00:52:25 +0300 Subject: [PATCH 19/28] chore: add env vars for docker compose --- docker-compose.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 87856ae..a19dad9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,11 +2,12 @@ version: '3.8' services: fastapi-app: + container_name: ${CONTAINER_NAME:-fastapi-app} + image: ${IMAGE_NAME:-fastapi} build: context: . - dockerfile: Dockerfile volumes: - .:/code ports: - - "8000:8000" + - ${EXPOSED_PORT:-8000}:${UVICORN_PORT:-8000} command: ["python", "-m", "app.main"] From 67e1cb54e347104b642f28dc34235bf680a2dd89 Mon Sep 17 00:00:00 2001 From: Tim Date: Thu, 7 Dec 2023 00:52:45 +0300 Subject: [PATCH 20/28] chore: move some settings to config.ini --- .env.dist | 7 +++---- config.ini | 10 ++++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 config.ini diff --git a/.env.dist b/.env.dist index 5e71fae..be6709e 100644 --- a/.env.dist +++ b/.env.dist @@ -1,7 +1,6 @@ -UVICORN_HOST=0.0.0.0 -UVICORN_PORT=8000 -UVICORN_LOG_LEVEL=debug -UVICORN_RELOAD=false +CONTAINER_NAME=music_box_api +IMAGE_NAME=music_box +EXPOSED_PORT=8000 SPOTIFY_CLIENT_ID=spotifyclientid SPOTIFY_CLIENT_SECRET=spotifyclientsecret \ No newline at end of file diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..315901b --- /dev/null +++ b/config.ini @@ -0,0 +1,10 @@ +[spotify] +client_id = ${SPOTIFY_CLIENT_ID} +client_secret = ${SPOTIFY_CLIENT_SECRET} +redirect_uri = http://localhost:8080 + +[uvicorn] +host = 0.0.0.0 +port = 8000 +log_level = debug +reload = false \ No newline at end of file From f8763823ed985298e16ce8eed026ee1f39adac1d Mon Sep 17 00:00:00 2001 From: Tim Date: Thu, 7 Dec 2023 00:53:22 +0300 Subject: [PATCH 21/28] feat: add spotify service --- app/spotify/service.py | 77 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/app/spotify/service.py b/app/spotify/service.py index 7d5ebab..c497bc9 100644 --- a/app/spotify/service.py +++ b/app/spotify/service.py @@ -1,2 +1,77 @@ +import spotipy + +from app.spotify.exceptions import DeviceNotFoundError, TrackNotFoundError +from app.spotify.schemas import Device, Track + + class SpotifyService: - pass \ No newline at end of file + def __init__( + self, + client_id: str, + client_secret: str, + redirect_uri: str + ) -> None: + self.__api = spotipy.Spotify( + auth_manager=spotipy.SpotifyOAuth( + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + scope="user-read-playback-state,user-modify-playback-state" + ) + ) + + self.__devices = self.__get_devices_from_api() + self.__current_device_index = 0 + + def play(self, track: Track) -> None: + current_device = self.get_current_device() + if current_device is None: + raise DeviceNotFoundError("No device found") + + try: + self.__api.transfer_playback( + device_id=current_device.id, + force_play=False + ) + self.__api.start_playback( + device_id=current_device.id, + uris=[track.uri] + ) + except spotipy.exceptions.SpotifyException as e: + if "Device not found" in e.msg: + raise DeviceNotFoundError(e.msg) + elif "Invalid track uri" in e.msg: + raise TrackNotFoundError(e.msg) + + def refresh_devices(self) -> list[Device]: + self.__devices = self.__get_devices_from_api() + self.__current_device_index = 0 + return self.__devices + + def next_device(self) -> Device | None: + if len(self.__devices) == 0: + return None + + self.__current_device_index = (self.__current_device_index + 1) % len(self.__devices) + return self.get_current_device() + + def previous_device(self) -> Device | None: + if len(self.__devices) == 0: + return None + + self.__current_device_index = (self.__current_device_index - 1) % len(self.__devices) + return self.get_current_device() + + def get_current_device(self) -> Device | None: + if len(self.__devices) == 0: + return None + + return self.__devices[self.__current_device_index] + + def get_devices(self) -> list[Device]: + print(self.__devices) + return self.__devices + + def __get_devices_from_api(self) -> list[Device]: + devices_json = self.__api.devices()["devices"] + return [Device(**device) for device in devices_json] From 42dcb2bcc7a0711ab179e6e802eb9d4535adab12 Mon Sep 17 00:00:00 2001 From: Tim Date: Thu, 7 Dec 2023 00:54:41 +0300 Subject: [PATCH 22/28] fix: add property instead of post init --- app/spotify/schemas.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/spotify/schemas.py b/app/spotify/schemas.py index 2bfb43d..3bb8720 100644 --- a/app/spotify/schemas.py +++ b/app/spotify/schemas.py @@ -15,5 +15,6 @@ class Device(BaseModel): class Track(BaseModel): id: str - def __post_init__(self): - self.uri = f"spotify:track:{self.id}" + @property + def uri(self): + return f"spotify:track:{self.id}" From a555b3af71c117dd18acc541b03047886781e757 Mon Sep 17 00:00:00 2001 From: Tim Date: Thu, 7 Dec 2023 00:55:51 +0300 Subject: [PATCH 23/28] fix: remove cbv --- app/system/router.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/app/system/router.py b/app/system/router.py index c4adfff..3f1cbf3 100644 --- a/app/system/router.py +++ b/app/system/router.py @@ -1,17 +1,15 @@ from fastapi import APIRouter, status -from fastapi_restful.cbv import cbv from starlette.responses import JSONResponse system_router = APIRouter() -@cbv(system_router) -class SystemRouter: - @system_router.get("/", include_in_schema=False) - async def root(self) -> dict[str, str]: - return {"message": "MusicBox API"} +@system_router.get("/", include_in_schema=False) +async def root(self) -> dict[str, str]: + return {"message": "MusicBox API"} - @system_router.get("/health", include_in_schema=False) - async def health(self) -> JSONResponse: - data = {"music-box-api": status.HTTP_200_OK} - return JSONResponse(data, status_code=status.HTTP_200_OK) + +@system_router.get("/health", include_in_schema=False) +async def health(self) -> JSONResponse: + data = {"music-box-api": status.HTTP_200_OK} + return JSONResponse(data, status_code=status.HTTP_200_OK) From cc9ebc8a9408671c4646640f0cf26ddd236e3aa3 Mon Sep 17 00:00:00 2001 From: Tim Date: Thu, 7 Dec 2023 00:56:00 +0300 Subject: [PATCH 24/28] chore: remove config --- app/config.py | 41 ----------------------------------------- 1 file changed, 41 deletions(-) delete mode 100644 app/config.py diff --git a/app/config.py b/app/config.py deleted file mode 100644 index 15ba9ef..0000000 --- a/app/config.py +++ /dev/null @@ -1,41 +0,0 @@ -from dataclasses import dataclass - -from environs import Env - - -@dataclass -class UvicornConfig: - host: str - port: int - log_level: str - reload: bool - - -@dataclass -class SpotifyConfig: - client_id: str - client_secret: str - - -@dataclass -class Config: - uvicorn: UvicornConfig - spotify: SpotifyConfig - - -def load_config(path: str | None = None) -> Config: - env = Env() - env.read_env(path) - - return Config( - uvicorn=UvicornConfig( - host=env.str("UVICORN_HOST"), - port=env.int("UVICORN_PORT"), - log_level=env.str("UVICORN_LOG_LEVEL"), - reload=env.bool("UVICORN_RELOAD") - ), - spotify=SpotifyConfig( - client_id=env.str("SPOTIFY_CLIENT_ID"), - client_secret=env.str("SPOTIFY_CLIENT_SECRET") - ) - ) From 9a1b4f8e1dd25148cf5f9bc54d456269c1ffb780 Mon Sep 17 00:00:00 2001 From: Tim Date: Thu, 7 Dec 2023 00:56:16 +0300 Subject: [PATCH 25/28] feat: add spotify exceptions --- app/spotify/exceptions.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 app/spotify/exceptions.py diff --git a/app/spotify/exceptions.py b/app/spotify/exceptions.py new file mode 100644 index 0000000..1c93c1e --- /dev/null +++ b/app/spotify/exceptions.py @@ -0,0 +1,25 @@ +class SpotifyError(Exception): + """Base class for exceptions in this module.""" + pass + + +class TrackNotFoundError(SpotifyError): + """Exception raised when a track is not found. + + Attributes: + message -- explanation of the error + """ + + def __init__(self, message: str): + self.message = message + + +class DeviceNotFoundError(SpotifyError): + """Exception raised when a device is not found. + + Attributes: + message -- explanation of the error + """ + + def __init__(self, message: str): + self.message = message From 353d80d2f1f687745235ad3601321aee0285a687 Mon Sep 17 00:00:00 2001 From: Tim Date: Thu, 7 Dec 2023 00:56:37 +0300 Subject: [PATCH 26/28] feat: add container for injection --- app/container.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 app/container.py diff --git a/app/container.py b/app/container.py new file mode 100644 index 0000000..7d879c3 --- /dev/null +++ b/app/container.py @@ -0,0 +1,20 @@ +from dependency_injector import containers, providers + +from app.spotify.service import SpotifyService + + +class Container(containers.DeclarativeContainer): + wiring_config = containers.WiringConfiguration( + packages=[ + "app", + ] + ) + + config = providers.Configuration(ini_files=["config.ini"], strict=True) + + spotify_service = providers.Singleton( + SpotifyService, + client_id=config.spotify.client_id, + client_secret=config.spotify.client_secret, + redirect_uri=config.spotify.redirect_uri + ) From 6b5d8e0b2e74d54e585ddf0c223851bdd6764a39 Mon Sep 17 00:00:00 2001 From: Tim Date: Thu, 7 Dec 2023 00:57:01 +0300 Subject: [PATCH 27/28] feat: inject spotify service to router --- app/spotify/router.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/app/spotify/router.py b/app/spotify/router.py index ae09371..50e03d9 100644 --- a/app/spotify/router.py +++ b/app/spotify/router.py @@ -1,16 +1,16 @@ -from fastapi import APIRouter -from fastapi_restful.cbv import cbv +from dependency_injector.wiring import inject, Provide +from fastapi import APIRouter, Depends -from .service import SpotifyService +from app.container import Container +from app.spotify.schemas import Device +from app.spotify.service import SpotifyService spotify_router = APIRouter() -@cbv(spotify_router) -class SpotifyController: - def __init__(self): - self.spotify_service = SpotifyService() - - @spotify_router.get("/devices") - def get_devices(self): - return self.spotify_service.get_devices() +@spotify_router.get("/devices") +@inject +def get_devices( + spotify_service: SpotifyService = Depends(Provide[Container.spotify_service]), +) -> list[Device]: + return spotify_service.refresh_devices() From 40359af016a32ffe0c575e835060310250604b39 Mon Sep 17 00:00:00 2001 From: Tim Date: Thu, 7 Dec 2023 00:57:28 +0300 Subject: [PATCH 28/28] feat: initialize container --- app/main.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/app/main.py b/app/main.py index 124b2c8..dc17dae 100644 --- a/app/main.py +++ b/app/main.py @@ -1,16 +1,17 @@ from fastapi import FastAPI +from pydantic import TypeAdapter -from .config import load_config -from .spotify.router import spotify_router -from .system.router import system_router +from app.container import Container +from app.spotify.router import spotify_router +from app.system.router import system_router -config = load_config() +container = Container() -def create_application(): +def create_application() -> FastAPI: application = FastAPI( title="MusicBox API", - debug=True # TODO: Remove debug mode + debug=True ) application.include_router(system_router) @@ -25,8 +26,8 @@ def create_application(): uvicorn.run( "app.main:create_application", factory=True, - host=config.uvicorn.host, - port=config.uvicorn.port, - log_level=config.uvicorn.log_level, - reload=config.uvicorn.reload, + host=container.config.uvicorn.host(), + port=int(container.config.uvicorn.port()), + log_level=container.config.uvicorn.log_level(), + reload=TypeAdapter(bool).validate_python(container.config.uvicorn.reload()), )