diff --git a/ansible/.ansible-secrets.template.yml b/ansible/.ansible-secrets.template.yml index 92bd462..fdfe064 100644 --- a/ansible/.ansible-secrets.template.yml +++ b/ansible/.ansible-secrets.template.yml @@ -2,3 +2,4 @@ spotify_client_id: spotify_client_id spotify_secret: spotify_secret debug_mode: false flask_secret_key: flask_secret_key +db_connection_string: postgresql://username:password@hostname:port/database \ No newline at end of file diff --git a/backend/.env.j2 b/backend/.env.j2 index b0adbeb..1147a03 100644 --- a/backend/.env.j2 +++ b/backend/.env.j2 @@ -14,3 +14,6 @@ SECRET_KEY={{flask_secret_key}} SPOTIFY_CLIENT_ID={{spotify_client_id}} SPOTIFY_SECRET={{spotify_secret}} SPOTIFY_REDIRECT_URI="{{backend_url}}/auth/get-user-code" + +# Database Connection String +DB_CONNECTION_STRING=postgresql://username:password@hostname:port/database diff --git a/backend/.env.template b/backend/.env.template index d4bcbb6..683f90a 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -14,3 +14,6 @@ SECRET_KEY="secret-key" SPOTIFY_CLIENT_ID="https://developer.spotify.com/dashboard" SPOTIFY_SECRET="https://developer.spotify.com/dashboard" SPOTIFY_REDIRECT_URI="http://localhost:8080/spotify-redirect" + +# Database Connection String +DB_CONNECTION_STRING=postgresql://username:password@hostname:port/database diff --git a/backend/.env.test b/backend/.env.test index d4bcbb6..683f90a 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -14,3 +14,6 @@ SECRET_KEY="secret-key" SPOTIFY_CLIENT_ID="https://developer.spotify.com/dashboard" SPOTIFY_SECRET="https://developer.spotify.com/dashboard" SPOTIFY_REDIRECT_URI="http://localhost:8080/spotify-redirect" + +# Database Connection String +DB_CONNECTION_STRING=postgresql://username:password@hostname:port/database diff --git a/backend/poetry.lock b/backend/poetry.lock index 0db196d..73a0ff6 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -11,6 +11,19 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "asyncio" +version = "3.4.3" +description = "reference implementation of PEP 3156" +optional = false +python-versions = "*" +files = [ + {file = "asyncio-3.4.3-cp33-none-win32.whl", hash = "sha256:b62c9157d36187eca799c378e572c969f0da87cd5fc42ca372d92cdb06e7e1de"}, + {file = "asyncio-3.4.3-cp33-none-win_amd64.whl", hash = "sha256:c46a87b48213d7464f22d9a497b9eef8c1928b68320a2fa94240f969f6fec08c"}, + {file = "asyncio-3.4.3-py3-none-any.whl", hash = "sha256:c4d18b22701821de07bd6aea8b53d21449ec0ec5680645e5317062ea21817d2d"}, + {file = "asyncio-3.4.3.tar.gz", hash = "sha256:83360ff8bc97980e4ff25c964c7bd3923d333d177aa4f7fb736b019f26c7cb41"}, +] + [[package]] name = "blinker" version = "1.8.2" @@ -358,6 +371,16 @@ files = [ {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] +[[package]] +name = "peewee" +version = "3.17.6" +description = "a little orm" +optional = false +python-versions = "*" +files = [ + {file = "peewee-3.17.6.tar.gz", hash = "sha256:cea5592c6f4da1592b7cff8eaf655be6648a1f5857469e30037bf920c03fb8fb"}, +] + [[package]] name = "pluggy" version = "1.5.0" @@ -373,6 +396,51 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "psycopg" +version = "3.2.1" +description = "PostgreSQL database adapter for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "psycopg-3.2.1-py3-none-any.whl", hash = "sha256:ece385fb413a37db332f97c49208b36cf030ff02b199d7635ed2fbd378724175"}, + {file = "psycopg-3.2.1.tar.gz", hash = "sha256:dc8da6dc8729dacacda3cc2f17d2c9397a70a66cf0d2b69c91065d60d5f00cb7"}, +] + +[package.dependencies] +typing-extensions = ">=4.4" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +binary = ["psycopg-binary (==3.2.1)"] +c = ["psycopg-c (==3.2.1)"] +dev = ["ast-comments (>=1.1.2)", "black (>=24.1.0)", "codespell (>=2.2)", "dnspython (>=2.1)", "flake8 (>=4.0)", "mypy (>=1.6)", "types-setuptools (>=57.4)", "wheel (>=0.37)"] +docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)", "sphinx-autodoc-typehints (>=1.12)"] +pool = ["psycopg-pool"] +test = ["anyio (>=4.0)", "mypy (>=1.6)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] + +[[package]] +name = "psycopg2" +version = "2.9.9" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +optional = false +python-versions = ">=3.7" +files = [ + {file = "psycopg2-2.9.9-cp310-cp310-win32.whl", hash = "sha256:38a8dcc6856f569068b47de286b472b7c473ac7977243593a288ebce0dc89516"}, + {file = "psycopg2-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:426f9f29bde126913a20a96ff8ce7d73fd8a216cfb323b1f04da402d452853c3"}, + {file = "psycopg2-2.9.9-cp311-cp311-win32.whl", hash = "sha256:ade01303ccf7ae12c356a5e10911c9e1c51136003a9a1d92f7aa9d010fb98372"}, + {file = "psycopg2-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:121081ea2e76729acfb0673ff33755e8703d45e926e416cb59bae3a86c6a4981"}, + {file = "psycopg2-2.9.9-cp312-cp312-win32.whl", hash = "sha256:d735786acc7dd25815e89cc4ad529a43af779db2e25aa7c626de864127e5a024"}, + {file = "psycopg2-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:a7653d00b732afb6fc597e29c50ad28087dcb4fbfb28e86092277a559ae4e693"}, + {file = "psycopg2-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:5e0d98cade4f0e0304d7d6f25bbfbc5bd186e07b38eac65379309c4ca3193efa"}, + {file = "psycopg2-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:7e2dacf8b009a1c1e843b5213a87f7c544b2b042476ed7755be813eaf4e8347a"}, + {file = "psycopg2-2.9.9-cp38-cp38-win32.whl", hash = "sha256:ff432630e510709564c01dafdbe996cb552e0b9f3f065eb89bdce5bd31fabf4c"}, + {file = "psycopg2-2.9.9-cp38-cp38-win_amd64.whl", hash = "sha256:bac58c024c9922c23550af2a581998624d6e02350f4ae9c5f0bc642c633a2d5e"}, + {file = "psycopg2-2.9.9-cp39-cp39-win32.whl", hash = "sha256:c92811b2d4c9b6ea0285942b2e7cac98a59e166d59c588fe5cfe1eda58e72d59"}, + {file = "psycopg2-2.9.9-cp39-cp39-win_amd64.whl", hash = "sha256:de80739447af31525feddeb8effd640782cf5998e1a4e9192ebdf829717e3913"}, + {file = "psycopg2-2.9.9.tar.gz", hash = "sha256:d1454bde93fb1e224166811694d600e746430c006fbb031ea06ecc2ea41bf156"}, +] + [[package]] name = "pydantic" version = "2.8.2" @@ -575,6 +643,17 @@ files = [ {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] +[[package]] +name = "tzdata" +version = "2024.1" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, + {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, +] + [[package]] name = "urllib3" version = "2.2.2" @@ -612,4 +691,4 @@ watchdog = ["watchdog (>=2.3)"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "019cdbcf42e3ac6a3d2fbe22301b6e506bfbfb8f297a94567a0a2eaafa709763" +content-hash = "888b0d4108a83d5b35314ff2b10f25256013f5a2283f282759024c4581f02791" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 827777c..e10e26f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,6 +17,10 @@ flask-cors = "^4.0.1" pydantic = "^2.6.4" requests = "^2.32.3" gunicorn = "^22.0.0" +peewee = "^3.17.6" +psycopg = "^3.2.1" +asyncio = "^3.4.3" +psycopg2 = "^2.9.9" [tool.poetry.group.dev.dependencies] pytest = "^8.1.1" diff --git a/backend/src/app.py b/backend/src/app.py index 1fcced1..657c767 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -1,5 +1,6 @@ from flask import Flask, make_response from flask_cors import CORS +from src.controllers.database import database_controller from src.controllers.spotify import spotify_controller from src.exceptions.Unauthorized import UnauthorizedException from src.flask_config import Config @@ -44,4 +45,6 @@ def handle_unauthorized_exception(_): app.register_blueprint(auth_controller(spotify=spotify)) app.register_blueprint(spotify_controller(spotify=spotify)) + app.register_blueprint(database_controller(spotify=spotify)) + return app diff --git a/backend/src/controllers/database.py b/backend/src/controllers/database.py new file mode 100644 index 0000000..1c3f9f3 --- /dev/null +++ b/backend/src/controllers/database.py @@ -0,0 +1,55 @@ +from logging import Logger +from flask import Blueprint, make_response, request + +from src.database.crud.playlist import ( + create_playlist, + get_playlist_by_id_or_none, + update_playlist, +) +from src.database.crud.user import get_or_create_user +from src.spotify import SpotifyClient + + +def database_controller(spotify: SpotifyClient): + database_controller = Blueprint( + name="database_controller", import_name=__name__, url_prefix="/database" + ) + + @database_controller.route("populate_user", methods=["GET"]) + def populate_user(): + access_token = request.cookies.get("spotify_access_token") + user = spotify.get_current_user(access_token) + simplified_playlists = spotify.get_all_playlists( + user_id=user.id, access_token=access_token + ) + get_or_create_user(user) + + for simplified_playlist in simplified_playlists: + if "Albums" in simplified_playlist.name: + db_playlist = get_playlist_by_id_or_none(simplified_playlist.id) + + if db_playlist is None: + [playlist, albums] = [ + spotify.get_playlist( + access_token=access_token, id=simplified_playlist.id + ), + spotify.get_playlist_album_info( + access_token=access_token, id=simplified_playlist.id + ), + ] + create_playlist(playlist, albums) + else: + if db_playlist.snapshot_id != simplified_playlist.snapshot_id: + [playlist, albums] = [ + spotify.get_playlist( + access_token=access_token, id=simplified_playlist.id + ), + spotify.get_playlist_album_info( + access_token=access_token, id=simplified_playlist.id + ), + ] + update_playlist(playlist, albums) + + return make_response("Playlist data populated", 201) + + return database_controller diff --git a/backend/src/controllers/spotify.py b/backend/src/controllers/spotify.py index 4b2bcbb..c8b00b5 100644 --- a/backend/src/controllers/spotify.py +++ b/backend/src/controllers/spotify.py @@ -1,8 +1,10 @@ +from logging import Logger from flask import Blueprint, make_response, request from src.dataclasses.playback_info import PlaybackInfo from src.dataclasses.playback_request import StartPlaybackRequest from src.dataclasses.playlist import Playlist from src.spotify import SpotifyClient +import sys def spotify_controller(spotify: SpotifyClient): diff --git a/backend/src/database/crud/album.py b/backend/src/database/crud/album.py new file mode 100644 index 0000000..ca7a519 --- /dev/null +++ b/backend/src/database/crud/album.py @@ -0,0 +1,37 @@ +from src.dataclasses.album import Album +from src.database.models import ( + AlbumArtistRelationship, + AlbumGenreRelationship, + DbGenre, + DbAlbum, + DbArtist, +) + + +def create_album_or_none(album: Album): + if DbAlbum.get_or_none(DbAlbum.id == album.id): + return + album = DbAlbum.create( + id=album.id, + album_type=album.album_type, + total_tracks=album.total_tracks, + image_url=album.images[0].url if album.images else None, + name=album.name, + release_date=album.release_date, + release_date_precision=album.release_date_precision, + label=album.label, + uri=album.uri, + ) + for artist in album.artists: + DbArtist.get_or_create( + id=artist.id, + image_url=album.images[0].url if album.images else None, + name=artist.name, + uri=artist.uri, + ) + AlbumArtistRelationship.create(album=album.id, artist=artist.id) + for genre in album.genres or []: + db_genre = DbGenre.get_or_create(name=genre) + AlbumGenreRelationship.create(album=album.id, genre=db_genre.id) + + return album diff --git a/backend/src/database/crud/playlist.py b/backend/src/database/crud/playlist.py new file mode 100644 index 0000000..f9fb67a --- /dev/null +++ b/backend/src/database/crud/playlist.py @@ -0,0 +1,46 @@ +from typing import List +from src.database.crud.album import create_album_or_none +from src.database.models import DbPlaylist, PlaylistAlbumRelationship +from src.dataclasses.album import Album +from src.dataclasses.playlist import Playlist + + +def get_playlist_by_id_or_none(id: str): + return DbPlaylist.get_or_none(DbPlaylist.id == id) + + +def create_playlist(playlist: Playlist, albums: List[Album]): + playlist = DbPlaylist.create( + id=playlist.id, + description=playlist.description, + image_url=playlist.images[0].url if playlist.images else None, + name=playlist.name, + owner=playlist.owner.id, + snapshot_id=playlist.snapshot_id, + uri=playlist.uri, + ) + + for album in albums: + create_album_or_none(album) + PlaylistAlbumRelationship.create(playlist=playlist.id, album=album.id) + + return playlist + + +def update_playlist(playlist: Playlist, albums: List[Album]): + playlist = DbPlaylist.update( + id=playlist.id, + description=playlist.description, + image_url=playlist.images[0].url if playlist.images else None, + name=playlist.name, + owner=playlist.owner.id, + snapshot_id=playlist.snapshot_id, + uri=playlist.uri, + ) + PlaylistAlbumRelationship.delete().where(playlist=playlist.id) + + for album in albums: + create_album_or_none(album) + PlaylistAlbumRelationship.create(playlist=playlist.id, album=album.id) + + return playlist diff --git a/backend/src/database/crud/user.py b/backend/src/database/crud/user.py new file mode 100644 index 0000000..9ff6e41 --- /dev/null +++ b/backend/src/database/crud/user.py @@ -0,0 +1,28 @@ +from src.database.models import DbUser +from src.dataclasses.user import User + + +def get_user_by_id(id: str): + return DbUser.get( + DbUser.id == id, + ) + + +def create_user(user: User): + return DbUser.create( + id=user.id, + display_name=user.display_name, + image_url=user.images[-1].url, + uri=user.uri, + ) + + +def get_or_create_user(user: User): + return DbUser.get_or_create( + id=user.id, + defaults={ + "display_name": user.display_name, + "image_url": user.images[-1].url, + "uri": user.uri, + }, + ) diff --git a/backend/src/database/migrations/init.py b/backend/src/database/migrations/init.py new file mode 100644 index 0000000..9d8437d --- /dev/null +++ b/backend/src/database/migrations/init.py @@ -0,0 +1,43 @@ +from src.database.models import ( + DbAlbum, + AlbumArtistRelationship, + AlbumGenreRelationship, + DbArtist, + DbGenre, + DbPlaylist, + PlaylistAlbumRelationship, + DbUser, + database, +) + + +def up(): + with database: + database.create_tables( + [ + DbUser, + DbPlaylist, + DbAlbum, + DbArtist, + DbGenre, + PlaylistAlbumRelationship, + AlbumArtistRelationship, + AlbumGenreRelationship, + ] + ) + + +def down(): + with database: + database.drop_tables( + [ + DbUser, + DbPlaylist, + DbAlbum, + DbArtist, + DbGenre, + PlaylistAlbumRelationship, + AlbumArtistRelationship, + AlbumGenreRelationship, + ] + ) diff --git a/backend/src/database/models.py b/backend/src/database/models.py new file mode 100644 index 0000000..61f0041 --- /dev/null +++ b/backend/src/database/models.py @@ -0,0 +1,95 @@ +from peewee import ( + PostgresqlDatabase, + Model, + CharField, + IntegerField, + DateField, + ForeignKeyField, +) +from src.flask_config import Config + +database = PostgresqlDatabase(Config().DB_CONNECTION_STRING) + + +class BaseModel(Model): + class Meta: + database = database + + +class DbUser(BaseModel): + id = CharField(primary_key=True) + display_name = CharField() + image_url = CharField(max_length=400) + uri = CharField() + + class Meta: + db_table = "user" + + +class DbPlaylist(BaseModel): + id = CharField(primary_key=True) + description = CharField() + image_url = CharField(null=True) + name = CharField() + owner = DbUser() + snapshot_id = CharField() + uri = CharField() + + class Meta: + db_table = "playlist" + + +class DbAlbum(BaseModel): + id = CharField(primary_key=True) + album_type = CharField() + total_tracks = IntegerField() + image_url = CharField() + name = CharField() + release_date = DateField() + release_date_precision = CharField() + label = CharField(null=True) + uri = CharField() + + class Meta: + db_table = "album" + + +class DbArtist(BaseModel): + id = CharField(primary_key=True) + image_url = CharField(null=True) + name = CharField() + uri = CharField() + + class Meta: + db_table = "artist" + + +class DbGenre(BaseModel): + name = CharField(unique=True) + + class Meta: + db_table = "genre" + + +class PlaylistAlbumRelationship(BaseModel): + playlist = ForeignKeyField(DbPlaylist, backref="albums") + album = ForeignKeyField(DbAlbum, backref="playlistsContaining") + + class Meta: + indexes = ((("playlist", "album"), True),) + + +class AlbumArtistRelationship(BaseModel): + album = ForeignKeyField(DbAlbum, backref="artists") + artist = ForeignKeyField(DbArtist, backref="albums") + + class Meta: + indexes = ((("album", "artist"), True),) + + +class AlbumGenreRelationship(BaseModel): + album = ForeignKeyField(DbAlbum, backref="genres") + genre = ForeignKeyField(DbArtist, backref="albums") + + class Meta: + indexes = ((("album", "genre"), True),) diff --git a/backend/src/dataclasses/playlist_info.py b/backend/src/dataclasses/playlist_info.py index 57d50e8..8b98af5 100644 --- a/backend/src/dataclasses/playlist_info.py +++ b/backend/src/dataclasses/playlist_info.py @@ -26,6 +26,7 @@ class SimplifiedPlaylist(BaseModel): description: str images: Optional[List[Image]] = None tracks: PlaylistInfoTracks + snapshot_id: str class CurrentUserPlaylists(BaseModel): diff --git a/backend/src/flask_config.py b/backend/src/flask_config.py index 672a31e..233989e 100644 --- a/backend/src/flask_config.py +++ b/backend/src/flask_config.py @@ -7,6 +7,7 @@ def __init__(self): self.SECRET_KEY = os.environ.get("SECRET_KEY") self.BACKEND_URL = os.environ.get("BACKEND_URL") self.FRONTEND_URL = os.environ.get("FRONTEND_URL") + self.DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING") if not self.SECRET_KEY: raise ValueError( "No SECRET_KEY set for Flask application. Did you follow the setup instructions?" diff --git a/backend/src/spotify.py b/backend/src/spotify.py index f5b78f5..0411ca9 100644 --- a/backend/src/spotify.py +++ b/backend/src/spotify.py @@ -146,7 +146,7 @@ def get_playlists( playlists = CurrentUserPlaylists.model_validate(api_playlists) return playlists - def get_all_playlists(self, user_id, access_token): + def get_all_playlists(self, user_id, access_token) -> List[SimplifiedPlaylist]: playlists: List[SimplifiedPlaylist] = [] offset = 0 limit = 50 diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index c9ef62f..38294c8 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -134,3 +134,7 @@ export const startPlayback = async (requestBody?: StartPlaybackRequest export const pauseOrStartPlayback = async (): Promise => { return jsonRequest(`spotify/pause_or_start_playback`, RequestMethod.PUT); }; + +export const populateUserData = async (): Promise => { + return jsonRequest(`database/populate_user`, RequestMethod.GET); +} \ No newline at end of file