diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..be6709e --- /dev/null +++ b/.env.dist @@ -0,0 +1,6 @@ +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/.gitignore b/.gitignore new file mode 100644 index 0000000..2dc53ca --- /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/ 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/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 + ) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..dc17dae --- /dev/null +++ b/app/main.py @@ -0,0 +1,33 @@ +from fastapi import FastAPI +from pydantic import TypeAdapter + +from app.container import Container +from app.spotify.router import spotify_router +from app.system.router import system_router + +container = Container() + + +def create_application() -> FastAPI: + application = FastAPI( + title="MusicBox API", + debug=True + ) + + 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=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()), + ) diff --git a/app/spotify/__init__.py b/app/spotify/__init__.py new file mode 100644 index 0000000..e69de29 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 diff --git a/app/spotify/router.py b/app/spotify/router.py new file mode 100644 index 0000000..50e03d9 --- /dev/null +++ b/app/spotify/router.py @@ -0,0 +1,16 @@ +from dependency_injector.wiring import inject, Provide +from fastapi import APIRouter, Depends + +from app.container import Container +from app.spotify.schemas import Device +from app.spotify.service import SpotifyService + +spotify_router = APIRouter() + + +@spotify_router.get("/devices") +@inject +def get_devices( + spotify_service: SpotifyService = Depends(Provide[Container.spotify_service]), +) -> list[Device]: + return spotify_service.refresh_devices() diff --git a/app/spotify/schemas.py b/app/spotify/schemas.py new file mode 100644 index 0000000..3bb8720 --- /dev/null +++ b/app/spotify/schemas.py @@ -0,0 +1,20 @@ +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 + + @property + def uri(self): + return f"spotify:track:{self.id}" diff --git a/app/spotify/service.py b/app/spotify/service.py new file mode 100644 index 0000000..c497bc9 --- /dev/null +++ b/app/spotify/service.py @@ -0,0 +1,77 @@ +import spotipy + +from app.spotify.exceptions import DeviceNotFoundError, TrackNotFoundError +from app.spotify.schemas import Device, Track + + +class SpotifyService: + 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] 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..3f1cbf3 --- /dev/null +++ b/app/system/router.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter, status +from starlette.responses import JSONResponse + +system_router = APIRouter() + + +@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) 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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a19dad9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3.8' + +services: + fastapi-app: + container_name: ${CONTAINER_NAME:-fastapi-app} + image: ${IMAGE_NAME:-fastapi} + build: + context: . + volumes: + - .:/code + ports: + - ${EXPOSED_PORT:-8000}:${UVICORN_PORT:-8000} + command: ["python", "-m", "app.main"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cc825f4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +spotipy==2.23.0 +environs==9.5.0 +fastapi[all]==0.104.1 +typing_inspect==0.9.0 +dependency-injector==4.41.0 \ No newline at end of file